Merge remote-tracking branch 'upstream/main'

This commit is contained in:
kjs 2025-12-17 15:02:32 +09:00
commit b0e5dffa6e
92 changed files with 9025 additions and 2200 deletions

View File

@ -3245,6 +3245,7 @@ export const resetUserPassword = async (
/**
* ( )
* column_labels
*/
export async function getTableSchema(
req: AuthenticatedRequest,
@ -3264,20 +3265,25 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema에서 컬럼 정보 가져오기
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = `
SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position
ic.column_name,
ic.data_type,
ic.is_nullable,
ic.column_default,
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
cl.column_label,
cl.display_order
FROM information_schema.columns ic
LEFT JOIN column_labels cl
ON cl.table_name = ic.table_name
AND cl.column_name = ic.column_name
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
`;
const columns = await query<any>(schemaQuery, [tableName]);
@ -3290,9 +3296,10 @@ export async function getTableSchema(
return;
}
// 컬럼 정보를 간단한 형태로 변환
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({
name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type,
nullable: col.is_nullable === "YES",
default: col.column_default,

View File

@ -424,18 +424,16 @@ export class EntityJoinController {
config.referenceTable
);
// 현재 display_column으로 사용 중인 컬럼 제외
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
const currentDisplayColumn =
config.displayColumn || config.displayColumns[0];
const availableColumns = columns.filter(
(col) => col.columnName !== currentDisplayColumn
);
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
return {
joinConfig: config,
tableName: config.referenceTable,
currentDisplayColumn: currentDisplayColumn,
availableColumns: availableColumns.map((col) => ({
availableColumns: columns.map((col) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType,

View File

@ -51,3 +51,4 @@ router.get("/data/:groupCode", getAutoFillData);
export default router;

View File

@ -47,3 +47,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
export default router;

View File

@ -63,3 +63,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
export default router;

View File

@ -51,3 +51,4 @@ router.get("/options/:exclusionCode", getExcludedOptions);
export default router;

View File

@ -332,6 +332,8 @@ export class MenuCopyService {
/**
*
* - flowId
* - dataflowConfig.flowConfig.flowId selectedDiagramId
*/
private async collectFlows(
screenIds: Set<number>,
@ -340,6 +342,7 @@ export class MenuCopyService {
logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`);
const flowIds = new Set<number>();
const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = [];
for (const screenId of screenIds) {
const layoutsResult = await client.query<ScreenLayout>(
@ -352,13 +355,35 @@ export class MenuCopyService {
// webTypeConfig.dataflowConfig.flowConfig.flowId
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
if (flowId) {
const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown";
if (flowId && typeof flowId === "number" && flowId > 0) {
if (!flowIds.has(flowId)) {
flowIds.add(flowId);
flowDetails.push({ flowId, flowName, screenId });
logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`);
}
}
// selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음)
const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) {
if (!flowIds.has(selectedDiagramId)) {
flowIds.add(selectedDiagramId);
flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId });
logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`);
}
}
}
}
if (flowIds.size > 0) {
logger.info(`✅ 플로우 수집 완료: ${flowIds.size}`);
logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`);
} else {
logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`);
}
return flowIds;
}
@ -473,16 +498,22 @@ export class MenuCopyService {
}
}
// flowId 매핑 (숫자 또는 숫자 문자열)
if (key === "flowId") {
// flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열)
// selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환
if (key === "flowId" || key === "selectedDiagramId") {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue)) {
if (!isNaN(numValue) && numValue > 0) {
const newId = flowIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
logger.debug(
logger.info(
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value}${newId}`
);
} else {
// 매핑이 없으면 경고 로그
logger.warn(
` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음`
);
}
}
}
@ -742,6 +773,8 @@ export class MenuCopyService {
/**
*
* - + (ID )
* -
*/
private async copyFlows(
flowIds: Set<number>,
@ -757,10 +790,11 @@ export class MenuCopyService {
}
logger.info(`🔄 플로우 복사 중: ${flowIds.size}`);
logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`);
for (const originalFlowId of flowIds) {
try {
// 1) flow_definition 조회
// 1) 원본 flow_definition 조회
const flowDefResult = await client.query<FlowDefinition>(
`SELECT * FROM flow_definition WHERE id = $1`,
[originalFlowId]
@ -772,8 +806,29 @@ export class MenuCopyService {
}
const flowDef = flowDefResult.rows[0];
logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`);
// 2) flow_definition 복사
// 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인
const existingFlowResult = await client.query<{ id: number }>(
`SELECT id FROM flow_definition
WHERE company_code = $1 AND name = $2 AND table_name = $3
LIMIT 1`,
[targetCompanyCode, flowDef.name, flowDef.table_name]
);
let newFlowId: number;
if (existingFlowResult.rows.length > 0) {
// 기존 플로우가 있으면 재사용
newFlowId = existingFlowResult.rows[0].id;
flowIdMap.set(originalFlowId, newFlowId);
logger.info(
` ♻️ 기존 플로우 재사용: ${originalFlowId}${newFlowId} (${flowDef.name})`
);
continue; // 스텝/연결 복사 생략 (기존 것 사용)
}
// 3) 새 flow_definition 복사
const newFlowResult = await client.query<{ id: number }>(
`INSERT INTO flow_definition (
name, description, table_name, is_active,
@ -792,11 +847,11 @@ export class MenuCopyService {
]
);
const newFlowId = newFlowResult.rows[0].id;
newFlowId = newFlowResult.rows[0].id;
flowIdMap.set(originalFlowId, newFlowId);
logger.info(
` ✅ 플로우 복사: ${originalFlowId}${newFlowId} (${flowDef.name})`
` ✅ 플로우 신규 복사: ${originalFlowId}${newFlowId} (${flowDef.name})`
);
// 3) flow_step 복사

View File

@ -1751,7 +1751,7 @@ export class ScreenManagementService {
// 기타
label: "text-display",
code: "select-basic",
entity: "select-basic",
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
category: "select-basic",
};

View File

@ -1447,7 +1447,8 @@ export class TableManagementService {
tableName,
columnName,
actualValue,
paramIndex
paramIndex,
operator // operator 전달 (equals면 직접 매칭)
);
default:
@ -1676,7 +1677,8 @@ export class TableManagementService {
tableName: string,
columnName: string,
value: any,
paramIndex: number
paramIndex: number,
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
): Promise<{
whereClause: string;
values: any[];
@ -1688,7 +1690,7 @@ export class TableManagementService {
columnName
);
// 🆕 배열 처리: IN 절 사용
// 배열 처리: IN 절 사용
if (Array.isArray(value)) {
if (value.length === 0) {
// 빈 배열이면 항상 false 조건
@ -1720,13 +1722,35 @@ export class TableManagementService {
}
if (typeof value === "string" && value.trim() !== "") {
const displayColumn = entityTypeInfo.displayColumn || "name";
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
if (operator === "equals") {
logger.info(
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
);
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
const referenceColumn = entityTypeInfo.referenceColumn || "id";
const referenceTable = entityTypeInfo.referenceTable;
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn;
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
);
}
// 참조 테이블의 표시 컬럼으로 검색
return {
whereClause: `EXISTS (
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = ${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex}
)`,
@ -1754,6 +1778,66 @@ export class TableManagementService {
}
}
/**
* (entityJoinService와 )
* : *_name > name > label/*_label > title > referenceColumn
*/
private async findDisplayColumnForTable(
tableName: string,
referenceColumn?: string
): Promise<string> {
try {
const result = await query<{ column_name: string }>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
const allColumns = result.map((r) => r.column_name);
// entityJoinService와 동일한 우선순위
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
const nameColumn = allColumns.find(
(col) => col.endsWith("_name") && col !== "company_name"
);
if (nameColumn) {
return nameColumn;
}
// 2. name 컬럼
if (allColumns.includes("name")) {
return "name";
}
// 3. label 또는 *_label 컬럼
const labelColumn = allColumns.find(
(col) => col === "label" || col.endsWith("_label")
);
if (labelColumn) {
return labelColumn;
}
// 4. title 컬럼
if (allColumns.includes("title")) {
return "title";
}
// 5. 참조 컬럼 (referenceColumn)
if (referenceColumn && allColumns.includes(referenceColumn)) {
return referenceColumn;
}
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
return allColumns.find((col) => col !== "id") || "id";
} catch (error) {
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
return referenceColumn || "id"; // 오류 시 기본값
}
}
/**
*
*/

View File

@ -583,3 +583,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -356,3 +356,4 @@
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?

View File

@ -0,0 +1,345 @@
# 즉시 저장(quickInsert) 버튼 액션 구현 계획서
## 1. 개요
### 1.1 목적
화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현
### 1.2 사용 사례
- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장
### 1.3 화면 구성 예시
```
┌─────────────────────────────────────────────────────────────┐
│ [entity 선택박스] [버튼: quickInsert] │
│ ┌─────────────────────────────┐ ┌──────────────┐ │
│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │
│ └─────────────────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 2. 기술 설계
### 2.1 버튼 액션 타입 추가
```typescript
// types/screen-management.ts
type ButtonActionType =
| "save"
| "cancel"
| "delete"
| "edit"
| "add"
| "search"
| "reset"
| "submit"
| "close"
| "popup"
| "navigate"
| "custom"
| "quickInsert" // 🆕 즉시 저장
```
### 2.2 quickInsert 설정 구조
```typescript
interface QuickInsertColumnMapping {
targetColumn: string; // 저장할 테이블의 컬럼명
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
// sourceType별 추가 설정
sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID
sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명
fixedValue?: any; // fixed: 고정값
userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode)
}
interface QuickInsertConfig {
targetTable: string; // 저장할 테이블명
columnMappings: QuickInsertColumnMapping[];
// 저장 후 동작
afterInsert?: {
refreshRightPanel?: boolean; // 우측 패널 새로고침
clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록
showSuccessMessage?: boolean; // 성공 메시지 표시
successMessage?: string; // 커스텀 성공 메시지
};
// 중복 체크 (선택사항)
duplicateCheck?: {
enabled: boolean;
columns: string[]; // 중복 체크할 컬럼들
errorMessage?: string; // 중복 시 에러 메시지
};
}
interface ButtonComponentConfig {
// 기존 설정들...
actionType: ButtonActionType;
// 🆕 quickInsert 전용 설정
quickInsertConfig?: QuickInsertConfig;
}
```
### 2.3 데이터 흐름
```
1. 사용자가 entity 선택박스에서 설비 선택
└─ equipment_code = "EQ-001" (내부값)
└─ 표시: "MCT-01 - 머시닝센터 #1"
2. 사용자가 "설비 추가" 버튼 클릭
3. quickInsert 핸들러 실행
├─ columnMappings 순회
│ ├─ equipment_code: component에서 값 가져오기 → "EQ-001"
│ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001"
└─ INSERT 데이터 구성
{
equipment_code: "EQ-001",
process_code: "PRC-001",
company_code: "COMPANY_7", // 자동 추가
writer: "wace" // 자동 추가
}
4. API 호출: POST /api/table-management/tables/process_equipment/add
5. 성공 시
├─ 성공 메시지 표시
├─ 우측 패널(카드/테이블) 새로고침
└─ 선택박스 초기화
```
---
## 3. 구현 계획
### 3.1 Phase 1: 타입 정의 및 설정 UI
| 작업 | 파일 | 설명 |
|------|------|------|
| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 |
| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 |
### 3.2 Phase 2: 버튼 액션 핸들러 구현
| 작업 | 파일 | 설명 |
|------|------|------|
| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 |
| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 |
### 3.3 Phase 3: 테스트 및 검증
| 작업 | 설명 |
|------|------|
| 3-1 | 공정별 설비 화면에서 테스트 |
| 3-2 | 중복 저장 방지 테스트 |
| 3-3 | 에러 처리 테스트 |
---
## 4. 상세 구현
### 4.1 ButtonConfigPanel 설정 UI
```
┌─────────────────────────────────────────────────────────────┐
│ 버튼 액션 타입 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 즉시 저장 (quickInsert) ▼ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ─────────────── 즉시 저장 설정 ─────────────── │
│ │
│ 대상 테이블 * │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ process_equipment ▼ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 컬럼 매핑 [+ 추가] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 매핑 #1 [삭제] │ │
│ │ 대상 컬럼: equipment_code │ │
│ │ 값 소스: 컴포넌트 선택 │ │
│ │ 컴포넌트: [equipment-select ▼] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 매핑 #2 [삭제] │ │
│ │ 대상 컬럼: process_code │ │
│ │ 값 소스: 좌측 패널 데이터 │ │
│ │ 소스 컬럼: process_code │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ─────────────── 저장 후 동작 ─────────────── │
│ │
│ ☑ 우측 패널 새로고침 │
│ ☑ 선택박스 초기화 │
│ ☑ 성공 메시지 표시 │
│ │
│ ─────────────── 중복 체크 (선택) ─────────────── │
│ │
│ ☐ 중복 체크 활성화 │
│ 체크 컬럼: equipment_code, process_code │
│ 에러 메시지: 이미 등록된 설비입니다. │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 핸들러 구현 (의사 코드)
```typescript
const handleQuickInsert = async (config: QuickInsertConfig) => {
// 1. 컬럼 매핑에서 값 수집
const insertData: Record<string, any> = {};
for (const mapping of config.columnMappings) {
let value: any;
switch (mapping.sourceType) {
case "component":
// 같은 화면의 컴포넌트에서 값 가져오기
value = getComponentValue(mapping.sourceComponentId);
break;
case "leftPanel":
// 분할 패널 좌측 선택 데이터에서 값 가져오기
value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn];
break;
case "fixed":
value = mapping.fixedValue;
break;
case "currentUser":
value = user?.[mapping.userField];
break;
}
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
}
}
// 2. 필수값 검증
if (Object.keys(insertData).length === 0) {
toast.error("저장할 데이터가 없습니다.");
return;
}
// 3. 중복 체크 (설정된 경우)
if (config.duplicateCheck?.enabled) {
const isDuplicate = await checkDuplicate(
config.targetTable,
config.duplicateCheck.columns,
insertData
);
if (isDuplicate) {
toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return;
}
}
// 4. API 호출
try {
await tableTypeApi.addTableData(config.targetTable, insertData);
// 5. 성공 후 동작
if (config.afterInsert?.showSuccessMessage) {
toast.success(config.afterInsert.successMessage || "저장되었습니다.");
}
if (config.afterInsert?.refreshRightPanel) {
// 우측 패널 새로고침 트리거
onRefresh?.();
}
if (config.afterInsert?.clearComponents) {
// 지정된 컴포넌트 초기화
for (const componentId of config.afterInsert.clearComponents) {
clearComponentValue(componentId);
}
}
} catch (error) {
toast.error("저장에 실패했습니다.");
}
};
```
---
## 5. 컴포넌트 간 통신 방안
### 5.1 문제점
- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함
- 현재는 각 컴포넌트가 독립적으로 동작
### 5.2 해결 방안: formData 활용
현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음.
```typescript
// InteractiveScreenViewerDynamic.tsx
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
// entity 선택박스에서 값 변경 시
const handleFormDataChange = (fieldName: string, value: any) => {
setLocalFormData(prev => ({ ...prev, [fieldName]: value }));
};
// 버튼 클릭 시 formData에서 값 가져오기
const getComponentValue = (componentId: string) => {
// componentId로 컴포넌트의 columnName 찾기
const component = allComponents.find(c => c.id === componentId);
if (component?.columnName) {
return formData[component.columnName];
}
return undefined;
};
```
---
## 6. 테스트 시나리오
### 6.1 정상 케이스
1. 좌측 테이블에서 공정 "PRC-001" 선택
2. 우측 설비 선택박스에서 "MCT-01" 선택
3. "설비 추가" 버튼 클릭
4. `process_equipment` 테이블에 데이터 저장 확인
5. 우측 카드/테이블에 새 항목 표시 확인
### 6.2 에러 케이스
1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지
2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지
3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지
### 6.3 엣지 케이스
1. 동일 설비 연속 추가 시도
2. 네트워크 오류 시 재시도
3. 권한 없는 사용자의 저장 시도
---
## 7. 일정
| Phase | 작업 | 예상 시간 |
|-------|------|----------|
| Phase 1 | 타입 정의 및 설정 UI | 1시간 |
| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 |
| Phase 3 | 테스트 및 검증 | 30분 |
| **합계** | | **2시간 30분** |
---
## 8. 향후 확장 가능성
1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가
2. **수정 모드**: 기존 데이터 수정 기능
3. **조건부 저장**: 특정 조건 만족 시에만 저장
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장

View File

@ -18,10 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
function ScreenViewPage() {
const params = useParams();
@ -307,6 +308,7 @@ function ScreenViewPage() {
return (
<ScreenPreviewProvider isPreviewMode={false}>
<ActiveTabProvider>
<TableOptionsProvider>
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
{/* 레이아웃 준비 중 로딩 표시 */}
@ -787,6 +789,7 @@ function ScreenViewPage() {
/>
</div>
</TableOptionsProvider>
</ActiveTabProvider>
</ScreenPreviewProvider>
);
}

View File

@ -148,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
switch (format) {
case "date":
return new Date(value).toLocaleDateString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "datetime":
return new Date(value).toLocaleString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":

View File

@ -2,7 +2,19 @@
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
import {
ArrowLeft,
Save,
Loader2,
Grid3x3,
Move,
Box,
Package,
Truck,
Check,
ParkingCircle,
RefreshCw,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -545,8 +557,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 레이아웃 데이터 로드
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
// 레이아웃 로드 함수
const loadLayout = async () => {
try {
setIsLoading(true);
@ -651,9 +664,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
@ -686,9 +697,30 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}
};
// 위젯 새로고침 핸들러
const handleRefresh = async () => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(
"저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?",
);
if (!confirmed) return;
}
setIsRefreshing(true);
setSelectedObject(null);
setMaterials([]);
await loadLayout();
setIsRefreshing(false);
toast({
title: "새로고침 완료",
description: "데이터가 갱신되었습니다.",
});
};
// 초기 로드
useEffect(() => {
loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거
}, [layoutId]);
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
useEffect(() => {
@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
};
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
const loadMaterialCountsForLocations = async (
locaKeys: string[],
dbConnectionId?: number,
materialTableName?: string,
) => {
const connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return;
@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find(
(mc: any) =>
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
(mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
);
if (materialCount) {
// count 또는 material_count 필드 사용
@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? (
<>
@ -1620,27 +1663,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Button>
</div>
<div className="flex gap-2">
<Select
value={selectedTemplateId}
onValueChange={(val) => setSelectedTemplateId(val)}
>
<Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
</SelectTrigger>
<SelectContent>
{mappingTemplates.length === 0 ? (
<div className="text-muted-foreground px-2 py-1 text-xs">
릿
</div>
<div className="text-muted-foreground px-2 py-1 text-xs"> 릿 </div>
) : (
mappingTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
<div className="flex flex-col">
<span>{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground text-[10px]">
{tpl.description}
</span>
<span className="text-muted-foreground text-[10px]">{tpl.description}</span>
)}
</div>
</SelectItem>
@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}}
onLoadColumns={async (tableName: string) => {
try {
const response = await ExternalDbConnectionAPI.getTableColumns(
selectedDbConnection,
tableName,
);
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
if (response.success && response.data) {
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
return response.data.map((col: any) => ({
column_name:
typeof col === "string"
? col
: col.column_name || col.COLUMN_NAME || String(col),
column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
data_type: col.data_type || col.DATA_TYPE,
description: col.description || col.COLUMN_COMMENT || undefined,
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
>
</Button>
<Button
onClick={handleSaveTemplate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@ -41,9 +41,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 검색 및 필터
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false);
// 레이아웃 데이터 로드
useEffect(() => {
// 레이아웃 데이터 로드 함수
const loadLayout = async () => {
try {
setIsLoading(true);
@ -61,9 +61,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
@ -111,7 +109,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey
obj.locaKey,
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
@ -143,7 +141,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
return { ...obj, materialCount: countData.count };
}
return obj;
})
}),
);
}
} else {
@ -162,9 +160,25 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}
};
// 위젯 새로고침 핸들러
const handleRefresh = async () => {
setIsRefreshing(true);
setSelectedObject(null);
setMaterials([]);
setShowInfoPanel(false);
await loadLayout();
setIsRefreshing(false);
toast({
title: "새로고침 완료",
description: "데이터가 갱신되었습니다.",
});
};
// 초기 로드
useEffect(() => {
loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 - 무한 루프 방지
}, [layoutId]);
// Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
</div>
{/* 메인 영역 */}
@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
/>
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
: ({locationObj.position.x.toFixed(1)},{" "}
{locationObj.position.z.toFixed(1)})
: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
</p>
{locationObj.locaKey && (
<p className="text-muted-foreground mt-0.5 text-[10px]">

View File

@ -29,7 +29,6 @@ import {
Plus,
Minus,
ArrowRight,
Save,
Zap,
} from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
@ -52,12 +51,6 @@ interface ColumnMapping {
systemColumn: string | null;
}
interface UploadConfig {
name: string;
type: string;
mappings: ColumnMapping[];
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
const [configName, setConfigName] = useState<string>("");
const [configType, setConfigType] = useState<string>("");
// 4단계: 확인
const [isUploading, setIsUploading] = useState(false);
@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
setDisplayData(data.slice(0, 10));
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) {
const columns = Object.keys(data[0]);
@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
setDisplayData(data.slice(0, 10));
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) {
const columns = Object.keys(data[0]);
@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
};
// 자동 매핑
// 자동 매핑 - 컬럼명과 라벨 모두 비교
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
const normalizedExcelCol = excelCol.toLowerCase().trim();
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
);
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
);
}
return {
excelColumn: excelCol,
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
@ -265,28 +266,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
);
};
// 설정 저장
const handleSaveConfig = () => {
if (!configName.trim()) {
toast.error("거래처명을 입력해주세요.");
return;
}
const config: UploadConfig = {
name: configName,
type: configType,
mappings: columnMappings,
};
const savedConfigs = JSON.parse(
localStorage.getItem("excelUploadConfigs") || "[]"
);
savedConfigs.push(config);
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
toast.success("설정이 저장되었습니다.");
};
// 다음 단계
const handleNext = () => {
if (currentStep === 1 && !file) {
@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setIsUploading(true);
try {
const mappedData = displayData.map((row) => {
// allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
const mappedData = allData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
setConfigName("");
setConfigType("");
}
}, [open]);
@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
)}
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */}
{/* 3단계: 컬럼 매핑 */}
{currentStep === 3 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
<div className="space-y-4">
<div>
<h3 className="mb-3 text-sm font-semibold sm:text-base"> </h3>
{/* 상단: 제목 + 자동 매핑 버튼 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={handleAutoMapping}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 중앙: 매핑 리스트 */}
{/* 매핑 리스트 */}
<div className="space-y-2">
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div>
@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함" />
<SelectValue placeholder="매핑 안함">
{mapping.systemColumn
? (() => {
const col = systemColumns.find(c => c.name === mapping.systemColumn);
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
value={col.name}
className="text-xs sm:text-sm"
>
{col.name} ({col.type})
{col.label || col.name} ({col.type})
</SelectItem>
))}
</SelectContent>
@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
))}
</div>
</div>
{/* 오른쪽: 현재 설정 저장 */}
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="mb-4 flex items-center gap-2">
<Save className="h-4 w-4" />
<h3 className="text-sm font-semibold sm:text-base"> </h3>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
*
</Label>
<Input
id="config-name"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
placeholder="거래처 선택"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
</Label>
<Input
id="config-type"
value={configType}
onChange={(e) => setConfigType(e.target.value)}
placeholder="유형을 입력하세요 (예: 원자재)"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<Button
type="button"
variant="default"
size="sm"
onClick={handleSaveConfig}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
>
<Save className="mr-2 h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<span className="font-medium">:</span> {selectedSheet}
</p>
<p>
<span className="font-medium"> :</span> {displayData.length}
<span className="font-medium"> :</span> {allData.length}
</p>
<p>
<span className="font-medium">:</span> {tableName}

View File

@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
interface ScreenModalState {
isOpen: boolean;
@ -666,6 +667,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<ActiveTabProvider>
<TableOptionsProvider>
<div
className="relative mx-auto bg-white"
@ -738,6 +740,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
})}
</div>
</TableOptionsProvider>
</ActiveTabProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>

View File

@ -180,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
switch (format) {
case "date":
return new Date(value).toLocaleDateString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "datetime":
return new Date(value).toLocaleString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":

View File

@ -203,14 +203,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier);
try {
// user_id 또는 vehicle_number로 조회 (시간은 KST로 변환)
// user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
const query = `SELECT
id, vehicle_number, user_id,
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
last_trip_start,
last_trip_end,
last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
last_empty_start,
last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
@ -281,15 +281,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return;
try {
// 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환)
// 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT
id, vehicle_number, user_id,
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
last_trip_start,
last_trip_end,
last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
last_empty_start,
last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles

View File

@ -40,6 +40,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
const [error, setError] = useState<string | null>(null);
const [screenInfo, setScreenInfo] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
// 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
@ -47,6 +48,10 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
const splitPanelContext = useSplitPanelContext();
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
const selectedLeftData = splitPanelContext?.selectedLeftData;
const prevSelectedLeftDataRef = useRef<string>("");
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
const { userId, userName, companyCode } = useAuth();
@ -71,7 +76,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 필드 값 변경 핸들러
const handleFieldChange = useCallback((fieldName: string, value: any) => {
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
setFormData((prev) => ({
...prev,
[fieldName]: value,
@ -83,35 +87,55 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
loadScreenData();
}, [embedding.childScreenId]);
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
// initialFormData 변경 시 formData 업데이트 (수정 모드)
useEffect(() => {
if (initialFormData && Object.keys(initialFormData).length > 0) {
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
setFormData(initialFormData);
}
}, [initialFormData]);
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
useEffect(() => {
// 우측 화면인 경우에만 적용
if (position !== "right" || !splitPanelContext) return;
// 자동 데이터 전달이 비활성화된 경우 스킵
if (splitPanelContext.disableAutoDataTransfer) {
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
if (position !== "right" || !splitPanelContext) {
return;
}
const mappedData = splitPanelContext.getMappedParentData();
if (Object.keys(mappedData).length > 0) {
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
setFormData((prev) => ({
...prev,
...mappedData,
}));
// 자동 데이터 전달이 비활성화된 경우 스킵
if (splitPanelContext.disableAutoDataTransfer) {
return;
}
}, [position, splitPanelContext, splitPanelContext?.selectedLeftData]);
// 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지)
const currentDataStr = JSON.stringify(selectedLeftData || {});
if (prevSelectedLeftDataRef.current === currentDataStr) {
return; // 실제 값이 같으면 스킵
}
prevSelectedLeftDataRef.current = currentDataStr;
// 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집
const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string);
// 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기
const initializedFormData: Record<string, any> = {};
// 먼저 모든 컬럼을 빈 문자열로 초기화
allColumnNames.forEach((colName) => {
initializedFormData[colName] = "";
});
// selectedLeftData가 있으면 해당 값으로 덮어쓰기
if (selectedLeftData && Object.keys(selectedLeftData).length > 0) {
Object.keys(selectedLeftData).forEach((key) => {
// null/undefined는 빈 문자열로, 나머지는 그대로
initializedFormData[key] = selectedLeftData[key] ?? "";
});
}
setFormData(initializedFormData);
setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링
}, [position, splitPanelContext, selectedLeftData, layout]);
// 선택 변경 이벤트 전파
useEffect(() => {
@ -128,13 +152,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
const screenData = await screenApi.getScreen(embedding.childScreenId);
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
screenId: embedding.childScreenId,
hasData: !!screenData,
tableName: screenData?.tableName,
screenName: screenData?.name || screenData?.screenName,
position,
});
if (screenData) {
setScreenInfo(screenData);
} else {
@ -399,11 +416,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
};
return (
<div
key={component.id}
className="absolute"
style={componentStyle}
>
<div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
<DynamicComponentRenderer
component={component}
isInteractive={true}

View File

@ -27,29 +27,12 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
// config에서 splitRatio 추출 (기본값 50)
const configSplitRatio = config?.splitRatio ?? 50;
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
screenId,
config,
leftScreenId: config?.leftScreenId,
rightScreenId: config?.rightScreenId,
configSplitRatio,
parentDataMapping: config?.parentDataMapping,
configKeys: config ? Object.keys(config) : [],
});
// 🆕 initialFormData 별도 로그 (명확한 확인)
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
hasInitialFormData: !!initialFormData,
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
initialFormData: initialFormData,
});
// 드래그로 조절 가능한 splitRatio 상태
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
React.useEffect(() => {
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
setSplitRatio(configSplitRatio);
}, [configSplitRatio]);

View File

@ -26,12 +26,56 @@ interface EditModalState {
onSave?: () => void;
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
tableName?: string; // 🆕 테이블명 (그룹 조회용)
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
saveButtonConfig?: {
enableDataflowControl?: boolean;
dataflowConfig?: any;
dataflowTiming?: string;
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
}
interface EditModalProps {
className?: string;
}
/**
* ( )
* action.type이 "save" button-primary
*/
const findSaveButtonInComponents = (components: any[]): any | null => {
if (!components || !Array.isArray(components)) return null;
for (const comp of components) {
// button-primary이고 action.type이 save인 경우
if (
comp.componentType === "button-primary" &&
comp.componentConfig?.action?.type === "save"
) {
return comp;
}
// conditional-container의 sections 내부 탐색
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
for (const section of comp.componentConfig.sections) {
if (section.screenId) {
// 조건부 컨테이너의 내부 화면은 별도로 로드해야 함
// 여기서는 null 반환하고, loadSaveButtonConfig에서 처리
continue;
}
}
}
// 자식 컴포넌트가 있으면 재귀 탐색
if (comp.children && Array.isArray(comp.children)) {
const found = findSaveButtonInComponents(comp.children);
if (found) return found;
}
}
return null;
};
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const { user } = useAuth();
const [modalState, setModalState] = useState<EditModalState>({
@ -44,6 +88,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
onSave: undefined,
groupByColumns: undefined,
tableName: undefined,
buttonConfig: undefined,
buttonContext: undefined,
saveButtonConfig: undefined,
});
const [screenData, setScreenData] = useState<{
@ -115,11 +162,88 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
};
};
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
const loadSaveButtonConfig = async (targetScreenId: number): Promise<{
enableDataflowControl?: boolean;
dataflowConfig?: any;
dataflowTiming?: string;
} | null> => {
try {
// 1. 대상 화면의 레이아웃 조회
const layoutData = await screenApi.getLayout(targetScreenId);
if (!layoutData?.components) {
console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId);
return null;
}
// 2. 저장 버튼 찾기
let saveButton = findSaveButtonInComponents(layoutData.components);
// 3. conditional-container가 있는 경우 내부 화면도 탐색
if (!saveButton) {
for (const comp of layoutData.components) {
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
for (const section of comp.componentConfig.sections) {
if (section.screenId) {
try {
const innerLayoutData = await screenApi.getLayout(section.screenId);
saveButton = findSaveButtonInComponents(innerLayoutData?.components || []);
if (saveButton) {
console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
sectionScreenId: section.screenId,
sectionLabel: section.label,
});
break;
}
} catch (innerError) {
console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
}
}
}
if (saveButton) break;
}
}
}
if (!saveButton) {
console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
return null;
}
// 4. webTypeConfig에서 제어로직 설정 추출
const webTypeConfig = saveButton.webTypeConfig;
if (webTypeConfig?.enableDataflowControl) {
const config = {
enableDataflowControl: webTypeConfig.enableDataflowControl,
dataflowConfig: webTypeConfig.dataflowConfig,
dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after",
};
console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
return config;
}
console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
return null;
} catch (error) {
console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
return null;
}
};
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } =
event.detail;
const handleOpenEditModal = async (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
if (screenId) {
const config = await loadSaveButtonConfig(screenId);
if (config) {
saveButtonConfig = config;
}
}
setModalState({
isOpen: true,
@ -131,6 +255,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
onSave,
groupByColumns, // 🆕 그룹핑 컬럼
tableName, // 🆕 테이블명
buttonConfig, // 🆕 버튼 설정
buttonContext, // 🆕 버튼 컨텍스트
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
});
// 편집 데이터로 폼 데이터 초기화
@ -578,6 +705,46 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", {
hasSaveButtonConfig: !!modalState.saveButtonConfig,
hasButtonConfig: !!modalState.buttonConfig,
controlConfig,
});
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
// buttonActions의 executeAfterSaveControl 동적 import
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
// 제어로직 실행
await ButtonActionExecutor.executeAfterSaveControl(
controlConfig,
{
formData: modalState.editData,
screenId: modalState.buttonContext?.screenId || modalState.screenId,
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
userId: user?.userId,
companyCode: user?.companyCode,
onRefresh: modalState.onSave,
}
);
console.log("✅ [EditModal] 제어로직 실행 완료");
} else {
console.log(" [EditModal] 저장 후 실행할 제어로직 없음");
}
} catch (controlError) {
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
// 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시)
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
handleClose();
} else {
toast.info("변경된 내용이 없습니다.");
@ -612,6 +779,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
await ButtonActionExecutor.executeAfterSaveControl(
controlConfig,
{
formData,
screenId: modalState.buttonContext?.screenId || modalState.screenId,
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
userId: user?.userId,
companyCode: user?.companyCode,
onRefresh: modalState.onSave,
}
);
console.log("✅ [EditModal] 제어로직 실행 완료");
}
} catch (controlError) {
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
handleClose();
} else {
throw new Error(response.message || "생성에 실패했습니다.");
@ -654,6 +852,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
await ButtonActionExecutor.executeAfterSaveControl(
controlConfig,
{
formData,
screenId: modalState.buttonContext?.screenId || modalState.screenId,
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
userId: user?.userId,
companyCode: user?.companyCode,
onRefresh: modalState.onSave,
}
);
console.log("✅ [EditModal] 제어로직 실행 완료");
}
} catch (controlError) {
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");

View File

@ -55,6 +55,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
@ -184,6 +185,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
@ -308,6 +311,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
}, [currentPage, searchValues, loadData, component.tableName]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
filterColumn: string;
filterValue: any;
} | null>(null);
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인
if (targetTable === component.tableName) {
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
tableName: component.tableName,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
}
};
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
return () => {
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
};
}, [component.tableName]);
// relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => {
if (relatedButtonFilter) {
loadData(1, searchValues);
}
}, [relatedButtonFilter]);
// 카테고리 타입 컬럼의 값 매핑 로드
useEffect(() => {
const loadCategoryMappings = async () => {
@ -702,10 +740,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
return;
}
// 🆕 RelatedDataButtons 필터 적용
let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
// 검색 파라미터와 연결 필터 병합
const mergedSearchParams = {
...searchParams,
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
console.log("🔍 데이터 조회 시작:", {
@ -713,6 +758,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
page,
pageSize,
linkedFilterValues,
relatedButtonFilterValues,
mergedSearchParams,
});
@ -819,7 +865,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false);
}
},
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가
);
// 현재 사용자 정보 로드
@ -947,7 +993,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
return newSet;
});
}, []);
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
}, [data, splitPanelContext, splitPanelPosition]);
// 전체 선택/해제 핸들러
const handleSelectAll = useCallback(

View File

@ -51,6 +51,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
/**
* 🔗
@ -2103,6 +2104,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return (
<SplitPanelProvider>
<ActiveTabProvider>
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
@ -2211,6 +2213,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</DialogContent>
</Dialog>
</TableOptionsProvider>
</ActiveTabProvider>
</SplitPanelProvider>
);
};

View File

@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps {
id: number;
tableName?: string;
};
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
onSave?: () => Promise<void>;
onRefresh?: () => void;
onFlowRefresh?: () => void;
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
// 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
userId?: string;
userName?: string;
companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달)
// 그룹 데이터 (EditModal에서 전달)
groupedData?: Record<string, any>[];
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
// 비활성화할 필드 목록 (EditModal에서 전달)
disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
// EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
}
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData,
disabledFields = [],
isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData,
parentTabId,
parentTabsComponentId,
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth();
@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp}
isInteractive={true}
formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
originalData={originalData || undefined}
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
menuObjid={menuObjid}
userId={user?.userId}
userName={user?.userName}
companyCode={user?.companyCode}
onSave={onSave}
allComponents={allComponents}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(selectedRows, selectedData) => {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
console.log("테이블에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData);
}}
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
groupedData={groupedData}
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
disabledFields={disabledFields}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData, stepId) => {
console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId });
console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
}}
onRefresh={
onRefresh ||
(() => {
// 부모로부터 전달받은 onRefresh 또는 기본 동작
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
console.log("InteractiveScreenViewerDynamic onRefresh 호출");
})
}
onFlowRefresh={onFlowRefresh}
onClose={() => {
// buttonActions.ts가 이미 처리함
}}
// 탭 관련 정보 전달
parentTabId={parentTabId}
parentTabsComponentId={parentTabsComponentId}
/>
);
}
@ -584,6 +589,219 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
};
// 🆕 즉시 저장(quickInsert) 액션 핸들러
const handleQuickInsertAction = async () => {
// componentConfig에서 quickInsertConfig 가져오기
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
if (!quickInsertConfig?.targetTable) {
toast.error("대상 테이블이 설정되지 않았습니다.");
return;
}
// 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용)
let targetTableColumns: string[] = [];
try {
const { default: apiClient } = await import("@/lib/api/client");
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
}
} catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error);
}
// 2. 컬럼 매핑에서 값 수집
const insertData: Record<string, any> = {};
const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) {
let value: any;
switch (mapping.sourceType) {
case "component":
// 같은 화면의 컴포넌트에서 값 가져오기
// 방법1: sourceColumnName 사용
if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) {
value = formData[mapping.sourceColumnName];
console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
}
// 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용
else if (mapping.sourceComponentId) {
const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
if (sourceComp) {
const fieldName = (sourceComp as any).columnName || sourceComp.id;
value = formData[fieldName];
console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`);
}
}
break;
case "leftPanel":
// 분할 패널 좌측 선택 데이터에서 값 가져오기
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
}
break;
case "fixed":
value = mapping.fixedValue;
break;
case "currentUser":
if (mapping.userField) {
switch (mapping.userField) {
case "userId":
value = user?.userId;
break;
case "userName":
value = userName;
break;
case "companyCode":
value = user?.companyCode;
break;
case "deptCode":
value = authUser?.deptCode;
break;
}
}
break;
}
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
}
}
// 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우)
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) {
continue;
}
// 대상 테이블에 해당 컬럼이 없으면 스킵
if (!targetTableColumns.includes(key)) {
continue;
}
// 시스템 컬럼 제외
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
if (systemColumns.includes(key)) {
continue;
}
// _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) {
continue;
}
// 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
}
}
console.log("🚀 quickInsert 최종 데이터:", insertData);
// 4. 필수값 검증
if (Object.keys(insertData).length === 0) {
toast.error("저장할 데이터가 없습니다. 값을 선택해주세요.");
return;
}
// 5. 중복 체크 (설정된 경우)
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
try {
const { default: apiClient } = await import("@/lib/api/client");
// 중복 체크를 위한 검색 조건 구성
const searchConditions: Record<string, any> = {};
for (const col of quickInsertConfig.duplicateCheck.columns) {
if (insertData[col] !== undefined) {
searchConditions[col] = { value: insertData[col], operator: "equals" };
}
}
console.log("📍 중복 체크 조건:", searchConditions);
// 기존 데이터 조회
const checkResponse = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
{
page: 1,
pageSize: 1,
search: searchConditions,
}
);
console.log("📍 중복 체크 응답:", checkResponse.data);
// data 배열이 있고 길이가 0보다 크면 중복
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return;
}
} catch (error) {
console.error("중복 체크 오류:", error);
// 중복 체크 실패 시 계속 진행
}
}
// 6. API 호출
try {
const { default: apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData
);
if (response.data?.success) {
// 7. 성공 후 동작
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
}
// 데이터 새로고침 (테이블리스트, 카드 디스플레이)
if (quickInsertConfig.afterInsert?.refreshData !== false) {
console.log("📍 데이터 새로고침 이벤트 발송");
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("refreshTable"));
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
}
}
// 지정된 컴포넌트 초기화
if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) {
for (const componentId of quickInsertConfig.afterInsert.clearComponents) {
const targetComp = allComponents.find((c: any) => c.id === componentId);
if (targetComp) {
const fieldName = (targetComp as any).columnName || targetComp.id;
onFormDataChange?.(fieldName, "");
}
}
}
} else {
toast.error(response.data?.message || "저장에 실패했습니다.");
}
} catch (error: any) {
console.error("quickInsert 오류:", error);
toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다.");
}
};
const handleClick = async () => {
try {
const actionType = config?.actionType || "save";
@ -604,6 +822,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
case "custom":
await handleCustomAction();
break;
case "quickInsert":
await handleQuickInsertAction();
break;
default:
// console.log("🔘 기본 버튼 클릭");
}

View File

@ -101,6 +101,46 @@ export const SaveModal: React.FC<SaveModalProps> = ({
};
}, [onClose]);
// 필수 항목 검증
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
components.forEach((component) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
console.log("🔍 필수 항목 검증:", {
componentId: component.id,
columnName,
label,
isRequired,
"component.required": component.required,
"style.required": component.style?.required,
"componentConfig.required": component.componentConfig?.required,
value: formData[columnName || ""],
});
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
missingFields.push(label || columnName);
}
}
});
return {
isValid: missingFields.length === 0,
missingFields,
};
};
// 저장 핸들러
const handleSave = async () => {
if (!screenData || !screenId) return;
@ -111,6 +151,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
return;
}
// ✅ 필수 항목 검증
const validation = validateRequiredFields();
if (!validation.isValid) {
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
return;
}
try {
setIsSaving(true);

View File

@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
// 엔티티 타입용 참조 테이블 정보
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
};
});

View File

@ -16,6 +16,7 @@ import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
// 🆕 제목 블록 타입
interface TitleBlock {
@ -333,23 +334,73 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const loadModalMappingColumns = async () => {
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
// allComponents에서 split-panel-layout 또는 table-list 찾기
let sourceTableName: string | null = null;
console.log("[openModalWithData] 컬럼 로드 시작:", {
allComponentsCount: allComponents.length,
currentTableName,
targetScreenId: config.action?.targetScreenId,
});
// 모든 컴포넌트 타입 로그
allComponents.forEach((comp, idx) => {
const compType = comp.componentType || (comp as any).componentConfig?.type;
console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`);
});
for (const comp of allComponents) {
const compType = comp.componentType || (comp as any).componentConfig?.type;
const compConfig = (comp as any).componentConfig || {};
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
// 분할 패널의 좌측 테이블명
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
(comp as any).componentConfig?.leftTableName;
sourceTableName = compConfig?.leftPanel?.tableName ||
compConfig?.leftTableName ||
compConfig?.tableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
// split-panel-layout2 타입 (새로운 분할 패널)
if (compType === "split-panel-layout2") {
sourceTableName = compConfig?.leftPanel?.tableName ||
compConfig?.tableName ||
compConfig?.leftTableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
// 테이블 리스트 타입
if (compType === "table-list") {
sourceTableName = (comp as any).componentConfig?.tableName;
sourceTableName = compConfig?.tableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
// 🆕 모든 컴포넌트에서 tableName 찾기 (폴백)
if (!sourceTableName && compConfig?.tableName) {
sourceTableName = compConfig.tableName;
console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`);
break;
}
}
// 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명)
if (!sourceTableName && currentTableName) {
sourceTableName = currentTableName;
console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`);
}
if (!sourceTableName) {
console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다.");
}
// 소스 테이블 컬럼 로드
if (sourceTableName) {
try {
@ -361,11 +412,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
name: col.name || col.columnName || col.column_name,
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
}));
setModalSourceColumns(columns);
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
}
}
} catch (error) {
@ -379,8 +430,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
try {
// 타겟 화면 정보 가져오기
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
if (screenResponse.data.success && screenResponse.data.data) {
const targetTableName = screenResponse.data.data.tableName;
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
if (targetTableName) {
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
if (columnResponse.data.success) {
@ -390,23 +445,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
name: col.name || col.columnName || col.column_name,
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
}));
setModalTargetColumns(columns);
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
}
}
} else {
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
}
}
} catch (error) {
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
}
} else {
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
}
};
loadModalMappingColumns();
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
}, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => {
@ -584,9 +643,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData">📦 </SelectItem>
<SelectItem value="openModalWithData"> + 🆕</SelectItem>
<SelectItem value="transferData"> </SelectItem>
<SelectItem value="openModalWithData"> + </SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="excel_download"> </SelectItem>
@ -1158,11 +1219,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</p>
</div>
) : (
<div className="space-y-2">
<div className="space-y-3">
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
{/* 소스 필드 선택 (Combobox) */}
<div className="flex-1">
<div key={index} className="rounded-md border bg-background p-3 space-y-2">
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Popover
open={modalSourcePopoverOpen[index] || false}
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
@ -1171,15 +1233,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{mapping.sourceField
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
: "소스 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
@ -1187,7 +1251,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
value={modalSourceSearch[index] || ""}
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalSourceColumns.map((col) => (
@ -1208,9 +1272,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
<span className="truncate">{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
)}
</CommandItem>
))}
@ -1221,10 +1285,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Popover>
</div>
<span className="text-xs text-muted-foreground"></span>
{/* 화살표 표시 */}
<div className="flex justify-center">
<span className="text-xs text-muted-foreground"></span>
</div>
{/* 타겟 필드 선택 (Combobox) */}
<div className="flex-1">
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Popover
open={modalTargetPopoverOpen[index] || false}
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
@ -1233,15 +1301,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{mapping.targetField
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
: "타겟 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
@ -1249,7 +1319,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
value={modalTargetSearch[index] || ""}
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalTargetColumns.map((col) => (
@ -1270,9 +1340,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
<span className="truncate">{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
)}
</CommandItem>
))}
@ -1284,20 +1354,23 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
{/* 삭제 버튼 */}
<div className="flex justify-end pt-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
size="sm"
className="h-6 text-[10px] text-destructive hover:bg-destructive/10"
onClick={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings.splice(index, 1);
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
}}
>
<X className="h-4 w-4" />
<X className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
))}
</div>
)}
@ -2998,6 +3071,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
{component.componentConfig?.action?.type === "quickInsert" && (
<QuickInsertConfigSection
component={component}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
)}
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Database, Search, Plus, Trash2 } from "lucide-react";
import { Database, Search, Info } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
interface EntityField {
name: string;
label: string;
type: string;
visible: boolean;
}
import { tableTypeApi } from "@/lib/api/screen";
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component,
@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
// 로컬 상태
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: true,
error: null,
});
// 로컬 상태 (UI 관련 설정만)
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
entityType: config.entityType || "",
displayFields: config.displayFields || [],
searchFields: config.searchFields || [],
valueField: config.valueField || "id",
labelField: config.labelField || "name",
valueField: config.valueField || "",
labelField: config.labelField || "",
multiple: config.multiple || false,
searchable: config.searchable !== false, // 기본값 true
placeholder: config.placeholder || "엔티티를 선택하세요",
searchable: config.searchable !== false,
placeholder: config.placeholder || "항목을 선택하세요",
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
pageSize: config.pageSize || 20,
minSearchLength: config.minSearchLength || 1,
@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
filters: config.filters || {},
});
// 새 필드 추가용 상태
const [newFieldName, setNewFieldName] = useState("");
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldType, setNewFieldType] = useState("string");
// 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
useEffect(() => {
const loadReferenceInfo = async () => {
// 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
const tableName = widget.tableName;
const columnName = widget.columnName;
if (!tableName || !columnName) {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "테이블 또는 컬럼 정보가 없습니다.",
});
return;
}
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(tableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === columnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
error: null,
});
// webTypeConfig에 참조 테이블 정보 자동 설정
if (finalRefTable) {
const newConfig = {
...localConfig,
valueField: finalRefColumn || "id",
labelField: finalDispColumn || "name",
};
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [widget.tableName, widget.columnName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id",
labelField: currentConfig.labelField || "name",
valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
placeholder: currentConfig.placeholder || "항목을 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1,
@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {},
});
}, [widget.webTypeConfig]);
}, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", localConfig);
};
// 필드 추가
const addDisplayField = () => {
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
const newField: EntityField = {
name: newFieldName.trim(),
label: newFieldLabel.trim(),
type: newFieldType,
visible: true,
};
const newFields = [...localConfig.displayFields, newField];
updateConfig("displayFields", newFields);
setNewFieldName("");
setNewFieldLabel("");
setNewFieldType("string");
};
// 필드 제거
const removeDisplayField = (index: number) => {
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
updateConfig("displayFields", newFields);
};
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], [field]: value };
setLocalConfig({ ...localConfig, displayFields: newFields });
};
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
const handleFieldBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
// 검색 필드 토글
const toggleSearchField = (fieldName: string) => {
const currentSearchFields = localConfig.searchFields || [];
const newSearchFields = currentSearchFields.includes(fieldName)
? currentSearchFields.filter((f) => f !== fieldName)
: [...currentSearchFields, fieldName];
updateConfig("searchFields", newSearchFields);
};
// 기본 엔티티 타입들
const commonEntityTypes = [
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
];
// 기본 엔티티 타입 적용
const applyEntityType = (entityType: string) => {
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
if (!entityConfig) return;
updateConfig("entityType", entityType);
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
name: field,
label: field.charAt(0).toUpperCase() + field.slice(1),
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
visible: true,
}));
updateConfig("displayFields", defaultFields);
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
};
// 필드 타입 옵션
const fieldTypes = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "boolean", label: "불린" },
{ value: "email", label: "이메일" },
{ value: "url", label: "URL" },
];
return (
<Card>
<CardHeader>
@ -182,214 +191,97 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Database className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-xs"> .</CardDescription>
<CardDescription className="text-xs">
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 기본 설정 */}
{/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<h4 className="text-sm font-medium flex items-center gap-2">
<span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-[10px]">
</span>
</h4>
<div className="space-y-2">
<Label htmlFor="entityType" className="text-xs">
</Label>
<Input
id="entityType"
value={localConfig.entityType || ""}
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
onBlur={handleInputBlur}
placeholder="user, product, department..."
className="text-xs"
/>
{referenceInfo.isLoading ? (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
) : referenceInfo.error ? (
<div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
<p className="text-xs text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : !referenceInfo.referenceTable ? (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
.
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : (
<div className="bg-muted/50 rounded-md border p-3 space-y-2">
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "-"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "-"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-2 gap-2">
{commonEntityTypes.map((entity) => (
<Button
key={entity.value}
size="sm"
variant="outline"
onClick={() => applyEntityType(entity.value)}
className="text-xs"
>
{entity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiEndpoint" className="text-xs">
API
</Label>
<Input
id="apiEndpoint"
value={localConfig.apiEndpoint || ""}
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
onBlur={handleInputBlur}
placeholder="/api/entities/user"
className="text-xs"
/>
</div>
</div>
{/* 필드 매핑 */}
{/* UI 모드 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<h4 className="text-sm font-medium">UI </h4>
<div className="grid grid-cols-2 gap-2">
{/* UI 모드 선택 */}
<div className="space-y-2">
<Label htmlFor="valueField" className="text-xs">
<Label htmlFor="uiMode" className="text-xs">
UI
</Label>
<Input
id="valueField"
value={localConfig.valueField || ""}
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
onBlur={handleInputBlur}
placeholder="id"
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="labelField" className="text-xs">
</Label>
<Input
id="labelField"
value={localConfig.labelField || ""}
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
onBlur={handleInputBlur}
placeholder="name"
className="text-xs"
/>
</div>
</div>
</div>
{/* 표시 필드 관리 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
{/* 새 필드 추가 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={newFieldLabel}
onChange={(e) => setNewFieldLabel(e.target.value)}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={addDisplayField}
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
className="text-xs"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 현재 필드 목록 */}
<div className="space-y-2">
<Label className="text-xs"> ({localConfig.displayFields.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.displayFields.map((field, index) => (
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Switch
checked={field.visible}
onCheckedChange={(checked) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], visible: checked };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Input
value={field.name}
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
onBlur={handleFieldBlur}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={field.label}
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
onBlur={handleFieldBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select
value={field.type}
onValueChange={(value) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], type: value };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
value={(localConfig as any).uiMode || "combo"}
onValueChange={(value) => updateConfig("uiMode" as any, value)}
>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
<SelectTrigger className="text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
<SelectItem value="select"> (Select)</SelectItem>
<SelectItem value="modal"> (Modal)</SelectItem>
<SelectItem value="combo"> + (Combo)</SelectItem>
<SelectItem value="autocomplete"> (Autocomplete)</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
onClick={() => toggleSearchField(field.name)}
className="p-1 text-xs"
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
>
<Search className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => removeDisplayField(index)}
className="p-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
<p className="text-[10px] text-muted-foreground">
{(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
{(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
{((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
{(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
</p>
</div>
))}
</div>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
@ -400,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
value={localConfig.placeholder || ""}
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
onBlur={handleInputBlur}
placeholder="엔티티를 선택하세요"
placeholder="항목을 선택하세요"
className="text-xs"
/>
</div>
@ -418,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
className="text-xs"
/>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
@ -456,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="searchable" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="searchable"
@ -470,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="multiple" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="multiple"
@ -480,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 필터 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="filters" className="text-xs">
JSON
</Label>
<Textarea
id="filters"
value={JSON.stringify(localConfig.filters || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateConfig("filters", parsed);
} catch {
// 유효하지 않은 JSON은 무시
}
}}
placeholder='{"status": "active", "department": "IT"}'
className="font-mono text-xs"
rows={3}
/>
<p className="text-muted-foreground text-xs">API JSON .</p>
</div>
</div>
{/* 상태 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
@ -516,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="required" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="required"
@ -530,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="readonly" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="readonly"
@ -547,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<div className="space-y-2">
<div className="flex items-center gap-2 rounded border bg-white p-2">
<Database className="h-4 w-4 text-gray-400" />
<span className="flex-1 text-xs text-muted-foreground">{localConfig.placeholder || "엔티티를 선택하세요"}</span>
<span className="flex-1 text-xs text-muted-foreground">
{localConfig.placeholder || "항목을 선택하세요"}
</span>
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
</div>
{localConfig.displayFields.length > 0 && (
<div className="text-muted-foreground text-xs">
<div className="font-medium"> :</div>
<div className="mt-1 flex flex-wrap gap-1">
{localConfig.displayFields
.filter((f) => f.visible)
.map((field, index) => (
<span key={index} className="rounded bg-gray-100 px-2 py-1">
{field.label}
{localConfig.searchFields.includes(field.name) && " 🔍"}
</span>
))}
</div>
</div>
)}
<div className="text-muted-foreground text-xs">
: {localConfig.entityType || "미정"} : {localConfig.valueField} :{" "}
{localConfig.labelField}
{localConfig.multiple && " • 다중선택"}
{localConfig.required && " • 필수"}
<div>: {referenceInfo.referenceTable || "미설정"}</div>
<div> : {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
<div> : {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
{localConfig.multiple && <span> / </span>}
{localConfig.required && <span> / </span>}
</div>
</div>
</div>
@ -582,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
};
EntityConfigPanel.displayName = "EntityConfigPanel";

View File

@ -0,0 +1,658 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Plus, X, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { QuickInsertConfig, QuickInsertColumnMapping } from "@/types/screen-management";
import { apiClient } from "@/lib/api/client";
interface QuickInsertConfigSectionProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[];
currentTableName?: string;
}
interface TableOption {
name: string;
label: string;
}
interface ColumnOption {
name: string;
label: string;
}
export const QuickInsertConfigSection: React.FC<QuickInsertConfigSectionProps> = ({
component,
onUpdateProperty,
allComponents = [],
currentTableName,
}) => {
// 현재 설정 가져오기
const config: QuickInsertConfig = component.componentConfig?.action?.quickInsertConfig || {
targetTable: "",
columnMappings: [],
afterInsert: {
refreshData: true,
clearComponents: [],
showSuccessMessage: true,
successMessage: "저장되었습니다.",
},
duplicateCheck: {
enabled: false,
columns: [],
errorMessage: "이미 존재하는 데이터입니다.",
},
};
// 테이블 목록 상태
const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablePopoverOpen, setTablePopoverOpen] = useState(false);
const [tableSearch, setTableSearch] = useState("");
// 대상 테이블 컬럼 목록 상태
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
// 매핑별 Popover 상태
const [targetColumnPopoverOpen, setTargetColumnPopoverOpen] = useState<Record<number, boolean>>({});
const [targetColumnSearch, setTargetColumnSearch] = useState<Record<number, string>>({});
const [sourceComponentPopoverOpen, setSourceComponentPopoverOpen] = useState<Record<number, boolean>>({});
const [sourceComponentSearch, setSourceComponentSearch] = useState<Record<number, string>>({});
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setTablesLoading(true);
try {
const response = await apiClient.get("/table-management/tables");
if (response.data?.success && response.data?.data) {
setTables(
response.data.data.map((t: any) => ({
name: t.tableName,
label: t.displayName || t.tableName,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setTablesLoading(false);
}
};
loadTables();
}, []);
// 대상 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadTargetColumns = async () => {
if (!config.targetTable) {
setTargetColumns([]);
return;
}
setTargetColumnsLoading(true);
try {
const response = await apiClient.get(`/table-management/tables/${config.targetTable}/columns`);
if (response.data?.success && response.data?.data) {
// columns가 배열인지 확인 (data.columns 또는 data 직접)
const columns = response.data.data.columns || response.data.data;
setTargetColumns(
(Array.isArray(columns) ? columns : []).map((col: any) => ({
name: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
}))
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTargetColumns([]);
} finally {
setTargetColumnsLoading(false);
}
};
loadTargetColumns();
}, [config.targetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<QuickInsertConfig>) => {
const newConfig = { ...config, ...updates };
onUpdateProperty("componentConfig.action.quickInsertConfig", newConfig);
},
[config, onUpdateProperty]
);
// 컬럼 매핑 추가
const addMapping = () => {
const newMapping: QuickInsertColumnMapping = {
targetColumn: "",
sourceType: "component",
sourceComponentId: "",
};
updateConfig({
columnMappings: [...(config.columnMappings || []), newMapping],
});
};
// 컬럼 매핑 삭제
const removeMapping = (index: number) => {
const newMappings = [...(config.columnMappings || [])];
newMappings.splice(index, 1);
updateConfig({ columnMappings: newMappings });
};
// 컬럼 매핑 업데이트
const updateMapping = (index: number, updates: Partial<QuickInsertColumnMapping>) => {
const newMappings = [...(config.columnMappings || [])];
newMappings[index] = { ...newMappings[index], ...updates };
updateConfig({ columnMappings: newMappings });
};
// 필터링된 테이블 목록
const filteredTables = tables.filter(
(t) =>
t.name.toLowerCase().includes(tableSearch.toLowerCase()) ||
t.label.toLowerCase().includes(tableSearch.toLowerCase())
);
// 컴포넌트 목록 (entity 타입 우선)
const availableComponents = allComponents.filter((comp: any) => {
// entity 타입 또는 select 타입 컴포넌트 필터링
const widgetType = comp.widgetType || comp.componentType || "";
return widgetType === "entity" || widgetType === "select" || widgetType === "text";
});
return (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4 dark:bg-green-950/20">
<h4 className="text-sm font-medium text-foreground"> </h4>
<p className="text-xs text-muted-foreground">
.
</p>
{/* 대상 테이블 선택 */}
<div>
<Label> *</Label>
<Popover open={tablePopoverOpen} onOpenChange={setTablePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablePopoverOpen}
className="h-8 w-full justify-between text-xs"
disabled={tablesLoading}
>
{config.targetTable
? tables.find((t) => t.name === config.targetTable)?.label || config.targetTable
: "테이블을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="테이블 검색..."
value={tableSearch}
onValueChange={setTableSearch}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{filteredTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
updateConfig({ targetTable: table.name, columnMappings: [] });
setTablePopoverOpen(false);
setTableSearch("");
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-4 w-4", config.targetTable === table.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
<span className="text-[10px] text-muted-foreground">{table.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 매핑 */}
{config.targetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> </Label>
<Button type="button" variant="outline" size="sm" onClick={addMapping} className="h-6 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.columnMappings || []).length === 0 ? (
<div className="rounded border-2 border-dashed py-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{(config.columnMappings || []).map((mapping, index) => (
<Card key={index} className="p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> #{index + 1}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMapping(index)}
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 대상 컬럼 */}
<div>
<Label className="text-xs"> ( )</Label>
<Popover
open={targetColumnPopoverOpen[index] || false}
onOpenChange={(open) => setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
disabled={targetColumnsLoading}
>
{mapping.targetColumn
? targetColumns.find((c) => c.name === mapping.targetColumn)?.label || mapping.targetColumn
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={targetColumnSearch[index] || ""}
onValueChange={(v) => setTargetColumnSearch((prev) => ({ ...prev, [index]: v }))}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{targetColumns
.filter(
(c) =>
c.name.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) ||
c.label.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
updateMapping(index, { targetColumn: col.name });
setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: false }));
setTargetColumnSearch((prev) => ({ ...prev, [index]: "" }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 값 소스 타입 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={mapping.sourceType}
onValueChange={(value: "component" | "leftPanel" | "fixed" | "currentUser") => {
updateMapping(index, {
sourceType: value,
sourceComponentId: undefined,
sourceColumn: undefined,
fixedValue: undefined,
userField: undefined,
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component" className="text-xs">
</SelectItem>
<SelectItem value="leftPanel" className="text-xs">
</SelectItem>
<SelectItem value="fixed" className="text-xs">
</SelectItem>
<SelectItem value="currentUser" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 소스 타입별 추가 설정 */}
{mapping.sourceType === "component" && (
<div>
<Label className="text-xs"> </Label>
<Popover
open={sourceComponentPopoverOpen[index] || false}
onOpenChange={(open) => setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{mapping.sourceComponentId
? (() => {
const comp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
return comp?.label || comp?.columnName || mapping.sourceComponentId;
})()
: "컴포넌트 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="컴포넌트 검색..."
value={sourceComponentSearch[index] || ""}
onValueChange={(v) => setSourceComponentSearch((prev) => ({ ...prev, [index]: v }))}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableComponents
.filter((comp: any) => {
const search = (sourceComponentSearch[index] || "").toLowerCase();
const label = (comp.label || "").toLowerCase();
const colName = (comp.columnName || "").toLowerCase();
return label.includes(search) || colName.includes(search);
})
.map((comp: any) => (
<CommandItem
key={comp.id}
value={comp.id}
onSelect={() => {
// sourceComponentId와 함께 sourceColumnName도 저장 (formData 접근용)
updateMapping(index, {
sourceComponentId: comp.id,
sourceColumnName: comp.columnName || undefined,
});
setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: false }));
setSourceComponentSearch((prev) => ({ ...prev, [index]: "" }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceComponentId === comp.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{comp.label || comp.columnName || comp.id}</span>
<span className="text-[10px] text-muted-foreground">
{comp.widgetType || comp.componentType}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{mapping.sourceType === "leftPanel" && (
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="예: process_code"
value={mapping.sourceColumn || ""}
onChange={(e) => updateMapping(index, { sourceColumn: e.target.value })}
className="h-7 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
)}
{mapping.sourceType === "fixed" && (
<div>
<Label className="text-xs"></Label>
<Input
placeholder="고정값 입력"
value={mapping.fixedValue || ""}
onChange={(e) => updateMapping(index, { fixedValue: e.target.value })}
className="h-7 text-xs"
/>
</div>
)}
{mapping.sourceType === "currentUser" && (
<div>
<Label className="text-xs"> </Label>
<Select
value={mapping.userField || ""}
onValueChange={(value: "userId" | "userName" | "companyCode" | "deptCode") => {
updateMapping(index, { userField: value });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="userId" className="text-xs">
ID
</SelectItem>
<SelectItem value="userName" className="text-xs">
</SelectItem>
<SelectItem value="companyCode" className="text-xs">
</SelectItem>
<SelectItem value="deptCode" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</Card>
))}
</div>
)}
</div>
)}
{/* 저장 후 동작 설정 */}
{config.targetTable && (
<div className="space-y-3 rounded border bg-background p-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs font-normal"> </Label>
<Switch
checked={config.afterInsert?.refreshData ?? true}
onCheckedChange={(checked) => {
updateConfig({
afterInsert: { ...config.afterInsert, refreshData: checked },
});
}}
/>
</div>
<p className="text-[10px] text-muted-foreground -mt-2">
,
</p>
<div className="flex items-center justify-between">
<Label className="text-xs font-normal"> </Label>
<Switch
checked={config.afterInsert?.showSuccessMessage ?? true}
onCheckedChange={(checked) => {
updateConfig({
afterInsert: { ...config.afterInsert, showSuccessMessage: checked },
});
}}
/>
</div>
{config.afterInsert?.showSuccessMessage && (
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="저장되었습니다."
value={config.afterInsert?.successMessage || ""}
onChange={(e) => {
updateConfig({
afterInsert: { ...config.afterInsert, successMessage: e.target.value },
});
}}
className="h-7 text-xs"
/>
</div>
)}
</div>
)}
{/* 중복 체크 설정 */}
{config.targetTable && (
<div className="space-y-3 rounded border bg-background p-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Switch
checked={config.duplicateCheck?.enabled ?? false}
onCheckedChange={(checked) => {
updateConfig({
duplicateCheck: { ...config.duplicateCheck, enabled: checked },
});
}}
/>
</div>
{config.duplicateCheck?.enabled && (
<>
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 max-h-40 overflow-y-auto rounded border bg-background p-2">
{targetColumns.length === 0 ? (
<p className="text-[10px] text-muted-foreground"> ...</p>
) : (
<div className="space-y-1">
{targetColumns.map((col) => {
const isChecked = (config.duplicateCheck?.columns || []).includes(col.name);
return (
<div
key={col.name}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
onClick={() => {
const currentColumns = config.duplicateCheck?.columns || [];
const newColumns = isChecked
? currentColumns.filter((c) => c !== col.name)
: [...currentColumns, col.name];
updateConfig({
duplicateCheck: { ...config.duplicateCheck, columns: newColumns },
});
}}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {}}
className="h-3 w-3 flex-shrink-0"
/>
<span className="flex-1 text-xs whitespace-nowrap">
{col.label}{col.label !== col.name && ` (${col.name})`}
</span>
</div>
);
})}
</div>
)}
</div>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="이미 존재하는 데이터입니다."
value={config.duplicateCheck?.errorMessage || ""}
onChange={(e) => {
updateConfig({
duplicateCheck: { ...config.duplicateCheck, errorMessage: e.target.value },
});
}}
className="h-7 text-xs"
/>
</div>
</>
)}
</div>
)}
{/* 사용 안내 */}
<div className="rounded-md bg-green-100 p-3 dark:bg-green-900/30">
<p className="text-xs text-green-900 dark:text-green-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3.
</p>
</div>
</div>
);
};
export default QuickInsertConfigSection;

View File

@ -46,6 +46,7 @@ interface DetailSettingsPanelProps {
currentTableName?: string; // 현재 화면의 테이블명
tables?: TableInfo[]; // 전체 테이블 목록
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
components?: ComponentData[]; // 현재 화면의 모든 컴포넌트 (연쇄관계 부모 필드 선택용)
}
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
@ -55,6 +56,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
currentTableName,
tables = [], // 기본값 빈 배열
currentScreenCompanyCode,
components = [], // 기본값 빈 배열
}) => {
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
const { webTypes } = useWebTypes({ active: "Y" });

View File

@ -943,6 +943,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Label className="text-xs"></Label>
</div>
)}
{/* 숨김 옵션 */}
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
onCheckedChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
}}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
</div>
</div>
</div>
);

View File

@ -7,15 +7,18 @@ import { X, Loader2 } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { cn } from "@/lib/utils";
import { useActiveTab } from "@/contexts/ActiveTabContext";
interface TabsWidgetProps {
component: TabsComponent;
className?: string;
style?: React.CSSProperties;
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
menuObjid?: number; // 부모 화면의 메뉴 OBJID
}
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
// ActiveTab context 사용
const { setActiveTab, removeTabsComponent } = useActiveTab();
const {
tabs = [],
defaultTab,
@ -25,12 +28,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
persistSelection = false,
} = component;
console.log("🎨 TabsWidget 렌더링:", {
componentId: component.id,
tabs,
tabsLength: tabs.length,
component,
});
const storageKey = `tabs-${component.id}-selected`;
@ -57,25 +54,35 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
}, [tabs]);
// 선택된 탭 변경 시 localStorage에 저장
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => {
if (persistSelection && typeof window !== "undefined") {
localStorage.setItem(storageKey, selectedTab);
}
}, [selectedTab, persistSelection, storageKey]);
// ActiveTab Context에 현재 활성 탭 정보 등록
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
if (currentTabInfo) {
setActiveTab(component.id, {
tabId: selectedTab,
tabsComponentId: component.id,
screenId: currentTabInfo.screenId,
label: currentTabInfo.label,
});
}
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
useEffect(() => {
return () => {
removeTabsComponent(component.id);
};
}, [component.id, removeTabsComponent]);
// 초기 로드 시 선택된 탭의 화면 불러오기
useEffect(() => {
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
console.log("🔄 초기 탭 로드:", {
selectedTab,
currentTab,
hasScreenId: !!currentTab?.screenId,
screenId: currentTab?.screenId,
});
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
console.log("📥 초기 화면 로딩 시작:", currentTab.screenId);
loadScreenLayout(currentTab.screenId);
}
}, [selectedTab, visibleTabs]);
@ -83,26 +90,20 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
// 화면 레이아웃 로드
const loadScreenLayout = async (screenId: number) => {
if (screenLayouts[screenId]) {
console.log("✅ 이미 로드된 화면:", screenId);
return; // 이미 로드됨
}
console.log("📥 화면 레이아웃 로딩 시작:", screenId);
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data });
if (response.data.success && response.data.data) {
console.log("✅ 화면 레이아웃 로드 완료:", screenId);
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
} else {
console.error("❌ 화면 레이아웃 로드 실패 - success false");
}
} catch (error) {
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
} finally {
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
}
@ -110,10 +111,9 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
// 탭 변경 핸들러
const handleTabChange = (tabId: string) => {
console.log("🔄 탭 변경:", tabId);
setSelectedTab(tabId);
// 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
setMountedTabs(prev => {
if (prev.has(tabId)) return prev;
const newSet = new Set(prev);
@ -123,10 +123,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
// 해당 탭의 화면 로드
const tab = visibleTabs.find((t) => t.id === tabId);
console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId });
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
console.log("📥 탭 변경 시 화면 로딩:", tab.screenId);
loadScreenLayout(tab.screenId);
}
};
@ -157,7 +154,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
};
if (visibleTabs.length === 0) {
console.log("⚠️ 보이는 탭이 없음");
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-muted-foreground text-sm"> </p>
@ -165,13 +161,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
);
}
console.log("🎨 TabsWidget 최종 렌더링:", {
visibleTabsCount: visibleTabs.length,
selectedTab,
screenLayoutsKeys: Object.keys(screenLayouts),
loadingScreensKeys: Object.keys(loadingScreens),
});
return (
<div className="flex h-full w-full flex-col pt-4" style={style}>
<Tabs
@ -233,14 +222,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
const layoutData = screenLayouts[tab.screenId];
const { components = [], screenResolution } = layoutData;
// 비활성 탭은 로그 생략
if (isActive) {
console.log("🎯 렌더링할 화면 데이터:", {
screenId: tab.screenId,
componentsCount: components.length,
screenResolution,
});
}
const designWidth = screenResolution?.width || 1920;
const designHeight = screenResolution?.height || 1080;
@ -260,16 +241,18 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
margin: "0 auto",
}}
>
{components.map((component: any) => (
{components.map((comp: any) => (
<InteractiveScreenViewerDynamic
key={component.id}
component={component}
key={comp.id}
component={comp}
allComponents={components}
screenInfo={{
id: tab.screenId,
tableName: layoutData.tableName,
}}
menuObjid={menuObjid}
parentTabId={tab.id}
parentTabsComponentId={component.id}
/>
))}
</div>

View File

@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
import {
RepeaterFieldGroupConfig,
RepeaterData,
RepeaterItemData,
RepeaterFieldDefinition,
CalculationFormula,
} from "@/types/repeater";
import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const breakpoint = previewBreakpoint || globalBreakpoint;
// 카테고리 매핑 데이터 (값 -> {label, color})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color: string }>>
>({});
// 설정 기본값
const {
@ -98,16 +106,30 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
useEffect(() => {
if (value.length > 0) {
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
if (value.length === 0) {
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
if (minItems > 0) {
const emptyItems = Array(minItems)
.fill(null)
.map(() => createEmptyItem());
setItems(emptyItems);
} else {
setItems([]);
}
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
return;
}
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
const calculatedFields = fields.filter(f => f.type === "calculated");
const calculatedFields = fields.filter((f) => f.type === "calculated");
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
const updatedValue = value.map(item => {
const updatedValue = value.map((item) => {
const updatedItem = { ...item };
let hasChange = false;
calculatedFields.forEach(calcField => {
calculatedFields.forEach((calcField) => {
const calculatedValue = calculateValue(calcField.formula, updatedItem);
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
updatedItem[calcField.name] = calculatedValue;
@ -133,13 +155,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
onChange?.(dataWithMeta);
} else {
// 🆕 기존 레코드 플래그 추가
const valueWithFlag = value.map(item => ({
const valueWithFlag = value.map((item) => ({
...item,
_existingRecord: !!item.id,
}));
setItems(valueWithFlag);
}
}
}, [value]);
// 항목 추가
@ -161,9 +182,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 항목 제거
const handleRemoveItem = (index: number) => {
if (items.length <= minItems) {
return;
}
// 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용)
// minItems 체크 제거 - 모든 항목 삭제 허용
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
const removedItem = items[index];
@ -207,8 +227,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
};
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
const calculatedFields = fields.filter(f => f.type === "calculated");
calculatedFields.forEach(calcField => {
const calculatedFields = fields.filter((f) => f.type === "calculated");
calculatedFields.forEach((calcField) => {
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
if (calculatedValue !== null) {
newItems[itemIndex][calcField.name] = calculatedValue;
@ -290,9 +310,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (!formula || !formula.field1) return null;
const value1 = parseFloat(item[formula.field1]) || 0;
const value2 = formula.field2
? (parseFloat(item[formula.field2]) || 0)
: (formula.constantValue ?? 0);
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
let result: number;
@ -341,10 +359,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
* @param format
* @returns
*/
const formatNumber = (
value: number | null,
format?: RepeaterFieldDefinition["numberFormat"]
): string => {
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
if (value === null || isNaN(value)) return "-";
let formattedValue = value;
@ -355,7 +370,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}
// 천 단위 구분자
let result = format?.useThousandSeparator !== false
let result =
format?.useThousandSeparator !== false
? formattedValue.toLocaleString("ko-KR", {
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
@ -373,10 +389,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly;
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
const commonProps = {
value: value || "",
disabled: isReadonly,
placeholder: field.placeholder,
placeholder: defaultPlaceholder,
required: field.required,
};
@ -386,11 +406,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const calculatedValue = calculateValue(field.formula, item);
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
return (
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
{formattedValue}
</span>
);
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
}
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
@ -436,7 +452,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (field.displayMode === "readonly") {
// select 타입인 경우 옵션에서 라벨 찾기
if (field.type === "select" && value && field.options) {
const option = field.options.find(opt => opt.value === value);
const option = field.options.find((opt) => opt.value === value);
return <span className="text-sm">{option?.label || value}</span>;
}
@ -461,16 +477,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
);
}
// 색상이 없으면 텍스트로 표시
return <span className="text-sm text-foreground">{categoryData.label}</span>;
return <span className="text-foreground text-sm">{categoryData.label}</span>;
}
}
// 일반 텍스트
return (
<span className="text-sm text-foreground">
{value || "-"}
</span>
);
return <span className="text-foreground text-sm">{value || "-"}</span>;
}
switch (field.type) {
@ -500,19 +512,43 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
rows={3}
className="resize-none min-w-[100px]"
className="min-w-[100px] resize-none"
/>
);
case "date":
case "date": {
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결)
let dateValue = value || "";
if (dateValue && typeof dateValue === "string") {
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출
if (dateValue.includes("T")) {
const date = new Date(dateValue);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
dateValue = `${year}-${month}-${day}`;
} else {
dateValue = "";
}
} else {
// 유효한 날짜인지 확인
const parsedDate = new Date(dateValue);
if (isNaN(parsedDate.getTime())) {
dateValue = ""; // 유효하지 않은 날짜면 빈 값
}
}
}
return (
<Input
{...commonProps}
value={dateValue}
type="date"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)}
className="min-w-[120px]"
/>
);
}
case "number":
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
@ -522,11 +558,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) {
return (
<span className="text-sm min-w-[80px] inline-block">
{formattedDisplay}
</span>
);
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
}
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
@ -540,11 +572,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
max={field.validation?.max}
className="pr-1"
/>
{value && (
<div className="text-muted-foreground text-[10px] mt-0.5">
{formattedDisplay}
</div>
)}
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
</div>
);
}
@ -597,8 +625,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
useEffect(() => {
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
const categoryFields = fields.filter(f => f.type === "category");
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
const categoryFields = fields.filter((f) => f.type === "category");
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
@ -632,7 +660,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({
setCategoryMappings((prev) => ({
...prev,
[columnName]: mapping,
}));
@ -644,12 +672,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
// material, division 등 조인된 테이블의 카테고리 필드
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
const joinedTableFields = ["material", "division", "status", "currency_code"];
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
if (fieldsToLoadFromJoinedTable.length > 0) {
// item_info 테이블에서 카테고리 매핑 로드
const joinedTableName = 'item_info';
const joinedTableName = "item_info";
for (const field of fieldsToLoadFromJoinedTable) {
const columnName = field.name;
@ -674,7 +702,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({
setCategoryMappings((prev) => ({
...prev,
[columnName]: mapping,
}));
@ -694,9 +722,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (fields.length === 0) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 text-center">
<p className="text-sm font-medium text-destructive"> </p>
<p className="mt-2 text-xs text-muted-foreground"> .</p>
<div className="border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
<p className="text-destructive text-sm font-medium"> </p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
</div>
</div>
);
@ -706,8 +734,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length === 0) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
<p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
{!readonly && !disabled && items.length < maxItems && (
<Button type="button" onClick={handleAddItem} size="sm">
<Plus className="mr-2 h-4 w-4" />
@ -740,7 +768,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{fields.map((field) => (
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
</TableHead>
))}
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
@ -751,7 +779,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableRow
key={itemIndex}
className={cn(
"bg-background transition-colors hover:bg-muted/50",
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
@ -762,15 +790,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
{itemIndex + 1}
</TableCell>
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
@ -783,13 +809,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && items.length > minItems && (
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
@ -829,12 +855,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-muted-foreground" />
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0 cursor-move" />
)}
{/* 인덱스 번호 */}
{showIndex && (
<CardTitle className="text-sm font-semibold text-foreground"> {itemIndex + 1}</CardTitle>
<CardTitle className="text-foreground text-sm font-semibold"> {itemIndex + 1}</CardTitle>
)}
</div>
@ -853,13 +879,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
)}
{/* 삭제 버튼 */}
{!readonly && !disabled && items.length > minItems && (
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
@ -873,9 +899,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<div className={getFieldsLayoutClass()}>
{fields.map((field) => (
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
<label className="text-sm font-medium text-foreground">
<label className="text-foreground text-sm font-medium">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
</label>
{renderField(field, itemIndex, item[field.name])}
</div>
@ -906,7 +932,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
)}
{/* 제한 안내 */}
<div className="flex justify-between text-xs text-muted-foreground">
<div className="text-muted-foreground flex justify-between text-xs">
<span>: {items.length} </span>
<span>
(: {minItems}, : {maxItems})

View File

@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
import {
RepeaterFieldGroupConfig,
RepeaterFieldDefinition,
RepeaterFieldType,
CalculationOperator,
CalculationFormula,
} from "@/types/repeater";
import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils";
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
};
// 필드 수정 (입력 중 - 로컬 상태만)
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
setLocalInputs(prev => ({
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
setLocalInputs((prev) => ({
...prev,
[index]: {
...prev[index],
[field]: value
}
[field]: value,
},
}));
};
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
newFields[index] = {
...newFields[index],
label: localInput.label,
placeholder: localInput.placeholder
placeholder: localInput.placeholder,
};
handleFieldsChange(newFields);
}
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p>
</div>
{/* 🆕 FK 컬럼 설정 (분할 패널용) */}
<div className="space-y-2">
<Label className="text-sm font-semibold">FK ( )</Label>
<Select
value={(config as any).fkColumn || "__none__"}
onValueChange={(value) => handleChange("fkColumn" as any, value === "__none__" ? undefined : value)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="FK 컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
.
<br />
: serial_no를 serial_no에 .
</p>
</div>
{/* 필드 정의 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
@ -263,7 +295,8 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
onSelect={() => {
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
const col = column as any;
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
const fieldType =
col.input_type || col.inputType || col.webType || col.widgetType || "text";
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
columnName: column.columnName,
@ -280,12 +313,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
type: fieldType as RepeaterFieldType,
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
setLocalInputs((prev) => ({
...prev,
[index]: {
label: column.columnLabel || column.columnName,
placeholder: prev[index]?.placeholder || ""
}
placeholder: prev[index]?.placeholder || "",
},
}));
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
}}
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-xs"></Label>
<Input
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
onChange={(e) => updateFieldLocal(index, "label", e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="필드 라벨"
className="h-8 w-full text-xs"
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1">
<Label className="text-xs">Placeholder</Label>
<Input
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
value={
localInputs[index]?.placeholder !== undefined
? localInputs[index].placeholder
: field.placeholder || ""
}
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="입력 안내"
className="h-8 w-full text-xs"
@ -380,9 +417,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px] text-blue-700"> 1</Label>
<Select
value={field.formula?.field1 || ""}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, field1: value } as CalculationFormula
})}
onValueChange={(value) =>
updateField(index, {
formula: { ...field.formula, field1: value } as CalculationFormula,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
@ -404,22 +443,40 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px] text-blue-700"></Label>
<Select
value={field.formula?.operator || "+"}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
})}
onValueChange={(value) =>
updateField(index, {
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="+" className="text-xs">+ </SelectItem>
<SelectItem value="-" className="text-xs">- </SelectItem>
<SelectItem value="*" className="text-xs">× </SelectItem>
<SelectItem value="/" className="text-xs">÷ </SelectItem>
<SelectItem value="%" className="text-xs">% </SelectItem>
<SelectItem value="round" className="text-xs"></SelectItem>
<SelectItem value="floor" className="text-xs"></SelectItem>
<SelectItem value="ceil" className="text-xs"></SelectItem>
<SelectItem value="+" className="text-xs">
+
</SelectItem>
<SelectItem value="-" className="text-xs">
-
</SelectItem>
<SelectItem value="*" className="text-xs">
×
</SelectItem>
<SelectItem value="/" className="text-xs">
÷
</SelectItem>
<SelectItem value="%" className="text-xs">
%
</SelectItem>
<SelectItem value="round" className="text-xs">
</SelectItem>
<SelectItem value="floor" className="text-xs">
</SelectItem>
<SelectItem value="ceil" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
@ -429,23 +486,26 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 2 / </Label>
<Select
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
value={
field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")
}
onValueChange={(value) => {
if (value.startsWith("__const__")) {
updateField(index, {
formula: {
...field.formula,
field2: undefined,
constantValue: 0
} as CalculationFormula
constantValue: 0,
} as CalculationFormula,
});
} else {
updateField(index, {
formula: {
...field.formula,
field2: value,
constantValue: undefined
} as CalculationFormula
constantValue: undefined,
} as CalculationFormula,
});
}
}}
@ -475,9 +535,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
min={0}
max={10}
value={field.formula?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
})}
onChange={(e) =>
updateField(index, {
formula: {
...field.formula,
decimalPlaces: parseInt(e.target.value) || 0,
} as CalculationFormula,
})
}
className="h-8 text-xs"
/>
</div>
@ -490,9 +555,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Input
type="number"
value={field.formula.constantValue}
onChange={(e) => updateField(index, {
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
})}
onChange={(e) =>
updateField(index, {
formula: {
...field.formula,
constantValue: parseFloat(e.target.value) || 0,
} as CalculationFormula,
})
}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox
id={`thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? true}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
onCheckedChange={(checked) =>
updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
})
}
/>
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
@ -519,9 +591,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
})
}
type="number"
min={0}
max={10}
@ -532,17 +606,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value },
})
}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value },
})
}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
@ -553,10 +631,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="rounded bg-white p-2 text-xs">
<span className="text-gray-500">: </span>
<code className="font-mono text-blue-700">
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
}
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
{field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
</code>
</div>
</div>
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox
id={`number-thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? false}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
onCheckedChange={(checked) =>
updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
})
}
/>
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
@ -583,9 +662,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
})
}
type="number"
min={0}
max={10}
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value },
})
}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value },
})
}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
className="h-8 w-full text-xs"
/>
<p className="text-[10px] text-muted-foreground">
<p className="text-muted-foreground text-[10px]">
</p>
</div>

View File

@ -0,0 +1,139 @@
"use client";
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
/**
*
*/
export interface ActiveTabInfo {
tabId: string; // 탭 고유 ID
tabsComponentId: string; // 부모 탭 컴포넌트 ID
screenId?: number; // 탭에 연결된 화면 ID
label?: string; // 탭 라벨
}
/**
* Context
*/
interface ActiveTabContextValue {
// 현재 활성 탭 정보 (탭 컴포넌트 ID -> 활성 탭 정보)
activeTabs: Map<string, ActiveTabInfo>;
// 활성 탭 설정
setActiveTab: (tabsComponentId: string, tabInfo: ActiveTabInfo) => void;
// 활성 탭 조회
getActiveTab: (tabsComponentId: string) => ActiveTabInfo | undefined;
// 특정 탭 컴포넌트의 활성 탭 ID 조회
getActiveTabId: (tabsComponentId: string) => string | undefined;
// 전체 활성 탭 ID 목록 (모든 탭 컴포넌트에서)
getAllActiveTabIds: () => string[];
// 탭 컴포넌트 제거 시 정리
removeTabsComponent: (tabsComponentId: string) => void;
}
const ActiveTabContext = createContext<ActiveTabContextValue | undefined>(undefined);
export const ActiveTabProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [activeTabs, setActiveTabs] = useState<Map<string, ActiveTabInfo>>(new Map());
/**
*
*/
const setActiveTab = useCallback((tabsComponentId: string, tabInfo: ActiveTabInfo) => {
setActiveTabs((prev) => {
const newMap = new Map(prev);
newMap.set(tabsComponentId, tabInfo);
return newMap;
});
}, []);
/**
*
*/
const getActiveTab = useCallback(
(tabsComponentId: string) => {
return activeTabs.get(tabsComponentId);
},
[activeTabs]
);
/**
* ID
*/
const getActiveTabId = useCallback(
(tabsComponentId: string) => {
return activeTabs.get(tabsComponentId)?.tabId;
},
[activeTabs]
);
/**
* ID
*/
const getAllActiveTabIds = useCallback(() => {
return Array.from(activeTabs.values()).map((info) => info.tabId);
}, [activeTabs]);
/**
*
*/
const removeTabsComponent = useCallback((tabsComponentId: string) => {
setActiveTabs((prev) => {
const newMap = new Map(prev);
newMap.delete(tabsComponentId);
return newMap;
});
}, []);
return (
<ActiveTabContext.Provider
value={{
activeTabs,
setActiveTab,
getActiveTab,
getActiveTabId,
getAllActiveTabIds,
removeTabsComponent,
}}
>
{children}
</ActiveTabContext.Provider>
);
};
/**
* Context Hook
*/
export const useActiveTab = () => {
const context = useContext(ActiveTabContext);
if (!context) {
// Context가 없으면 기본값 반환 (탭이 없는 화면에서 사용 시)
return {
activeTabs: new Map(),
setActiveTab: () => {},
getActiveTab: () => undefined,
getActiveTabId: () => undefined,
getAllActiveTabIds: () => [],
removeTabsComponent: () => {},
};
}
return context;
};
/**
* Optional Context Hook ( undefined )
*/
export const useActiveTabOptional = () => {
return useContext(ActiveTabContext);
};

View File

@ -5,7 +5,7 @@
"use client";
import React, { createContext, useContext, useCallback, useRef } from "react";
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger";
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
@ -15,6 +15,10 @@ interface ScreenContextValue {
tableName?: string;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
formData: Record<string, any>;
updateFormData: (fieldName: string, value: any) => void;
// 컴포넌트 등록
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
unregisterDataProvider: (componentId: string) => void;
@ -42,10 +46,31 @@ interface ScreenContextProviderProps {
/**
*
*/
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
export function ScreenContextProvider({
screenId,
tableName,
splitPanelPosition,
children,
}: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 폼 데이터 업데이트 함수
const updateFormData = useCallback((fieldName: string, value: any) => {
setFormData((prev) => {
const updated = { ...prev, [fieldName]: value };
logger.debug("ScreenContext formData 업데이트", {
fieldName,
valueType: typeof value,
isArray: Array.isArray(value),
});
return updated;
});
}, []);
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
dataProvidersRef.current.set(componentId, provider);
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
@ -83,10 +108,13 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<ScreenContextValue>(() => ({
const value = React.useMemo<ScreenContextValue>(
() => ({
screenId,
tableName,
splitPanelPosition,
formData,
updateFormData,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
@ -95,10 +123,13 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
}), [
}),
[
screenId,
tableName,
splitPanelPosition,
formData,
updateFormData,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
@ -107,7 +138,8 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
]);
],
);
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
}
@ -130,4 +162,3 @@ export function useScreenContext() {
export function useScreenContextOptional() {
return useContext(ScreenContext);
}

View File

@ -282,10 +282,6 @@ export function SplitPanelProvider({
* 🆕
*/
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, {
hasData: !!data,
dataKeys: data ? Object.keys(data) : [],
});
setSelectedLeftData(data);
}, []);
@ -323,11 +319,6 @@ export function SplitPanelProvider({
}
}
logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, {
autoMappedKeys: Object.keys(selectedLeftData),
explicitMappings: parentDataMapping.length,
finalKeys: Object.keys(mappedData),
});
return mappedData;
}, [selectedLeftData, parentDataMapping]);
@ -350,7 +341,6 @@ export function SplitPanelProvider({
}
}
logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues);
return filterValues;
}, [selectedLeftData, linkedFilters]);

View File

@ -3,12 +3,14 @@ import React, {
useContext,
useState,
useCallback,
useMemo,
ReactNode,
} from "react";
import {
TableRegistration,
TableOptionsContextValue,
} from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext";
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
undefined
@ -83,18 +85,41 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
const updatedTable = { ...table, dataCount: count };
const newMap = new Map(prev);
newMap.set(tableId, updatedTable);
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
tableId,
count,
updated: true,
});
return newMap;
}
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
return prev;
});
}, []);
// ActiveTab context 사용 (optional - 에러 방지)
const activeTabContext = useActiveTab();
/**
*
*/
const getActiveTabTables = useCallback(() => {
const allTables = Array.from(registeredTables.values());
const activeTabIds = activeTabContext.getAllActiveTabIds();
// 활성 탭이 없으면 탭에 속하지 않은 테이블만 반환
if (activeTabIds.length === 0) {
return allTables.filter(table => !table.parentTabId);
}
// 활성 탭에 속한 테이블 + 탭에 속하지 않은 테이블
return allTables.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
}, [registeredTables, activeTabContext]);
/**
*
*/
const getTablesForTab = useCallback((tabId: string) => {
const allTables = Array.from(registeredTables.values());
return allTables.filter(table => table.parentTabId === tabId);
}, [registeredTables]);
return (
<TableOptionsContext.Provider
value={{
@ -105,6 +130,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
updateTableDataCount,
selectedTableId,
setSelectedTableId,
getActiveTabTables,
getTablesForTab,
}}
>
{children}

View File

@ -193,3 +193,4 @@ export function applyAutoFillToFormData(
}

View File

@ -26,7 +26,14 @@ export const dataApi = {
size: number;
totalPages: number;
}> => {
const response = await apiClient.get(`/data/${tableName}`, { params });
// filters를 평탄화하여 쿼리 파라미터로 전달 (백엔드 ...filters 형식에 맞춤)
const { filters, ...restParams } = params || {};
const flattenedParams = {
...restParams,
...(filters || {}), // filters 객체를 평탄화
};
const response = await apiClient.get(`/data/${tableName}`, { params: flattenedParams });
const raw = response.data || {};
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];

View File

@ -2,6 +2,7 @@ import { apiClient } from "./client";
export interface TableColumn {
name: string;
label: string; // 컬럼 라벨 (column_labels 테이블에서 가져옴)
type: string;
nullable: boolean;
default: string | null;

View File

@ -132,6 +132,9 @@ export interface DynamicComponentRendererProps {
mode?: "view" | "edit";
// 모달 내에서 렌더링 여부
isInModal?: boolean;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
[key: string]: any;
}
@ -226,43 +229,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType);
// 🔍 디버깅: screen-split-panel 조회 결과 확인
if (componentType === "screen-split-panel") {
console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", {
componentType,
found: !!newComponent,
componentId: component.id,
componentConfig: component.componentConfig,
hasFormData: !!props.formData,
formDataKeys: props.formData ? Object.keys(props.formData) : [],
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
});
}
// 🔍 디버깅: select-basic 조회 결과 확인
if (componentType === "select-basic") {
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
componentType,
found: !!newComponent,
componentId: component.id,
componentConfig: component.componentConfig,
});
}
// 🔍 디버깅: text-input 컴포넌트 조회 결과 확인
if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") {
console.log("🔍 [DynamicComponentRenderer] text-input 조회:", {
componentType,
componentId: component.id,
componentLabel: component.label,
componentConfig: component.componentConfig,
webTypeConfig: (component as any).webTypeConfig,
autoGeneration: (component as any).autoGeneration,
found: !!newComponent,
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
});
}
if (newComponent) {
// 새 컴포넌트 시스템으로 렌더링
try {
@ -324,19 +290,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
currentValue = formData?.[fieldName] || "";
}
// 🆕 디버깅: text-input 값 추출 확인
if (componentType === "text-input" && formData && Object.keys(formData).length > 0) {
console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", {
componentId: component.id,
componentLabel: component.label,
columnName: (component as any).columnName,
fieldName,
currentValue,
hasFormData: !!formData,
formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만
});
}
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => {
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
@ -369,6 +322,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 숨김 값 추출
const hiddenValue = component.hidden || component.componentConfig?.hidden;
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
if (hiddenValue && isInteractive) {
return null;
}
// size.width와 size.height를 style.width와 style.height로 변환
const finalStyle: React.CSSProperties = {
...component.style,
@ -415,7 +373,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
mode,
// 🆕 화면 모드 (edit/view)와 컴포넌트 UI 모드 구분
screenMode: mode,
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
mode: component.componentConfig?.mode || mode,
isInModal,
readonly: component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly

View File

@ -376,6 +376,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴)
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
dataflowConfig: component.webTypeConfig?.dataflowConfig,
dataflowTiming: component.webTypeConfig?.dataflowTiming,
};
} else if (componentConfig.action && typeof componentConfig.action === "object") {
// 🔥 이미 객체인 경우에도 제어관리 설정 추가
@ -383,6 +384,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...componentConfig.action,
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
dataflowConfig: component.webTypeConfig?.dataflowConfig,
dataflowTiming: component.webTypeConfig?.dataflowTiming,
};
}
@ -827,10 +829,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
groupedData.length > 0
) {
effectiveSelectedRowsData = groupedData;
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
count: groupedData.length,
data: groupedData,
});
}
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
@ -846,12 +844,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
return item.originalData || item;
});
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
tableName: effectiveTableName,
count: modalData.length,
rawData: modalData,
extractedData: effectiveSelectedRowsData,
});
}
} catch (error) {
console.warn("modalDataStore 접근 실패:", error);
@ -868,6 +860,44 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return;
}
// 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단
// (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지)
if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) {
toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요.");
return;
}
// 수정(edit) 액션 검증
if (processedConfig.action.type === "edit") {
// 선택된 데이터가 없으면 경고
if (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) {
toast.warning("수정할 항목을 선택해주세요.");
return;
}
// groupByColumns 설정이 있으면 해당 컬럼 값이 유일한지 확인
const groupByColumns = processedConfig.action.groupByColumns;
if (groupByColumns && groupByColumns.length > 0 && effectiveSelectedRowsData.length > 1) {
// 첫 번째 그룹핑 컬럼 기준으로 중복 체크 (예: order_no)
const groupByColumn = groupByColumns[0];
const uniqueValues = new Set(
effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean)
);
if (uniqueValues.size > 1) {
// 컬럼명을 한글로 변환 (order_no -> 수주번호)
const columnLabels: Record<string, string> = {
order_no: "수주번호",
shipment_no: "출하번호",
purchase_no: "구매번호",
};
const columnLabel = columnLabels[groupByColumn] || groupByColumn;
toast.warning(`${columnLabel} 하나만 선택해주세요. (현재 ${uniqueValues.size}개 선택됨)`);
return;
}
}
}
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
const componentConfigs: Record<string, any> = {};
if (allComponents && Array.isArray(allComponents)) {
@ -878,17 +908,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 디버깅: tableName 확인
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
propsTableName: tableName,
contextTableName: screenContext?.tableName,
effectiveTableName,
propsScreenId: screenId,
contextScreenId: screenContext?.screenId,
effectiveScreenId,
});
// 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
// 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
// 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴
// (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록)
let splitPanelParentData: Record<string, any> | undefined;
@ -897,18 +917,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨)
if (splitPanelPosition !== "left") {
splitPanelParentData = splitPanelContext.getMappedParentData();
if (Object.keys(splitPanelParentData).length > 0) {
console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", {
splitPanelParentData,
splitPanelPosition,
isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안
});
}
}
}
// 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합
// 우선순위: props.formData > screenContext.formData > splitPanelParentData
const screenContextFormData = screenContext?.formData || {};
const propsFormData = formData || {};
// 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
// 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
effectiveFormData = { ...splitPanelParentData };
}
const context: ButtonActionContext = {
formData: formData || {},
formData: effectiveFormData,
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
@ -937,6 +964,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
componentConfigs,
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData,
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
splitPanelContext: splitPanelContext ? {
selectedLeftData: splitPanelContext.selectedLeftData,
refreshRightPanel: splitPanelContext.refreshRightPanel,
} : undefined,
} as ButtonActionContext;
// 확인이 필요한 액션인지 확인

View File

@ -4,6 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"
import { ComponentRendererProps } from "@/types/component";
import { CardDisplayConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { getFullImageUrl, apiClient } from "@/lib/api/client";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -61,20 +62,34 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 테이블 데이터 상태 관리
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정
const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부
const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
// 필터 상태 변경 래퍼 (로깅용)
// 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가)
const [refreshKey, setRefreshKey] = useState(0);
// refreshCardDisplay 이벤트 리스너
useEffect(() => {
const handleRefreshCardDisplay = () => {
console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침");
setRefreshKey((prev) => prev + 1);
};
window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay);
return () => {
window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay);
};
}, []);
// 필터 상태 변경 래퍼
const setFilters = useCallback((newFilters: TableFilter[]) => {
console.log("🎴 [CardDisplay] setFilters 호출됨:", {
componentId: component.id,
filtersCount: newFilters.length,
filters: newFilters,
});
setFiltersInternal(newFilters);
}, [component.id]);
}, []);
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
const [columnMeta, setColumnMeta] = useState<
@ -108,6 +123,58 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
setEditModalOpen(true);
};
// 삭제 핸들러
const handleCardDelete = async (data: any, index: number) => {
// 사용자 확인
if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) {
return;
}
try {
const tableNameToUse = tableName || component.componentConfig?.tableName;
if (!tableNameToUse) {
alert("테이블 정보가 없습니다.");
return;
}
// 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함)
const deleteData = [data];
// API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정)
// 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만
// axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용
const response = await apiClient.request({
method: 'DELETE',
url: `/table-management/tables/${tableNameToUse}/delete`,
data: deleteData,
headers: {
'Content-Type': 'application/json',
},
});
if (response.data.success) {
alert("삭제되었습니다.");
// 로컬 상태에서 삭제된 항목 제거
setLoadedTableData(prev => prev.filter((item, idx) => idx !== index));
// 선택된 항목이면 선택 해제
const cardKey = getCardKey(data, index);
if (selectedRows.has(cardKey)) {
const newSelectedRows = new Set(selectedRows);
newSelectedRows.delete(cardKey);
setSelectedRows(newSelectedRows);
}
} else {
alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`);
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류";
alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`);
}
};
// 편집 폼 데이터 변경 핸들러
const handleEditFormChange = (key: string, value: string) => {
setEditData((prev: any) => ({
@ -135,8 +202,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// loadTableData();
} catch (error) {
console.error("❌ 편집 저장 실패:", error);
alert("❌ 저장에 실패했습니다.");
alert("저장에 실패했습니다.");
}
};
@ -145,6 +211,25 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const loadTableData = async () => {
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
if (isDesignMode) {
setLoading(false);
setInitialLoadDone(true);
return;
}
// 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지)
// splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음
const isRightPanelEarly = splitPanelPosition === "right";
const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
if (isRightPanelEarly && !hasSelectedLeftDataEarly) {
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
// 초기 로드가 아닌 경우에는 데이터를 지우지 않음
if (!initialLoadDone) {
setLoadedTableData([]);
}
setLoading(false);
setInitialLoadDone(true);
return;
}
@ -152,18 +237,107 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
if (!tableNameToUse) {
setLoading(false);
setInitialLoadDone(true);
return;
}
// 연결 필터 확인 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false;
let hasSelectedLeftData = false;
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") ||
filter.targetColumn === tableNameToUse
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
const tableSpecificFilters: Record<string, any> = {};
for (const [key, value] of Object.entries(linkedFilterValues)) {
// key가 "테이블명.컬럼명" 형식인 경우
if (key.includes(".")) {
const [tblName, columnName] = key.split(".");
if (tblName === tableNameToUse) {
// 연결 필터는 코드 값이므로 equals 연산자 사용
tableSpecificFilters[columnName] = { value, operator: "equals" };
hasLinkedFiltersConfigured = true;
}
} else {
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
tableSpecificFilters[key] = { value, operator: "equals" };
}
}
linkedFilterValues = tableSpecificFilters;
}
// 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시
// 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수
// splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인
const isRightPanelFromContext = splitPanelPosition === "right";
const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId
? splitPanelContext.getPositionByScreenId(screenId as number) === "right"
: false;
const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext;
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) {
setLoadedTableData([]);
setLoading(false);
setInitialLoadDone(true);
return;
}
try {
setLoading(true);
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([
tableTypeApi.getTableData(tableNameToUse, {
// API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함)
const apiParams: Record<string, any> = {
page: 1,
size: 50, // 카드 표시용으로 적당한 개수
}),
search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined,
};
// 조인 컬럼 설정 가져오기 (componentConfig에서)
const joinColumnsConfig = component.componentConfig?.joinColumns || [];
const entityJoinColumns = joinColumnsConfig
.filter((col: any) => col.isJoinColumn)
.map((col: any) => ({
columnName: col.columnName,
sourceColumn: col.sourceColumn,
referenceTable: col.referenceTable,
referenceColumn: col.referenceColumn,
displayColumn: col.referenceColumn,
label: col.label,
joinAlias: col.columnName, // 백엔드에서 필요한 joinAlias 추가
sourceTable: tableNameToUse, // 기준 테이블
}));
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
// 조인 컬럼이 있으면 entityJoinApi 사용
let dataResponse;
if (entityJoinColumns.length > 0) {
console.log("🔗 [CardDisplay] 엔티티 조인 API 사용:", entityJoinColumns);
dataResponse = await entityJoinApi.getTableDataWithJoins(tableNameToUse, {
...apiParams,
additionalJoinColumns: entityJoinColumns,
});
} else {
dataResponse = await tableTypeApi.getTableData(tableNameToUse, apiParams);
}
const [columnsResponse, inputTypesResponse] = await Promise.all([
tableTypeApi.getColumns(tableNameToUse),
tableTypeApi.getColumnInputTypes(tableNameToUse),
]);
@ -180,7 +354,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
codeCategory: item.codeCategory || item.code_category,
};
});
console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta);
setColumnMeta(meta);
// 카테고리 타입 컬럼 찾기 및 매핑 로드
@ -188,17 +361,14 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
.filter(([_, m]) => m.inputType === "category")
.map(([columnName]) => columnName);
console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns);
if (categoryColumns.length > 0) {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const columnName of categoryColumns) {
try {
console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`);
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data);
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
@ -210,29 +380,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`);
});
mappings[columnName] = mapping;
}
} catch (error) {
console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error);
// 카테고리 매핑 로드 실패 시 무시
}
}
console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings);
setCategoryMappings(mappings);
}
} catch (error) {
console.error(`❌ CardDisplay: 데이터 로딩 실패`, error);
setLoadedTableData([]);
setLoadedTableColumns([]);
} finally {
setLoading(false);
setInitialLoadDone(true);
}
};
loadTableData();
}, [isDesignMode, tableName, component.componentConfig?.tableName]);
}, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]);
// 컴포넌트 설정 (기본값 보장)
const componentConfig = {
@ -272,8 +440,34 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
}
// 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산)
const isRightPanelForDisplay = splitPanelPosition === "right" ||
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData;
const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay &&
Object.keys(selectedLeftDataForDisplay).length > 0;
// 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록
useEffect(() => {
if (hasSelectedLeftDataForDisplay) {
setHasEverSelectedLeftData(true);
}
}, [hasSelectedLeftDataForDisplay]);
// 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
// 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지
const shouldHideDataForRightPanel = isRightPanelForDisplay &&
!hasEverSelectedLeftData &&
!hasSelectedLeftDataForDisplay;
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
const displayData = useMemo(() => {
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환
if (shouldHideDataForRightPanel) {
return [];
}
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
if (loadedTableData.length > 0) {
return loadedTableData;
@ -290,7 +484,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 데이터가 없으면 빈 배열 반환
return [];
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
}, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]);
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
@ -335,13 +529,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
additionalData: {},
}));
useModalDataStore.getState().setData(tableNameToUse, modalItems);
console.log("[CardDisplay] modalDataStore에 데이터 저장:", {
dataSourceId: tableNameToUse,
count: modalItems.length,
});
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
@ -349,13 +538,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked) {
splitPanelContext.setSelectedLeftData(data);
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
data,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
splitPanelContext.setSelectedLeftData(null);
console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화");
}
}
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
@ -422,21 +606,38 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}, [categoryMappings]);
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
// 초기 로드 여부 추적
const isInitialLoadRef = useRef(true);
// 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응)
const mountCountRef = useRef(0);
useEffect(() => {
mountCountRef.current += 1;
const currentMount = mountCountRef.current;
if (!tableNameToUse || isDesignMode) return;
// 초기 로드는 별도 useEffect에서 처리하므로 스킵
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵
const isRightPanel = splitPanelPosition === "right" ||
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
const hasSelectedLeftData = splitPanelContext?.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
if (isRightPanel && !hasSelectedLeftData) {
// 데이터를 지우지 않고 로딩만 false로 설정
setLoading(false);
return;
}
// 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨)
// 필터 변경이 아닌 경우 스킵
if (currentMount <= 2 && filters.length === 0) {
return;
}
const loadFilteredData = async () => {
try {
setLoading(true);
// 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지)
// 필터 값을 검색 파라미터로 변환
const searchParams: Record<string, any> = {};
@ -446,12 +647,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}
});
console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", {
tableName: tableNameToUse,
filtersCount: filters.length,
searchParams,
});
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
page: 1,
@ -466,16 +661,14 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
}
} catch (error) {
console.error("❌ [CardDisplay] 필터 적용 실패:", error);
} finally {
setLoading(false);
// 필터 적용 실패 시 무시
}
};
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
loadFilteredData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, tableNameToUse, isDesignMode, tableId]);
}, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]);
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
@ -498,7 +691,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
label: mapping?.[value]?.label || value,
}));
} catch (error) {
console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error);
return [];
}
}, [tableNameToUse]);
@ -545,10 +737,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", {
tableId,
filtersCount: newFilters.length,
});
setFiltersRef.current(newFilters);
};
@ -568,20 +756,12 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
getColumnUniqueValues: getColumnUniqueValuesWrapper,
};
console.log("📋 [CardDisplay] TableOptionsContext에 등록:", {
tableId,
tableName: tableNameToUse,
columnsCount: columns.length,
dataCount: loadedTableData.length,
});
registerTableRef.current(registration);
const unregister = unregisterTableRef.current;
const currentTableId = tableId;
return () => {
console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId);
unregister(currentTableId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -593,8 +773,34 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
columnsKey, // 컬럼 변경 시에만 재등록
]);
// 로딩 중인 경우 로딩 표시
if (loading) {
// 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
// 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지)
if (shouldHideDataForRightPanel) {
return (
<div
className={className}
style={{
...componentStyle,
...style,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "20px",
background: "#f8fafc",
borderRadius: "12px",
}}
>
<div className="text-muted-foreground text-center">
<div className="text-lg mb-2"> </div>
<div className="text-sm text-gray-400"> </div>
</div>
</div>
);
}
// 로딩 중이고 데이터가 없는 경우에만 로딩 표시
// 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지)
if (loading && displayData.length === 0 && !hasEverSelectedLeftData) {
return (
<div
className={className}
@ -617,28 +823,29 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
display: "grid",
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
padding: "32px", // 패딩 대폭 증가
gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격
padding: "16px", // 패딩
width: "100%",
height: "100%",
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤)
background: "transparent", // 배경색 제거
overflow: "auto",
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
borderRadius: "0", // 라운드 제거
};
// 카드 스타일 - 컴팩트한 디자인
const cardStyle: React.CSSProperties = {
backgroundColor: "white",
border: "1px solid #e5e7eb",
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
transition: "all 0.2s ease",
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
cursor: isDesignMode ? "pointer" : "default",
width: "100%", // 전체 너비 차지
};
// 텍스트 자르기 함수
@ -957,6 +1164,17 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
</button>
)}
{(componentConfig.cardStyle?.showDeleteButton ?? false) && (
<button
className="text-xs text-red-500 hover:text-red-700 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCardDelete(data, index);
}}
>
</button>
)}
</div>
)}
</div>

View File

@ -1,6 +1,21 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { entityJoinApi } from "@/lib/api/entityJoin";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Trash2 } from "lucide-react";
interface CardDisplayConfigPanelProps {
config: any;
@ -9,9 +24,32 @@ interface CardDisplayConfigPanelProps {
tableColumns?: any[];
}
interface EntityJoinColumn {
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}
interface JoinTable {
tableName: string;
currentDisplayColumn: string;
joinConfig?: {
sourceColumn: string;
};
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
description?: string;
}>;
}
/**
* CardDisplay
* UI
* UI +
*/
export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
config,
@ -19,6 +57,40 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
screenTableName,
tableColumns = [],
}) => {
// 엔티티 조인 컬럼 상태
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: EntityJoinColumn[];
joinTables: JoinTable[];
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 엔티티 조인 컬럼 정보 가져오기
useEffect(() => {
const fetchEntityJoinColumns = async () => {
const tableName = config.tableName || screenTableName;
if (!tableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(tableName);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
});
} catch (error) {
console.error("Entity 조인 컬럼 조회 오류:", error);
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [config.tableName, screenTableName]);
const handleChange = (key: string, value: any) => {
onChange({ ...config, [key]: value });
};
@ -28,7 +100,6 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
let newConfig = { ...config };
let current = newConfig;
// 중첩 객체 생성
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
@ -40,6 +111,47 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
onChange(newConfig);
};
// 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트
const handleColumnSelect = (path: string, columnName: string) => {
const joinColumn = entityJoinColumns.availableColumns.find(
(col) => col.joinAlias === columnName
);
if (joinColumn) {
const joinColumnsConfig = config.joinColumns || [];
const existingJoinColumn = joinColumnsConfig.find(
(jc: any) => jc.columnName === columnName
);
if (!existingJoinColumn) {
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const newJoinColumnConfig = {
columnName: joinColumn.joinAlias,
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
referenceTable: joinColumn.tableName,
referenceColumn: joinColumn.columnName,
isJoinColumn: true,
};
onChange({
...config,
columnMapping: {
...config.columnMapping,
[path.split(".")[1]]: columnName,
},
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
});
return;
}
}
handleNestedChange(path, columnName);
};
// 표시 컬럼 추가
const addDisplayColumn = () => {
const currentColumns = config.columnMapping?.displayColumns || [];
@ -58,122 +170,198 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
const updateDisplayColumn = (index: number, value: string) => {
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
currentColumns[index] = value;
const joinColumn = entityJoinColumns.availableColumns.find(
(col) => col.joinAlias === value
);
if (joinColumn) {
const joinColumnsConfig = config.joinColumns || [];
const existingJoinColumn = joinColumnsConfig.find(
(jc: any) => jc.columnName === value
);
if (!existingJoinColumn) {
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const newJoinColumnConfig = {
columnName: joinColumn.joinAlias,
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
referenceTable: joinColumn.tableName,
referenceColumn: joinColumn.columnName,
isJoinColumn: true,
};
onChange({
...config,
columnMapping: {
...config.columnMapping,
displayColumns: currentColumns,
},
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
});
return;
}
}
handleNestedChange("columnMapping.displayColumns", currentColumns);
};
// 테이블별로 조인 컬럼 그룹화
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
entityJoinColumns.availableColumns.forEach((col) => {
if (!joinColumnsByTable[col.tableName]) {
joinColumnsByTable[col.tableName] = [];
}
joinColumnsByTable[col.tableName].push(col);
});
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
const renderColumnSelect = (
value: string,
onChangeHandler: (value: string) => void,
placeholder: string = "컬럼을 선택하세요"
) => {
return (
<Select
value={value || "__none__"}
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{/* 선택 안함 옵션 */}
<SelectItem value="__none__" className="text-xs text-muted-foreground">
</SelectItem>
{/* 기본 테이블 컬럼 */}
{tableColumns.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
</SelectLabel>
{tableColumns.map((column) => (
<SelectItem
key={column.columnName}
value={column.columnName}
className="text-xs"
>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectGroup>
)}
{/* 조인 테이블별 컬럼 */}
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
<SelectGroup key={tableName}>
<SelectLabel className="text-xs font-semibold text-blue-600">
{tableName} ()
</SelectLabel>
{columns.map((col) => (
<SelectItem
key={col.joinAlias}
value={col.joinAlias}
className="text-xs"
>
{col.suggestedLabel || col.columnLabel}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium text-gray-700"> </div>
<div className="text-sm font-medium"> </div>
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
{tableColumns && tableColumns.length > 0 && (
<div className="space-y-3">
<h5 className="text-xs font-medium text-gray-700"> </h5>
<h5 className="text-xs font-medium text-muted-foreground"> </h5>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={config.columnMapping?.titleColumn || ""}
onChange={(e) => handleNestedChange("columnMapping.titleColumn", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{tableColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
{loadingEntityJoins && (
<div className="text-xs text-muted-foreground"> ...</div>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
{renderColumnSelect(
config.columnMapping?.titleColumn || "",
(value) => handleColumnSelect("columnMapping.titleColumn", value)
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={config.columnMapping?.subtitleColumn || ""}
onChange={(e) => handleNestedChange("columnMapping.subtitleColumn", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{tableColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
<div className="space-y-1">
<Label className="text-xs"> </Label>
{renderColumnSelect(
config.columnMapping?.subtitleColumn || "",
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={config.columnMapping?.descriptionColumn || ""}
onChange={(e) => handleNestedChange("columnMapping.descriptionColumn", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{tableColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
<div className="space-y-1">
<Label className="text-xs"> </Label>
{renderColumnSelect(
config.columnMapping?.descriptionColumn || "",
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={config.columnMapping?.imageColumn || ""}
onChange={(e) => handleNestedChange("columnMapping.imageColumn", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{tableColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
<div className="space-y-1">
<Label className="text-xs"> </Label>
{renderColumnSelect(
config.columnMapping?.imageColumn || "",
(value) => handleColumnSelect("columnMapping.imageColumn", value)
)}
</div>
{/* 동적 표시 컬럼 추가 */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-medium text-gray-600"> </label>
<button
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addDisplayColumn}
className="rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600"
className="h-6 px-2 text-xs"
>
+
</button>
</Button>
</div>
<div className="space-y-2">
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => (
<div key={index} className="flex items-center space-x-2">
<select
value={column}
onChange={(e) => updateDisplayColumn(index, e.target.value)}
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{tableColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} ({col.dataType})
</option>
))}
</select>
<button
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
{renderColumnSelect(
column,
(value) => updateDisplayColumn(index, value)
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDisplayColumn(index)}
className="rounded bg-red-500 px-2 py-1 text-xs text-white hover:bg-red-600"
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
</button>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && (
<div className="rounded border border-dashed border-gray-300 py-2 text-center text-xs text-gray-500">
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
"컬럼 추가"
</div>
)}
@ -184,173 +372,166 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
{/* 카드 스타일 설정 */}
<div className="space-y-3">
<h5 className="text-xs font-medium text-gray-700"> </h5>
<h5 className="text-xs font-medium text-muted-foreground"> </h5>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<input
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
min="1"
max="6"
value={config.cardsPerRow || 3}
onChange={(e) => handleChange("cardsPerRow", parseInt(e.target.value))}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
className="h-8 text-xs"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> (px)</label>
<input
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
min="0"
max="50"
value={config.cardSpacing || 16}
onChange={(e) => handleChange("cardSpacing", parseInt(e.target.value))}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
className="h-8 text-xs"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showTitle"
checked={config.cardStyle?.showTitle ?? true}
onChange={(e) => handleNestedChange("cardStyle.showTitle", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showTitle", checked)}
/>
<label htmlFor="showTitle" className="text-xs text-gray-600">
<Label htmlFor="showTitle" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showSubtitle"
checked={config.cardStyle?.showSubtitle ?? true}
onChange={(e) => handleNestedChange("cardStyle.showSubtitle", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showSubtitle", checked)}
/>
<label htmlFor="showSubtitle" className="text-xs text-gray-600">
<Label htmlFor="showSubtitle" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showDescription"
checked={config.cardStyle?.showDescription ?? true}
onChange={(e) => handleNestedChange("cardStyle.showDescription", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDescription", checked)}
/>
<label htmlFor="showDescription" className="text-xs text-gray-600">
<Label htmlFor="showDescription" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showImage"
checked={config.cardStyle?.showImage ?? false}
onChange={(e) => handleNestedChange("cardStyle.showImage", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showImage", checked)}
/>
<label htmlFor="showImage" className="text-xs text-gray-600">
<Label htmlFor="showImage" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showActions"
checked={config.cardStyle?.showActions ?? true}
onChange={(e) => handleNestedChange("cardStyle.showActions", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showActions", checked)}
/>
<label htmlFor="showActions" className="text-xs text-gray-600">
<Label htmlFor="showActions" className="text-xs font-normal">
</label>
</Label>
</div>
{/* 개별 버튼 설정 (액션 버튼이 활성화된 경우에만 표시) */}
{/* 개별 버튼 설정 */}
{(config.cardStyle?.showActions ?? true) && (
<div className="ml-4 space-y-2 border-l-2 border-gray-200 pl-3">
<div className="ml-5 space-y-2 border-l-2 border-muted pl-3">
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showViewButton"
checked={config.cardStyle?.showViewButton ?? true}
onChange={(e) => handleNestedChange("cardStyle.showViewButton", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showViewButton", checked)}
/>
<label htmlFor="showViewButton" className="text-xs text-gray-600">
<Label htmlFor="showViewButton" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="showEditButton"
checked={config.cardStyle?.showEditButton ?? true}
onChange={(e) => handleNestedChange("cardStyle.showEditButton", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleNestedChange("cardStyle.showEditButton", checked)}
/>
<label htmlFor="showEditButton" className="text-xs text-gray-600">
<Label htmlFor="showEditButton" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showDeleteButton"
checked={config.cardStyle?.showDeleteButton ?? false}
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDeleteButton", checked)}
/>
<Label htmlFor="showDeleteButton" className="text-xs font-normal">
</Label>
</div>
</div>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<input
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
min="10"
max="500"
value={config.cardStyle?.maxDescriptionLength || 100}
onChange={(e) => handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
className="h-8 text-xs"
/>
</div>
</div>
{/* 공통 설정 */}
<div className="space-y-3">
<h5 className="text-xs font-medium text-gray-700"> </h5>
<h5 className="text-xs font-medium text-muted-foreground"> </h5>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="disabled"
checked={config.disabled || false}
onChange={(e) => handleChange("disabled", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
<label htmlFor="disabled" className="text-xs text-gray-600">
<Label htmlFor="disabled" className="text-xs font-normal">
</label>
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
id="readonly"
checked={config.readonly || false}
onChange={(e) => handleChange("readonly", e.target.checked)}
className="rounded border-gray-300"
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<label htmlFor="readonly" className="text-xs text-gray-600">
<Label htmlFor="readonly" className="text-xs font-normal">
</label>
</Label>
</div>
</div>
</div>

View File

@ -16,6 +16,7 @@ export interface CardStyleConfig {
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
showViewButton?: boolean; // 상세보기 버튼 표시 여부
showEditButton?: boolean; // 편집 버튼 표시 여부
showDeleteButton?: boolean; // 삭제 버튼 표시 여부
}
/**

View File

@ -166,8 +166,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
// 🆕 UTC 시간을 로컬 시간으로 변환하여 날짜 추출 (타임존 이슈 해결)
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
return dateStr.split("T")[0];
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// 다른 형식의 날짜 문자열이나 Date 객체 처리
@ -276,7 +281,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
@ -299,16 +304,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}}
className={cn(
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
{/* 구분자 */}
<span className="text-base font-medium text-muted-foreground">~</span>
<span className="text-muted-foreground text-base font-medium">~</span>
{/* 종료일 */}
<input
@ -326,11 +333,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}}
className={cn(
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
</div>
@ -344,7 +353,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
@ -368,11 +377,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}}
className={cn(
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
</div>
@ -402,11 +413,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
className={cn(
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
onClick={handleClick}
onDragStart={onDragStart}

View File

@ -1,19 +1,24 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { Search, X, Check, ChevronsUpDown } from "lucide-react";
import { EntitySearchModal } from "./EntitySearchModal";
import { EntitySearchInputProps, EntitySearchResult } from "./types";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
export function EntitySearchInputComponent({
tableName,
displayField,
valueField,
searchFields = [displayField],
mode = "combo",
mode: modeProp,
uiMode, // EntityConfigPanel에서 저장되는 값
placeholder = "검색...",
disabled = false,
filterCondition = {},
@ -24,31 +29,230 @@ export function EntitySearchInputComponent({
showAdditionalInfo = false,
additionalFields = [],
className,
}: EntitySearchInputProps) {
style,
// 연쇄관계 props
cascadingRelationCode,
parentValue: parentValueProp,
parentFieldId,
formData,
// 🆕 추가 props
component,
isInteractive,
onFormDataChange,
}: EntitySearchInputProps & {
uiMode?: string;
component?: any;
isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void;
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
}) {
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
const config = component?.componentConfig || {};
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
const effectiveParentFieldId = parentFieldId || config.parentFieldId;
const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined
// 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨)
const isChildRole = effectiveCascadingRole === "child";
const shouldApplyCascading = effectiveCascadingRelationCode && isChildRole;
const [modalOpen, setModalOpen] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
const [options, setOptions] = useState<EntitySearchResult[]>([]);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// value가 변경되면 표시값 업데이트
// 연쇄관계 상태
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
const previousParentValue = useRef<any>(null);
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
const parentValue = isChildRole
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
: undefined;
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
const filterConditionKey = JSON.stringify(filterCondition || {});
// 연쇄관계가 설정된 경우: 부모 값이 변경되면 자식 옵션 로드 (자식 역할일 때만)
useEffect(() => {
if (value && selectedData) {
setDisplayValue(selectedData[displayField] || "");
const loadCascadingOptions = async () => {
if (!shouldApplyCascading) return;
// 부모 값이 없으면 옵션 초기화
if (!parentValue) {
setCascadingOptions([]);
// 부모 값이 변경되면 현재 값도 초기화
if (previousParentValue.current !== null && previousParentValue.current !== parentValue) {
handleClear();
}
previousParentValue.current = parentValue;
return;
}
// 부모 값이 동일하면 스킵
if (previousParentValue.current === parentValue) {
return;
}
previousParentValue.current = parentValue;
setIsCascadingLoading(true);
try {
console.log("🔗 연쇄관계 옵션 로드:", { effectiveCascadingRelationCode, parentValue });
const response = await cascadingRelationApi.getOptions(effectiveCascadingRelationCode, String(parentValue));
if (response.success && response.data) {
// 옵션을 EntitySearchResult 형태로 변환
const formattedOptions = response.data.map((opt: any) => ({
[valueField]: opt.value,
[displayField]: opt.label,
...opt, // 추가 필드도 포함
}));
setCascadingOptions(formattedOptions);
console.log("✅ 연쇄관계 옵션 로드 완료:", formattedOptions.length, "개");
// 현재 선택된 값이 새 옵션에 없으면 초기화
if (value && !formattedOptions.find((opt: any) => opt[valueField] === value)) {
handleClear();
}
} else {
setCascadingOptions([]);
}
} catch (error) {
console.error("❌ 연쇄관계 옵션 로드 실패:", error);
setCascadingOptions([]);
} finally {
setIsCascadingLoading(false);
}
};
loadCascadingOptions();
}, [shouldApplyCascading, effectiveCascadingRelationCode, parentValue, valueField, displayField]);
// select 모드일 때 옵션 로드 (연쇄관계가 없거나 부모 역할인 경우)
useEffect(() => {
if (mode === "select" && tableName && !optionsLoaded && !shouldApplyCascading) {
loadOptions();
setOptionsLoaded(true);
}
}, [mode, tableName, filterConditionKey, optionsLoaded, shouldApplyCascading]);
const loadOptions = async () => {
if (!tableName) return;
setIsLoadingOptions(true);
try {
const response = await dynamicFormApi.getTableData(tableName, {
page: 1,
pageSize: 100, // 최대 100개까지 로드
filters: filterCondition,
});
if (response.success && response.data) {
setOptions(response.data);
}
} catch (error) {
console.error("옵션 로드 실패:", error);
} finally {
setIsLoadingOptions(false);
}
};
// 실제 사용할 옵션 목록 (자식 역할이고 연쇄관계가 있으면 연쇄 옵션 사용)
const effectiveOptions = shouldApplyCascading ? cascadingOptions : options;
const isLoading = shouldApplyCascading ? isCascadingLoading : isLoadingOptions;
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
useEffect(() => {
const loadDisplayValue = async () => {
if (value && selectedData) {
// 이미 selectedData가 있으면 표시값만 업데이트
setDisplayValue(selectedData[displayField] || "");
} else if (value && mode === "select" && effectiveOptions.length > 0) {
// select 모드에서 value가 있고 options가 로드된 경우
const found = effectiveOptions.find((opt) => opt[valueField] === value);
if (found) {
setSelectedData(found);
setDisplayValue(found[displayField] || "");
}
} else if (value && !selectedData && tableName) {
// value는 있지만 selectedData가 없는 경우 (초기 로드 시)
// API로 해당 데이터 조회
try {
console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField });
const response = await dynamicFormApi.getTableData(tableName, {
filters: { [valueField]: value },
pageSize: 1,
});
if (response.success && response.data) {
// 데이터 추출 (중첩 구조 처리)
const responseData = response.data as any;
const dataArray = Array.isArray(responseData)
? responseData
: responseData?.data
? Array.isArray(responseData.data)
? responseData.data
: [responseData.data]
: [];
if (dataArray.length > 0) {
const foundData = dataArray[0];
setSelectedData(foundData);
setDisplayValue(foundData[displayField] || "");
console.log("✅ [EntitySearchInput] 초기값 로드 완료:", foundData);
} else {
// 데이터를 찾지 못한 경우 value 자체를 표시
console.log("⚠️ [EntitySearchInput] 초기값 데이터 없음, value 표시:", value);
setDisplayValue(String(value));
}
} else {
console.log("⚠️ [EntitySearchInput] API 응답 실패, value 표시:", value);
setDisplayValue(String(value));
}
} catch (error) {
console.error("❌ [EntitySearchInput] 초기값 조회 실패:", error);
// 에러 시 value 자체를 표시
setDisplayValue(String(value));
}
} else if (!value) {
setDisplayValue("");
setSelectedData(null);
}
}, [value, displayField]);
};
loadDisplayValue();
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
}
};
const handleClear = () => {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, null);
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
}
};
const handleOpenModal = () => {
@ -57,10 +261,105 @@ export function EntitySearchInputComponent({
}
};
const handleSelectOption = (option: EntitySearchResult) => {
handleSelect(option[valueField], option);
setSelectOpen(false);
};
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
const componentHeight = style?.height;
const inputStyle: React.CSSProperties = componentHeight ? { height: componentHeight } : {};
// select 모드: 검색 가능한 드롭다운
if (mode === "select") {
return (
<div className={cn("space-y-2", className)}>
<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>
)}
<Popover open={selectOpen} onOpenChange={setSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={selectOpen}
disabled={disabled || isLoading || Boolean(shouldApplyCascading && !parentValue)}
className={cn(
"w-full justify-between font-normal",
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
!value && "text-muted-foreground",
)}
style={inputStyle}
>
{isLoading
? "로딩 중..."
: shouldApplyCascading && !parentValue
? "상위 항목을 먼저 선택하세요"
: displayValue || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{effectiveOptions.map((option, index) => (
<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", value === option[valueField] ? "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>
</PopoverContent>
</Popover>
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
</div>
);
}
// modal, combo, autocomplete 모드
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 gap-2">
<div className="flex h-full gap-2">
<div className="relative flex-1">
<Input
value={displayValue}
@ -68,7 +367,8 @@ export function EntitySearchInputComponent({
placeholder={placeholder}
disabled={disabled}
readOnly={mode === "modal" || mode === "combo"}
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
className={cn("w-full pr-8", !componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
/>
{displayValue && !disabled && (
<Button
@ -76,19 +376,21 @@ export function EntitySearchInputComponent({
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm"
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
>
<Search className="h-4 w-4" />
</Button>
@ -97,7 +399,7 @@ export function EntitySearchInputComponent({
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2">
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
@ -107,7 +409,8 @@ export function EntitySearchInputComponent({
</div>
)}
{/* 검색 모달 */}
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
{(mode === "modal" || mode === "combo") && (
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
@ -120,7 +423,7 @@ export function EntitySearchInputComponent({
modalColumns={modalColumns}
onSelect={handleSelect}
/>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -8,19 +8,27 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
import { EntitySearchInputConfig } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import { cn } from "@/lib/utils";
import Link from "next/link";
interface EntitySearchInputConfigPanelProps {
config: EntitySearchInputConfig;
onConfigChange: (config: EntitySearchInputConfig) => void;
currentComponent?: any; // 테이블 패널에서 드래그한 컴포넌트 정보
allComponents?: any[]; // 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
}
export function EntitySearchInputConfigPanel({
config,
onConfigChange,
currentComponent,
allComponents = [],
}: EntitySearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
@ -31,7 +39,151 @@ export function EntitySearchInputConfigPanel({
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 전체 테이블 목록 로드
// 연쇄 드롭다운 설정 상태 (SelectBasicConfigPanel과 동일)
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
// 연쇄관계 목록
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
isAutoLoaded: boolean; // 자동 로드되었는지 여부
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: null,
});
// 자동 설정 완료 여부 (중복 방지)
const autoConfigApplied = useRef(false);
// 테이블 패널에서 드래그한 컴포넌트인 경우, 참조 테이블 정보 자동 로드
useEffect(() => {
const loadReferenceInfo = async () => {
// currentComponent에서 소스 테이블/컬럼 정보 추출
const sourceTableName = currentComponent?.tableName || currentComponent?.sourceTableName;
const sourceColumnName = currentComponent?.columnName || currentComponent?.sourceColumnName;
if (!sourceTableName || !sourceColumnName) {
return;
}
// 이미 config에 테이블명이 설정되어 있고, 자동 로드가 완료되었다면 스킵
if (config.tableName && autoConfigApplied.current) {
return;
}
setReferenceInfo(prev => ({ ...prev, isLoading: true, error: null }));
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(sourceTableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === sourceColumnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "id";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "name";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
isAutoLoaded: true,
error: null,
});
// 참조 테이블 정보로 config 자동 설정 (config에 아직 설정이 없는 경우만)
if (finalRefTable && !config.tableName) {
autoConfigApplied.current = true;
const newConfig: EntitySearchInputConfig = {
...localConfig,
tableName: finalRefTable,
valueField: finalRefColumn,
displayField: finalDispColumn,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [currentComponent?.tableName, currentComponent?.columnName, currentComponent?.sourceTableName, currentComponent?.sourceColumnName]);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// 연쇄 관계 목록 로드 함수
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
// 전체 테이블 목록 로드 (수동 선택을 위해)
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
@ -73,8 +225,11 @@ export function EntitySearchInputConfigPanel({
loadColumns();
}, [localConfig.tableName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalConfig(config);
// 연쇄 드롭다운 설정 동기화
setCascadingEnabled(!!config.cascadingRelationCode);
}, [config]);
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
@ -83,6 +238,71 @@ export function EntitySearchInputConfigPanel({
onConfigChange(newConfig);
};
// 연쇄 드롭다운 활성화/비활성화
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 설정 제거
const newConfig = {
...localConfig,
cascadingRelationCode: undefined,
cascadingRole: undefined,
cascadingParentField: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// 활성화 시 관계 목록 로드
loadRelationList();
}
};
// 연쇄 관계 선택 (역할은 별도 선택)
const handleRelationSelect = (code: string) => {
const newConfig = {
...localConfig,
cascadingRelationCode: code || undefined,
cascadingRole: undefined, // 역할은 별도로 선택
cascadingParentField: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
// 역할 변경 핸들러
const handleRoleChange = (role: "parent" | "child") => {
const selectedRel = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
if (role === "parent" && selectedRel) {
// 부모 역할: 부모 테이블 정보로 설정
const newConfig = {
...localConfig,
cascadingRole: role,
tableName: selectedRel.parent_table,
valueField: selectedRel.parent_value_column,
displayField: selectedRel.parent_label_column || selectedRel.parent_value_column,
cascadingParentField: undefined, // 부모 역할이면 부모 필드 필요 없음
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else if (role === "child" && selectedRel) {
// 자식 역할: 자식 테이블 정보로 설정
const newConfig = {
...localConfig,
cascadingRole: role,
tableName: selectedRel.child_table,
valueField: selectedRel.child_value_column,
displayField: selectedRel.child_label_column || selectedRel.child_value_column,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
const addSearchField = () => {
const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] });
@ -134,10 +354,213 @@ export function EntitySearchInputConfigPanel({
updateConfig({ additionalFields: fields });
};
// 자동 로드된 참조 테이블 정보가 있는지 확인
const hasAutoReference = referenceInfo.isAutoLoaded && referenceInfo.referenceTable;
return (
<div className="space-y-4 p-4">
{/* 연쇄 드롭다운 설정 - SelectConfigPanel과 동일한 패턴 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h4 className="text-sm font-medium"> </h4>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
. (: 창고 )
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Label className="text-xs"> </Label>
<Select
value={localConfig.cascadingRelationCode || ""}
onValueChange={handleRelationSelect}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{localConfig.cascadingRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={localConfig.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={localConfig.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{localConfig.cascadingRole === "parent"
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
: localConfig.cascadingRole === "child"
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{localConfig.cascadingRelationCode && localConfig.cascadingRole === "child" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={localConfig.cascadingParentField || ""}
onChange={(e) => updateConfig({ cascadingParentField: e.target.value || undefined })}
placeholder="예: warehouse_code"
className="text-xs"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && localConfig.cascadingRole && (
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
{localConfig.cascadingRole === "parent" ? (
<>
<div className="font-medium text-blue-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_value_column}</span>
</div>
</>
) : (
<>
<div className="font-medium text-green-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_value_column}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_filter_column}</span>
</div>
</>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
{/* 구분선 - 연쇄 드롭다운 비활성화 시에만 표시 */}
{!cascadingEnabled && (
<div className="border-t pt-4">
<p className="text-[10px] text-muted-foreground mb-4">
/ .
</p>
</div>
)}
{/* 참조 테이블 자동 로드 정보 표시 */}
{referenceInfo.isLoading && (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
)}
{hasAutoReference && !cascadingEnabled && (
<div className="bg-primary/5 rounded-md border border-primary/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-xs font-medium text-primary"> </span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "id"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "name"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
: {currentComponent?.tableName}.{currentComponent?.columnName}
</p>
</div>
)}
{referenceInfo.error && !hasAutoReference && !cascadingEnabled && (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
)}
<div className="space-y-2">
<Label className="text-xs sm:text-sm">
*
{hasAutoReference && (
<span className="text-[10px] text-muted-foreground ml-2">( )</span>
)}
</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
@ -302,7 +725,7 @@ export function EntitySearchInputConfigPanel({
<Label className="text-xs sm:text-sm">UI </Label>
<Select
value={localConfig.mode || "combo"}
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
onValueChange={(value: "select" | "autocomplete" | "modal" | "combo") =>
updateConfig({ mode: value })
}
>
@ -310,11 +733,18 @@ export function EntitySearchInputConfigPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="select"> ( )</SelectItem>
<SelectItem value="combo"> ( + )</SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="autocomplete"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{localConfig.mode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
{localConfig.mode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
{(localConfig.mode === "combo" || !localConfig.mode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
{localConfig.mode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
</p>
</div>
<div className="space-y-2">

View File

@ -4,11 +4,16 @@ export interface EntitySearchInputConfig {
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
mode?: "autocomplete" | "modal" | "combo";
mode?: "select" | "autocomplete" | "modal" | "combo";
placeholder?: string;
modalTitle?: string;
modalColumns?: string[];
showAdditionalInfo?: boolean;
additionalFields?: string[];
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
}

View File

@ -11,7 +11,11 @@ export interface EntitySearchInputProps {
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
// UI 모드
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
// - select: 드롭다운 선택 (검색 가능한 콤보박스)
// - modal: 모달 팝업에서 선택
// - combo: 입력 + 모달 버튼 (기본)
// - autocomplete: 입력하면서 자동완성
mode?: "select" | "autocomplete" | "modal" | "combo"; // 기본: "combo"
placeholder?: string;
disabled?: boolean;
@ -19,6 +23,13 @@ export interface EntitySearchInputProps {
filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시
// 연쇄관계 설정
cascadingRelationCode?: string; // 연쇄관계 코드 (cascading_relation 테이블)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
parentFieldId?: string; // 부모 필드의 컬럼명 (자식 역할일 때, formData에서 값 추출용)
parentValue?: any; // 부모 필드의 현재 값 (직접 전달)
formData?: Record<string, any>; // 전체 폼 데이터 (부모 값 추출용)
// 선택된 값
value?: any;
onChange?: (value: any, fullData?: any) => void;
@ -33,6 +44,7 @@ export interface EntitySearchInputProps {
// 스타일
className?: string;
style?: React.CSSProperties;
}
export interface EntitySearchResult {

View File

@ -85,6 +85,9 @@ import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록,
// 🆕 메일 수신자 선택 컴포넌트
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
// 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
/**
*
*/

View File

@ -53,6 +53,9 @@ export interface LocationSwapSelectorProps {
formData?: Record<string, any>;
onFormDataChange?: (field: string, value: any) => void;
// 🆕 사용자 정보 (DB에서 초기값 로드용)
userId?: string;
// componentConfig (화면 디자이너에서 전달)
componentConfig?: {
dataSource?: DataSourceConfig;
@ -65,6 +68,10 @@ export interface LocationSwapSelectorProps {
showSwapButton?: boolean;
swapButtonPosition?: "center" | "right";
variant?: "card" | "inline" | "minimal";
// 🆕 DB 초기값 로드 설정
loadFromDb?: boolean; // DB에서 초기값 로드 여부
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
dbKeyField?: string; // 키 필드 (기본: user_id)
};
}
@ -80,6 +87,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
formData = {},
onFormDataChange,
componentConfig,
userId,
} = props;
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
@ -94,6 +102,11 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
const variant = config.variant || props.variant || "card";
// 🆕 DB 초기값 로드 설정
const loadFromDb = config.loadFromDb !== false; // 기본값 true
const dbTableName = config.dbTableName || "vehicles";
const dbKeyField = config.dbKeyField || "user_id";
// 기본 옵션 (포항/광양)
const DEFAULT_OPTIONS: LocationOption[] = [
{ value: "pohang", label: "포항" },
@ -104,6 +117,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
const [loading, setLoading] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
// 로컬 선택 상태 (Select 컴포넌트용)
const [localDeparture, setLocalDeparture] = useState<string>("");
@ -193,8 +207,89 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
loadOptions();
}, [dataSource, isDesignMode]);
// formData에서 초기값 동기화
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
useEffect(() => {
const loadFromDatabase = async () => {
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
if (isDesignMode || !loadFromDb || !userId) {
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
return;
}
// 이미 로드했으면 스킵
if (dbLoaded) {
return;
}
try {
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
const response = await apiClient.post(
`/table-management/tables/${dbTableName}/data`,
{
page: 1,
size: 1,
search: { [dbKeyField]: userId },
autoFilter: true,
}
);
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
if (vehicleData) {
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
setLocalDeparture(dbDeparture);
onFormDataChange?.(departureField, dbDeparture);
// 라벨도 업데이트
if (departureLabelField) {
const opt = options.find(o => o.value === dbDeparture);
if (opt) {
onFormDataChange?.(departureLabelField, opt.label);
}
}
}
if (dbDestination && options.some(o => o.value === dbDestination)) {
setLocalDestination(dbDestination);
onFormDataChange?.(destinationField, dbDestination);
// 라벨도 업데이트
if (destinationLabelField) {
const opt = options.find(o => o.value === dbDestination);
if (opt) {
onFormDataChange?.(destinationLabelField, opt.label);
}
}
}
}
setDbLoaded(true);
} catch (error) {
console.error("[LocationSwapSelector] DB 로드 실패:", error);
setDbLoaded(true); // 실패해도 다시 시도하지 않음
}
};
// 옵션이 로드된 후에 DB 로드 실행
if (options.length > 0) {
loadFromDatabase();
}
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
useEffect(() => {
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
if (loadFromDb && userId && !dbLoaded) {
return;
}
const depVal = formData[departureField];
const destVal = formData[destinationField];
@ -204,7 +299,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
if (destVal && options.some(o => o.value === destVal)) {
setLocalDestination(destVal);
}
}, [formData, departureField, destinationField, options]);
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
// 출발지 변경
const handleDepartureChange = (selectedValue: string) => {

View File

@ -470,6 +470,58 @@ export function LocationSwapSelectorConfigPanel({
</div>
</div>
{/* DB 초기값 로드 설정 */}
<div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">DB </h4>
<p className="text-xs text-muted-foreground">
DB에 /
</p>
<div className="flex items-center justify-between">
<Label>DB에서 </Label>
<Switch
checked={config?.loadFromDb !== false}
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
/>
</div>
{config?.loadFromDb !== false && (
<>
<div className="space-y-2">
<Label> </Label>
<Select
value={config?.dbTableName || "vehicles"}
onValueChange={(value) => handleChange("dbTableName", value)}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vehicles">vehicles ()</SelectItem>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={config?.dbKeyField || "user_id"}
onChange={(e) => handleChange("dbKeyField", e.target.value)}
placeholder="user_id"
/>
<p className="text-xs text-muted-foreground">
ID로 (기본: user_id)
</p>
</div>
</>
)}
</div>
{/* 안내 */}
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
@ -480,6 +532,8 @@ export function LocationSwapSelectorConfigPanel({
2. /
<br />
3.
<br />
4. DB
</p>
</div>
</div>

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { Plus, Columns } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
@ -328,6 +328,12 @@ export function ModalRepeaterTableComponent({
const companyCode = componentConfig?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
// 체크박스 선택 상태
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// 균등 분배 트리거 (값이 변경되면 RepeaterTable에서 균등 분배 실행)
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
@ -794,6 +800,18 @@ export function ModalRepeaterTableComponent({
handleChange(newData);
};
// 선택된 항목 일괄 삭제 핸들러
const handleBulkDelete = () => {
if (selectedRows.size === 0) return;
// 선택되지 않은 항목만 남김
const newData = localValue.filter((_, index) => !selectedRows.has(index));
// 데이터 업데이트 및 선택 상태 초기화
handleChange(newData);
setSelectedRows(new Set());
};
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
const columnLabels = columns.reduce((acc, col) => {
// sourceColumnLabels에 정의된 라벨 우선 사용
@ -805,9 +823,34 @@ export function ModalRepeaterTableComponent({
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{localValue.length > 0 && `${localValue.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
{columns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
title="컬럼 너비 균등 분배"
>
<Columns className="h-3.5 w-3.5 mr-1" />
</Button>
)}
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button
variant="destructive"
onClick={handleBulkDelete}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
({selectedRows.size})
</Button>
)}
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
@ -816,6 +859,7 @@ export function ModalRepeaterTableComponent({
{modalButtonText}
</Button>
</div>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
@ -826,6 +870,9 @@ export function ModalRepeaterTableComponent({
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={equalizeWidthsTrigger}
/>
{/* 항목 선택 모달 */}

View File

@ -1,14 +1,68 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Trash2, ChevronDown, Check } from "lucide-react";
import { ChevronDown, Check, GripVertical } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
// @dnd-kit imports
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
interface SortableRowProps {
id: string;
children: (props: {
attributes: React.HTMLAttributes<HTMLElement>;
listeners: React.HTMLAttributes<HTMLElement> | undefined;
isDragging: boolean;
}) => React.ReactNode;
className?: string;
}
function SortableRow({ id, children, className }: SortableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "#f0f9ff" : undefined,
};
return (
<tr ref={setNodeRef} style={style} className={className}>
{children({ attributes, listeners, isDragging })}
</tr>
);
}
interface RepeaterTableProps {
columns: RepeaterColumnConfig[];
data: any[];
@ -18,6 +72,11 @@ interface RepeaterTableProps {
// 동적 데이터 소스 관련
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
// 체크박스 선택 관련
selectedRows: Set<number>; // 선택된 행 인덱스
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
// 균등 분배 트리거
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
}
export function RepeaterTable({
@ -28,7 +87,60 @@ export function RepeaterTable({
onRowDelete,
activeDataSources = {},
onDataSourceChange,
selectedRows,
onSelectionChange,
equalizeWidthsTrigger,
}: RepeaterTableProps) {
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef<HTMLDivElement>(null);
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
const [isEqualizedMode, setIsEqualizedMode] = useState(false);
// DnD 센서 설정
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px 이동해야 드래그 시작 (클릭과 구분)
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 드래그 종료 핸들러
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = data.findIndex((_, idx) => `row-${idx}` === active.id);
const newIndex = data.findIndex((_, idx) => `row-${idx}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newData = arrayMove(data, oldIndex, newIndex);
onDataChange(newData);
// 선택된 행 인덱스도 업데이트
if (selectedRows.size > 0) {
const newSelectedRows = new Set<number>();
selectedRows.forEach((oldIdx) => {
if (oldIdx === oldIndex) {
newSelectedRows.add(newIndex);
} else if (oldIdx > oldIndex && oldIdx <= newIndex) {
newSelectedRows.add(oldIdx - 1);
} else if (oldIdx < oldIndex && oldIdx >= newIndex) {
newSelectedRows.add(oldIdx + 1);
} else {
newSelectedRows.add(oldIdx);
}
});
onSelectionChange(newSelectedRows);
}
}
}
};
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
field: string;
@ -66,16 +178,101 @@ export function RepeaterTable({
startX: e.clientX,
startWidth: columnWidths[field] || 120,
});
// 수동 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
};
// 더블클릭으로 기본 너비로 리셋
// 컬럼 확장 상태 추적 (토글용)
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
// 데이터 기준 최적 너비 계산
const calculateAutoFitWidth = (field: string): number => {
const column = columns.find(col => col.field === field);
if (!column) return 120;
// 헤더 텍스트 길이 (대략 8px per character + padding)
const headerWidth = (column.label?.length || field.length) * 8 + 40;
// 데이터 중 가장 긴 텍스트 찾기
let maxDataWidth = 0;
data.forEach(row => {
const value = row[field];
if (value !== undefined && value !== null) {
let displayText = String(value);
// 숫자는 천단위 구분자 포함
if (typeof value === 'number') {
displayText = value.toLocaleString();
}
// 날짜는 yyyy-mm-dd 형식
if (column.type === 'date' && displayText.includes('T')) {
displayText = displayText.split('T')[0];
}
// 대략적인 너비 계산 (8px per character + padding)
const textWidth = displayText.length * 8 + 32;
maxDataWidth = Math.max(maxDataWidth, textWidth);
}
});
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
const optimalWidth = Math.max(headerWidth, maxDataWidth);
return Math.min(Math.max(optimalWidth, 60), 400);
};
// 더블클릭으로 auto-fit / 기본 너비 토글
const handleDoubleClick = (field: string) => {
setColumnWidths((prev) => ({
...prev,
// 개별 컬럼 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
setExpandedColumns(prev => {
const newSet = new Set(prev);
if (newSet.has(field)) {
// 확장 상태 → 기본 너비로 복구
newSet.delete(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: defaultWidths[field] || 120,
}));
} else {
// 기본 상태 → 데이터 기준 auto-fit
newSet.add(field);
const autoWidth = calculateAutoFitWidth(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: autoWidth,
}));
}
return newSet;
});
};
// 균등 분배 트리거 감지
useEffect(() => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
if (!containerRef.current) return;
// 실제 컨테이너 너비 측정
const containerWidth = containerRef.current.offsetWidth;
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
const checkboxColumnWidth = 40;
const borderWidth = 2;
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
newWidths[col.field] = equalWidth;
});
setColumnWidths(newWidths);
setExpandedColumns(new Set()); // 확장 상태 초기화
setIsEqualizedMode(true); // 균등 분배 모드 활성화
}, [equalizeWidthsTrigger, columns]);
useEffect(() => {
if (!resizing) return;
@ -112,6 +309,33 @@ export function RepeaterTable({
onRowChange(rowIndex, newRow);
};
// 전체 선택 체크박스 핸들러
const handleSelectAll = (checked: boolean) => {
if (checked) {
// 모든 행 선택
const allIndices = new Set(data.map((_, index) => index));
onSelectionChange(allIndices);
} else {
// 전체 해제
onSelectionChange(new Set());
}
};
// 개별 행 선택 핸들러
const handleRowSelect = (rowIndex: number, checked: boolean) => {
const newSelection = new Set(selectedRows);
if (checked) {
newSelection.add(rowIndex);
} else {
newSelection.delete(rowIndex);
}
onSelectionChange(newSelection);
};
// 전체 선택 상태 계산
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
const renderCell = (
row: any,
column: RepeaterColumnConfig,
@ -144,7 +368,7 @@ export function RepeaterTable({
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
/>
);
@ -172,7 +396,7 @@ export function RepeaterTable({
type="date"
value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
/>
);
@ -184,7 +408,7 @@ export function RepeaterTable({
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -203,20 +427,48 @@ export function RepeaterTable({
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
/>
);
}
};
// 드래그 아이템 ID 목록
const sortableItems = data.map((_, idx) => `row-${idx}`);
return (
<div className="border border-gray-200 bg-white">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div ref={containerRef} className="border border-gray-200 bg-white">
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<table className="w-full text-xs border-collapse">
<table
className={cn(
"text-xs border-collapse",
isEqualizedMode && "w-full"
)}
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
>
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12">
#
{/* 드래그 핸들 헤더 */}
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
<span className="sr-only"></span>
</th>
{/* 체크박스 헤더 */}
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
<Checkbox
checked={isAllSelected}
// @ts-ignore - indeterminate는 HTML 속성
data-indeterminate={isIndeterminate}
onCheckedChange={handleSelectAll}
className={cn(
"border-gray-400",
isIndeterminate && "data-[state=checked]:bg-primary"
)}
/>
</th>
{columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
@ -225,13 +477,15 @@ export function RepeaterTable({
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
: null;
const isExpanded = expandedColumns.has(col.field);
return (
<th
key={col.field}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 기본 너비로 되돌리기"
title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
>
<div className="flex items-center justify-between pointer-events-none">
<div className="flex items-center gap-1 pointer-events-auto">
@ -303,11 +557,9 @@ export function RepeaterTable({
</th>
);
})}
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
</th>
</tr>
</thead>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<tbody className="bg-white">
{data.length === 0 ? (
<tr>
@ -320,32 +572,59 @@ export function RepeaterTable({
</tr>
) : (
data.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors">
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200">
{rowIndex + 1}
<SortableRow
key={`row-${rowIndex}`}
id={`row-${rowIndex}`}
className={cn(
"hover:bg-blue-50/50 transition-colors",
selectedRows.has(rowIndex) && "bg-blue-50"
)}
>
{({ attributes, listeners, isDragging }) => (
<>
{/* 드래그 핸들 */}
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
<button
type="button"
className={cn(
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
isDragging && "cursor-grabbing"
)}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4 text-gray-400" />
</button>
</td>
{/* 체크박스 */}
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
className="border-gray-400"
/>
</td>
{/* 데이터 컬럼들 */}
{columns.map((col) => (
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
<td
key={col.field}
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden"
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }}
>
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
</>
)}
</SortableRow>
))
)}
</tbody>
</SortableContext>
</table>
</div>
</div>
</DndContext>
);
}

View File

@ -0,0 +1,162 @@
# RelatedDataButtons 컴포넌트
좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시하는 컴포넌트
## 개요
- **ID**: `related-data-buttons`
- **카테고리**: data
- **웹타입**: container
- **버전**: 1.0.0
## 사용 사례
### 품목별 라우팅 버전 관리
```
┌─────────────────────────────────────────────────────┐
│ 알루미늄 프레임 [+ 라우팅 버전 추가] │
│ ITEM001 │
│ ┌──────────────┐ ┌─────────┐ │
│ │ 기본 라우팅 ★ │ │ 개선버전 │ │
│ └──────────────┘ └─────────┘ │
└─────────────────────────────────────────────────────┘
```
## 데이터 흐름
```
1. 좌측 패널: item_info 선택
↓ SplitPanelContext.selectedLeftData
2. RelatedDataButtons: item_code로 item_routing_version 조회
↓ 버튼 클릭 시 이벤트 발생
3. 하위 테이블: routing_version_id로 item_routing_detail 필터링
```
## 설정 옵션
### 소스 매핑 (sourceMapping)
| 속성 | 타입 | 설명 |
|------|------|------|
| sourceTable | string | 좌측 패널 테이블명 (예: item_info) |
| sourceColumn | string | 필터에 사용할 컬럼 (예: item_code) |
### 헤더 표시 (headerDisplay)
| 속성 | 타입 | 설명 |
|------|------|------|
| show | boolean | 헤더 표시 여부 |
| titleColumn | string | 제목으로 표시할 컬럼 (예: item_name) |
| subtitleColumn | string | 부제목으로 표시할 컬럼 (예: item_code) |
### 버튼 데이터 소스 (buttonDataSource)
| 속성 | 타입 | 설명 |
|------|------|------|
| tableName | string | 조회할 테이블명 (예: item_routing_version) |
| filterColumn | string | 필터링할 컬럼명 (예: item_code) |
| displayColumn | string | 버튼에 표시할 컬럼명 (예: version_name) |
| valueColumn | string | 선택 시 전달할 값 컬럼 (기본: id) |
| orderColumn | string | 정렬 컬럼 |
| orderDirection | "ASC" \| "DESC" | 정렬 방향 |
### 버튼 스타일 (buttonStyle)
| 속성 | 타입 | 설명 |
|------|------|------|
| variant | string | 기본 버튼 스타일 (default, outline, secondary, ghost) |
| activeVariant | string | 선택 시 버튼 스타일 |
| size | string | 버튼 크기 (sm, default, lg) |
| defaultIndicator.column | string | 기본 버전 판단 컬럼 |
| defaultIndicator.showStar | boolean | 별표 아이콘 표시 여부 |
### 추가 버튼 (addButton)
| 속성 | 타입 | 설명 |
|------|------|------|
| show | boolean | 추가 버튼 표시 여부 |
| label | string | 버튼 라벨 |
| position | "header" \| "inline" | 버튼 위치 |
| modalScreenId | number | 연결할 모달 화면 ID |
### 이벤트 설정 (events)
| 속성 | 타입 | 설명 |
|------|------|------|
| targetTable | string | 필터링할 하위 테이블명 |
| targetFilterColumn | string | 하위 테이블의 필터 컬럼명 |
## 이벤트
### related-button-select
버튼 선택 시 발생하는 커스텀 이벤트
```typescript
window.addEventListener("related-button-select", (e: CustomEvent) => {
const { targetTable, filterColumn, filterValue, selectedData } = e.detail;
// 하위 테이블 필터링 처리
});
```
## 사용 예시
### 품목별 라우팅 버전 화면
```typescript
const config: RelatedDataButtonsConfig = {
sourceMapping: {
sourceTable: "item_info",
sourceColumn: "item_code",
},
headerDisplay: {
show: true,
titleColumn: "item_name",
subtitleColumn: "item_code",
},
buttonDataSource: {
tableName: "item_routing_version",
filterColumn: "item_code",
displayColumn: "version_name",
valueColumn: "id",
},
buttonStyle: {
variant: "outline",
activeVariant: "default",
defaultIndicator: {
column: "is_default",
showStar: true,
},
},
events: {
targetTable: "item_routing_detail",
targetFilterColumn: "routing_version_id",
},
addButton: {
show: true,
label: "+ 라우팅 버전 추가",
position: "header",
},
autoSelectFirst: true,
};
```
## 분할 패널과 함께 사용
```
┌─────────────────┬──────────────────────────────────────────────┐
│ │ [RelatedDataButtons 컴포넌트] │
│ 품목 목록 │ 품목명 표시 + 버전 버튼들 │
│ (좌측 패널) ├──────────────────────────────────────────────┤
│ │ [DataTable 컴포넌트] │
│ item_info │ 공정 순서 테이블 (item_routing_detail) │
│ │ related-button-select 이벤트로 필터링 │
└─────────────────┴──────────────────────────────────────────────┘
```
## 개발자 정보
- **생성일**: 2024-12
- **경로**: `lib/registry/components/related-data-buttons/`

View File

@ -0,0 +1,462 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Star, Loader2, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { dataApi } from "@/lib/api/data";
import type { RelatedDataButtonsConfig, ButtonItem } from "./types";
// 전역 상태: 현재 선택된 버튼 데이터를 외부에서 접근 가능하게
declare global {
interface Window {
__relatedButtonsSelectedData?: {
selectedItem: ButtonItem | null;
masterData: Record<string, any> | null;
config: RelatedDataButtonsConfig | null;
};
// 🆕 RelatedDataButtons가 대상으로 하는 테이블 목록 (전역 레지스트리)
__relatedButtonsTargetTables?: Set<string>;
}
}
// 전역 레지스트리 초기화
if (typeof window !== "undefined" && !window.__relatedButtonsTargetTables) {
window.__relatedButtonsTargetTables = new Set();
}
interface RelatedDataButtonsComponentProps {
config: RelatedDataButtonsConfig;
className?: string;
style?: React.CSSProperties;
}
export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentProps> = ({
config,
className,
style,
}) => {
const [buttons, setButtons] = useState<ButtonItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<ButtonItem | null>(null);
const [loading, setLoading] = useState(false);
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
// SplitPanel Context 연결
const splitPanelContext = useSplitPanelContext();
// 선택된 데이터를 전역 상태에 저장 (외부 버튼에서 접근용)
useEffect(() => {
window.__relatedButtonsSelectedData = {
selectedItem,
masterData,
config,
};
console.log("🔄 [RelatedDataButtons] 전역 상태 업데이트:", {
selectedItem,
hasConfig: !!config,
modalLink: config?.modalLink,
});
}, [selectedItem, masterData, config]);
// 좌측 패널에서 선택된 데이터 감지
useEffect(() => {
if (!splitPanelContext?.selectedLeftData) {
setMasterData(null);
setButtons([]);
setSelectedId(null);
setSelectedItem(null);
// 🆕 좌측 데이터가 없을 때 대상 테이블에 빈 상태 알림
if (config.events?.targetTable) {
window.dispatchEvent(new CustomEvent("related-button-select", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: null, // null로 설정하여 빈 상태 표시
selectedData: null,
},
}));
}
return;
}
setMasterData(splitPanelContext.selectedLeftData);
}, [splitPanelContext?.selectedLeftData, config.events]);
// 🆕 컴포넌트 마운트 시 대상 테이블에 필터 필요 알림
useEffect(() => {
if (config.events?.targetTable) {
// 전역 레지스트리에 등록
window.__relatedButtonsTargetTables?.add(config.events.targetTable);
// 이벤트도 발생 (이미 마운트된 테이블 컴포넌트를 위해)
window.dispatchEvent(new CustomEvent("related-button-register", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
},
}));
console.log("📝 [RelatedDataButtons] 대상 테이블에 필터 등록:", config.events.targetTable);
}
return () => {
// 컴포넌트 언마운트 시 등록 해제
if (config.events?.targetTable) {
window.__relatedButtonsTargetTables?.delete(config.events.targetTable);
window.dispatchEvent(new CustomEvent("related-button-unregister", {
detail: {
targetTable: config.events.targetTable,
},
}));
}
};
}, [config.events?.targetTable, config.events?.targetFilterColumn]);
// 버튼 데이터 로드
const loadButtons = useCallback(async () => {
if (!masterData || !config.buttonDataSource?.tableName) {
return;
}
const filterValue = masterData[config.sourceMapping.sourceColumn];
if (!filterValue) {
setButtons([]);
return;
}
setLoading(true);
try {
const { tableName, filterColumn, displayColumn, valueColumn, orderColumn, orderDirection } = config.buttonDataSource;
const response = await dataApi.getTableData(tableName, {
filters: { [filterColumn]: filterValue },
sortBy: orderColumn || "created_date",
sortOrder: (orderDirection?.toLowerCase() || "asc") as "asc" | "desc",
size: 50,
});
if (response.data && response.data.length > 0) {
const defaultConfig = config.buttonStyle?.defaultIndicator;
const items: ButtonItem[] = response.data.map((row: Record<string, any>) => {
let isDefault = false;
if (defaultConfig?.column) {
const val = row[defaultConfig.column];
const checkValue = defaultConfig.value || "Y";
isDefault = val === checkValue || val === true || val === "true";
}
return {
id: row.id || row[valueColumn || "id"],
displayText: row[displayColumn] || row.id,
value: row[valueColumn || "id"],
isDefault,
rawData: row,
};
});
setButtons(items);
// 자동 선택: 기본 항목 또는 첫 번째 항목
if (config.autoSelectFirst && items.length > 0) {
const defaultItem = items.find(item => item.isDefault);
const targetItem = defaultItem || items[0];
setSelectedId(targetItem.id);
setSelectedItem(targetItem);
emitSelection(targetItem);
}
}
} catch (error) {
console.error("RelatedDataButtons 데이터 로드 실패:", error);
setButtons([]);
} finally {
setLoading(false);
}
}, [masterData, config.buttonDataSource, config.sourceMapping, config.buttonStyle, config.autoSelectFirst]);
// masterData 변경 시 버튼 로드
useEffect(() => {
if (masterData) {
setSelectedId(null); // 마스터 변경 시 선택 초기화
setSelectedItem(null);
loadButtons();
}
}, [masterData, loadButtons]);
// 선택 이벤트 발생
const emitSelection = useCallback((item: ButtonItem) => {
if (!config.events?.targetTable || !config.events?.targetFilterColumn) {
return;
}
// 커스텀 이벤트 발생 (하위 테이블 필터링용)
window.dispatchEvent(new CustomEvent("related-button-select", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: item.value,
selectedData: item.rawData,
},
}));
console.log("📌 RelatedDataButtons 선택 이벤트:", {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: item.value,
});
}, [config.events]);
// 버튼 클릭 핸들러
const handleButtonClick = useCallback((item: ButtonItem) => {
setSelectedId(item.id);
setSelectedItem(item);
emitSelection(item);
}, [emitSelection]);
// 모달 열기 (선택된 버튼 데이터 전달)
const openModalWithSelectedData = useCallback((targetScreenId: number) => {
if (!selectedItem) {
console.warn("선택된 버튼이 없습니다.");
return;
}
// 데이터 매핑 적용
const initialData: Record<string, any> = {};
if (config.modalLink?.dataMapping) {
config.modalLink.dataMapping.forEach(mapping => {
if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") {
initialData[mapping.targetField] = selectedItem.id;
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
}
});
} else {
// 기본 매핑: id를 routing_version_id로 전달
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
}
console.log("📤 RelatedDataButtons 모달 열기:", {
targetScreenId,
selectedItem,
initialData,
});
window.dispatchEvent(new CustomEvent("open-screen-modal", {
detail: {
screenId: targetScreenId,
initialData,
onSuccess: () => {
loadButtons(); // 모달 성공 후 새로고침
},
},
}));
}, [selectedItem, config.modalLink, loadButtons]);
// 외부 버튼에서 모달 열기 요청 수신
useEffect(() => {
const handleExternalModalOpen = (event: CustomEvent) => {
const { targetScreenId, componentId } = event.detail || {};
// componentId가 지정되어 있고 현재 컴포넌트가 아니면 무시
if (componentId && componentId !== config.sourceMapping?.sourceTable) {
return;
}
if (targetScreenId && selectedItem) {
openModalWithSelectedData(targetScreenId);
}
};
window.addEventListener("related-buttons-open-modal" as any, handleExternalModalOpen);
return () => {
window.removeEventListener("related-buttons-open-modal" as any, handleExternalModalOpen);
};
}, [selectedItem, config.sourceMapping, openModalWithSelectedData]);
// 내부 모달 링크 버튼 클릭
const handleModalLinkClick = useCallback(() => {
if (!config.modalLink?.targetScreenId) {
console.warn("모달 링크 설정이 없습니다.");
return;
}
openModalWithSelectedData(config.modalLink.targetScreenId);
}, [config.modalLink, openModalWithSelectedData]);
// 추가 버튼 클릭
const handleAddClick = useCallback(() => {
if (!config.addButton?.modalScreenId) return;
const filterValue = masterData?.[config.sourceMapping.sourceColumn];
window.dispatchEvent(new CustomEvent("open-screen-modal", {
detail: {
screenId: config.addButton.modalScreenId,
initialData: {
[config.buttonDataSource.filterColumn]: filterValue,
},
onSuccess: () => {
loadButtons(); // 모달 성공 후 새로고침
},
},
}));
}, [config.addButton, config.buttonDataSource.filterColumn, config.sourceMapping.sourceColumn, masterData, loadButtons]);
// 버튼 variant 계산
const getButtonVariant = useCallback((item: ButtonItem): "default" | "outline" | "secondary" | "ghost" => {
if (selectedId === item.id) {
return config.buttonStyle?.activeVariant || "default";
}
return config.buttonStyle?.variant || "outline";
}, [selectedId, config.buttonStyle]);
// 마스터 데이터 없음
if (!masterData) {
return (
<div className={cn("rounded-lg border bg-card p-4", className)} style={style}>
<p className="text-sm text-muted-foreground text-center">
</p>
</div>
);
}
const headerConfig = config.headerDisplay;
const addButtonConfig = config.addButton;
const modalLinkConfig = config.modalLink;
return (
<div className={cn("rounded-lg border bg-card", className)} style={style}>
{/* 헤더 영역 */}
{headerConfig?.show !== false && (
<div className="flex items-start justify-between p-4 pb-3">
<div>
{/* 제목 (품목명 등) */}
{headerConfig?.titleColumn && masterData[headerConfig.titleColumn] && (
<h3 className="text-lg font-semibold">
{masterData[headerConfig.titleColumn]}
</h3>
)}
{/* 부제목 (품목코드 등) */}
{headerConfig?.subtitleColumn && masterData[headerConfig.subtitleColumn] && (
<p className="text-sm text-muted-foreground">
{masterData[headerConfig.subtitleColumn]}
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* 모달 링크 버튼 (헤더 위치) */}
{modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition === "header" && (
<Button
variant="outline"
size="sm"
onClick={handleModalLinkClick}
disabled={!selectedItem}
title={!selectedItem ? "버튼을 먼저 선택하세요" : ""}
>
<ExternalLink className="mr-1 h-4 w-4" />
{modalLinkConfig.buttonLabel || "상세 추가"}
</Button>
)}
{/* 헤더 위치 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position === "header" && (
<Button
variant="default"
size="sm"
onClick={handleAddClick}
className="bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "버전 추가"}
</Button>
)}
</div>
</div>
)}
{/* 버튼 영역 */}
<div className="px-4 pb-4">
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : buttons.length === 0 ? (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{config.emptyMessage || "데이터가 없습니다"}
</p>
{/* 인라인 추가 버튼 (데이터 없을 때) */}
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
<Button
variant="outline"
size="sm"
onClick={handleAddClick}
className="border-dashed"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "추가"}
</Button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{buttons.map((item) => (
<Button
key={item.id}
variant={getButtonVariant(item)}
size={config.buttonStyle?.size || "default"}
onClick={() => handleButtonClick(item)}
className={cn(
"relative",
selectedId === item.id && "ring-2 ring-primary ring-offset-1"
)}
>
{/* 기본 버전 별표 */}
{item.isDefault && config.buttonStyle?.defaultIndicator?.showStar && (
<Star className="mr-1.5 h-3.5 w-3.5 fill-yellow-400 text-yellow-400" />
)}
{item.displayText}
</Button>
))}
{/* 모달 링크 버튼 (인라인 위치) */}
{modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition !== "header" && (
<Button
variant="outline"
size={config.buttonStyle?.size || "default"}
onClick={handleModalLinkClick}
disabled={!selectedItem}
title={!selectedItem ? "버튼을 먼저 선택하세요" : ""}
>
<ExternalLink className="mr-1 h-4 w-4" />
{modalLinkConfig.buttonLabel || "상세 추가"}
</Button>
)}
{/* 인라인 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
<Button
variant="outline"
size={config.buttonStyle?.size || "default"}
onClick={handleAddClick}
className="border-dashed"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "추가"}
</Button>
)}
</div>
)}
</div>
</div>
);
};
export default RelatedDataButtonsComponent;

View File

@ -0,0 +1,874 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { screenApi } from "@/lib/api/screen";
import type { RelatedDataButtonsConfig } from "./types";
// 화면 정보 타입
interface ScreenInfo {
screenId: number;
screenName: string;
tableName?: string;
}
// 화면 선택 컴포넌트
interface ScreenSelectorProps {
value?: number;
onChange: (screenId: number | undefined, tableName?: string) => void;
placeholder?: string;
}
const ScreenSelector: React.FC<ScreenSelectorProps> = ({ value, onChange, placeholder = "화면 선택" }) => {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadScreens = async () => {
setLoading(true);
try {
const response = await screenApi.getScreens({ size: 500 });
if (response.data) {
setScreens(response.data.map((s: any) => ({
screenId: s.screenId,
screenName: s.screenName || s.name || `화면 ${s.screenId}`,
tableName: s.tableName || s.table_name,
})));
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
const selectedScreen = screens.find(s => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between text-xs h-9">
{loading ? "로딩중..." : selectedScreen ? `${selectedScreen.screenName} (${selectedScreen.screenId})` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2 text-center"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName} ${screen.screenId}`}
onSelect={() => {
onChange(screen.screenId, screen.tableName);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
<span className="truncate">{screen.screenName}</span>
<span className="ml-auto text-muted-foreground">({screen.screenId})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface TableInfo {
tableName: string;
displayName?: string;
}
interface ColumnInfo {
columnName: string;
columnLabel?: string;
}
interface RelatedDataButtonsConfigPanelProps {
config: RelatedDataButtonsConfig;
onChange: (config: RelatedDataButtonsConfig) => void;
tables?: TableInfo[];
}
export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPanelProps> = ({
config,
onChange,
tables: propTables = [],
}) => {
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnInfo[]>([]);
const [buttonTableColumns, setButtonTableColumns] = useState<ColumnInfo[]>([]);
const [targetModalTableColumns, setTargetModalTableColumns] = useState<ColumnInfo[]>([]); // 대상 모달 테이블 컬럼
const [targetModalTableName, setTargetModalTableName] = useState<string>(""); // 대상 모달 테이블명
const [eventTargetTableColumns, setEventTargetTableColumns] = useState<ColumnInfo[]>([]); // 하위 테이블 연동 대상 테이블 컬럼
// Popover 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [buttonTableOpen, setButtonTableOpen] = useState(false);
// 전체 테이블 로드
useEffect(() => {
const loadTables = async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.tableLabel || t.table_label || t.displayName,
})));
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
loadTables();
}, []);
// 소스 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.sourceMapping?.sourceTable) {
setSourceTableColumns([]);
return;
}
try {
const response = await getTableColumns(config.sourceMapping.sourceTable);
if (response.success && response.data?.columns) {
setSourceTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.sourceMapping?.sourceTable]);
// 버튼 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.buttonDataSource?.tableName) {
setButtonTableColumns([]);
return;
}
try {
const response = await getTableColumns(config.buttonDataSource.tableName);
if (response.success && response.data?.columns) {
setButtonTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("버튼 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.buttonDataSource?.tableName]);
// 대상 모달 화면의 테이블명 로드 (초기 로드 및 screenId 변경 시)
useEffect(() => {
const loadTargetScreenTable = async () => {
if (!config.modalLink?.targetScreenId) {
setTargetModalTableName("");
return;
}
try {
const screenInfo = await screenApi.getScreen(config.modalLink.targetScreenId);
if (screenInfo?.tableName) {
setTargetModalTableName(screenInfo.tableName);
}
} catch (error) {
console.error("대상 모달 화면 정보 로드 실패:", error);
}
};
loadTargetScreenTable();
}, [config.modalLink?.targetScreenId]);
// 대상 모달 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetModalTableName) {
setTargetModalTableColumns([]);
return;
}
try {
const response = await getTableColumns(targetModalTableName);
if (response.success && response.data?.columns) {
setTargetModalTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("대상 모달 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [targetModalTableName]);
// 하위 테이블 연동 대상 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.events?.targetTable) {
setEventTargetTableColumns([]);
return;
}
try {
const response = await getTableColumns(config.events.targetTable);
if (response.success && response.data?.columns) {
setEventTargetTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("하위 테이블 연동 대상 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.events?.targetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback((updates: Partial<RelatedDataButtonsConfig>) => {
onChange({ ...config, ...updates });
}, [config, onChange]);
const updateSourceMapping = useCallback((updates: Partial<RelatedDataButtonsConfig["sourceMapping"]>) => {
onChange({
...config,
sourceMapping: { ...config.sourceMapping, ...updates },
});
}, [config, onChange]);
const updateHeaderDisplay = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["headerDisplay"]>>) => {
onChange({
...config,
headerDisplay: { ...config.headerDisplay, ...updates } as any,
});
}, [config, onChange]);
const updateButtonDataSource = useCallback((updates: Partial<RelatedDataButtonsConfig["buttonDataSource"]>) => {
onChange({
...config,
buttonDataSource: { ...config.buttonDataSource, ...updates },
});
}, [config, onChange]);
const updateButtonStyle = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["buttonStyle"]>>) => {
onChange({
...config,
buttonStyle: { ...config.buttonStyle, ...updates },
});
}, [config, onChange]);
const updateAddButton = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["addButton"]>>) => {
onChange({
...config,
addButton: { ...config.addButton, ...updates },
});
}, [config, onChange]);
const updateEvents = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["events"]>>) => {
onChange({
...config,
events: { ...config.events, ...updates },
});
}, [config, onChange]);
const updateModalLink = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["modalLink"]>>) => {
onChange({
...config,
modalLink: { ...config.modalLink, ...updates },
});
}, [config, onChange]);
const tables = allTables.length > 0 ? allTables : propTables;
return (
<div className="space-y-6">
{/* 소스 매핑 (좌측 패널 연결) */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> ( )</Label>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Popover open={sourceTableOpen} onOpenChange={setSourceTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.sourceMapping?.sourceTable || "테이블 선택"}
<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">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateSourceMapping({ sourceTable: table.tableName });
setSourceTableOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", config.sourceMapping?.sourceTable === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Select
value={config.sourceMapping?.sourceColumn || ""}
onValueChange={(value) => updateSourceMapping({ sourceColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 헤더 표시 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Switch
checked={config.headerDisplay?.show !== false}
onCheckedChange={(checked) => updateHeaderDisplay({ show: checked })}
/>
</div>
{config.headerDisplay?.show !== false && (
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.headerDisplay?.titleColumn || ""}
onValueChange={(value) => updateHeaderDisplay({ titleColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="제목 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<Select
value={config.headerDisplay?.subtitleColumn || "__none__"}
onValueChange={(value) => updateHeaderDisplay({ subtitleColumn: value === "__none__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="부제목 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* 버튼 데이터 소스 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Popover open={buttonTableOpen} onOpenChange={setButtonTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.buttonDataSource?.tableName || "테이블 선택"}
<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">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateButtonDataSource({ tableName: table.tableName });
setButtonTableOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", config.buttonDataSource?.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonDataSource?.filterColumn || ""}
onValueChange={(value) => updateButtonDataSource({ filterColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{buttonTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonDataSource?.displayColumn || ""}
onValueChange={(value) => updateButtonDataSource({ displayColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{buttonTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 버튼 스타일 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonStyle?.variant || "outline"}
onValueChange={(value: any) => updateButtonStyle({ variant: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="secondary">Secondary</SelectItem>
<SelectItem value="ghost">Ghost</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonStyle?.activeVariant || "default"}
onValueChange={(value: any) => updateButtonStyle({ activeVariant: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="secondary">Secondary</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 기본 표시 설정 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.buttonStyle?.defaultIndicator?.column || "__none__"}
onValueChange={(value) => updateButtonStyle({
defaultIndicator: {
...config.buttonStyle?.defaultIndicator,
column: value === "__none__" ? "" : value,
showStar: config.buttonStyle?.defaultIndicator?.showStar ?? true,
},
})}
>
<SelectTrigger>
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{buttonTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.buttonStyle?.defaultIndicator?.column && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={config.buttonStyle?.defaultIndicator?.showStar ?? true}
onCheckedChange={(checked) => updateButtonStyle({
defaultIndicator: {
...config.buttonStyle?.defaultIndicator,
column: config.buttonStyle?.defaultIndicator?.column || "",
showStar: checked,
},
})}
/>
<Label className="text-xs"> </Label>
</div>
</div>
)}
</div>
{/* 이벤트 설정 (하위 테이블 연동) */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.events?.targetTable || "테이블 선택"}
<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">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateEvents({ targetTable: table.tableName });
}}
>
<Check className={cn("mr-2 h-4 w-4", config.events?.targetTable === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Select
value={config.events?.targetFilterColumn || ""}
onValueChange={(value) => updateEvents({ targetFilterColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{eventTargetTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
{eventTargetTableColumns.length === 0 && config.events?.targetTable && (
<p className="text-xs text-muted-foreground"> ...</p>
)}
{!config.events?.targetTable && (
<p className="text-xs text-muted-foreground"> </p>
)}
</div>
</div>
{/* 추가 버튼 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Switch
checked={config.addButton?.show ?? false}
onCheckedChange={(checked) => updateAddButton({ show: checked })}
/>
</div>
{config.addButton?.show && (
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.addButton?.label || ""}
onChange={(e) => updateAddButton({ label: e.target.value })}
placeholder="+ 버전 추가"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={config.addButton?.position || "header"}
onValueChange={(value: any) => updateAddButton({ position: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.addButton?.modalScreenId}
onChange={(screenId) => updateAddButton({ modalScreenId: screenId })}
placeholder="화면 선택"
/>
</div>
</div>
)}
</div>
{/* 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> ( )</Label>
<Switch
checked={config.modalLink?.enabled ?? false}
onCheckedChange={(checked) => updateModalLink({ enabled: checked })}
/>
</div>
{config.modalLink?.enabled && (
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.triggerType || "external"}
onValueChange={(value: any) => updateModalLink({ triggerType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="external"> ( )</SelectItem>
<SelectItem value="button"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{config.modalLink?.triggerType === "button" && (
<>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.modalLink?.buttonLabel || ""}
onChange={(e) => updateModalLink({ buttonLabel: e.target.value })}
placeholder="공정 추가"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.buttonPosition || "header"}
onValueChange={(value: any) => updateModalLink({ buttonPosition: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.modalLink?.targetScreenId}
onChange={(screenId, tableName) => {
updateModalLink({ targetScreenId: screenId });
if (tableName) {
setTargetModalTableName(tableName);
}
}}
placeholder="화면 선택"
/>
{targetModalTableName && (
<p className="text-xs text-muted-foreground mt-1">
: {targetModalTableName}
</p>
)}
</div>
<div className="space-y-2 pt-2 border-t">
<Label className="text-xs font-medium"> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.dataMapping?.[0]?.sourceField === "id" ? "__id__" :
config.modalLink?.dataMapping?.[0]?.sourceField === "value" ? "__value__" :
config.modalLink?.dataMapping?.[0]?.sourceField || "__id__"}
onValueChange={(value) => updateModalLink({
dataMapping: [{
sourceField: value === "__id__" ? "id" : value === "__value__" ? "value" : value,
targetField: config.modalLink?.dataMapping?.[0]?.targetField || ""
}]
})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__id__">ID ( )</SelectItem>
<SelectItem value="__value__"> (valueColumn)</SelectItem>
{buttonTableColumns
.filter(col => col.columnName !== "id") // id 중복 제거
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.dataMapping?.[0]?.targetField || "__none__"}
onValueChange={(value) => updateModalLink({
dataMapping: [{
sourceField: config.modalLink?.dataMapping?.[0]?.sourceField || "id",
targetField: value === "__none__" ? "" : value
}]
})}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{targetModalTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
{targetModalTableColumns.length === 0 && targetModalTableName && (
<p className="text-xs text-muted-foreground"> ...</p>
)}
{!targetModalTableName && (
<p className="text-xs text-muted-foreground"> </p>
)}
</div>
</div>
</div>
</div>
)}
</div>
{/* 기타 설정 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="flex items-center gap-2">
<Switch
checked={config.autoSelectFirst ?? true}
onCheckedChange={(checked) => updateConfig({ autoSelectFirst: checked })}
/>
<Label className="text-xs"> </Label>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.emptyMessage || ""}
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
placeholder="데이터가 없습니다"
/>
</div>
</div>
</div>
);
};
export default RelatedDataButtonsConfigPanel;

View File

@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { RelatedDataButtonsDefinition } from "./index";
import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
/**
* RelatedDataButtons
*
*/
export class RelatedDataButtonsRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = RelatedDataButtonsDefinition;
render(): React.ReactElement {
const { component } = this.props;
return (
<RelatedDataButtonsComponent
config={component?.config || RelatedDataButtonsDefinition.defaultConfig}
className={component?.className}
style={component?.style}
/>
);
}
}
// 자동 등록 실행
RelatedDataButtonsRenderer.registerSelf();

View File

@ -0,0 +1,53 @@
import type { ComponentConfig } from "@/lib/registry/types";
export const relatedDataButtonsConfig: ComponentConfig = {
id: "related-data-buttons",
name: "연관 데이터 버튼",
description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들",
category: "data",
webType: "container",
version: "1.0.0",
icon: "LayoutList",
defaultConfig: {
sourceMapping: {
sourceTable: "",
sourceColumn: "",
},
headerDisplay: {
show: true,
titleColumn: "",
subtitleColumn: "",
},
buttonDataSource: {
tableName: "",
filterColumn: "",
displayColumn: "",
valueColumn: "id",
orderColumn: "created_date",
orderDirection: "ASC",
},
buttonStyle: {
variant: "outline",
activeVariant: "default",
size: "default",
defaultIndicator: {
column: "",
showStar: true,
},
},
addButton: {
show: false,
label: "+ 버전 추가",
position: "header",
},
events: {
targetTable: "",
targetFilterColumn: "",
},
autoSelectFirst: true,
emptyMessage: "데이터가 없습니다",
},
configPanelComponent: "RelatedDataButtonsConfigPanel",
rendererComponent: "RelatedDataButtonsRenderer",
};

View File

@ -0,0 +1,71 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
import { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel";
/**
* RelatedDataButtons
*
*/
export const RelatedDataButtonsDefinition = createComponentDefinition({
id: "related-data-buttons",
name: "연관 데이터 버튼",
nameEng: "Related Data Buttons",
description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들",
category: ComponentCategory.DATA,
webType: "container",
component: RelatedDataButtonsComponent,
defaultConfig: {
sourceMapping: {
sourceTable: "",
sourceColumn: "",
},
headerDisplay: {
show: true,
titleColumn: "",
subtitleColumn: "",
},
buttonDataSource: {
tableName: "",
filterColumn: "",
displayColumn: "",
valueColumn: "id",
orderColumn: "created_date",
orderDirection: "ASC",
},
buttonStyle: {
variant: "outline",
activeVariant: "default",
size: "default",
defaultIndicator: {
column: "",
showStar: true,
},
},
addButton: {
show: false,
label: "+ 버전 추가",
position: "header",
},
events: {
targetTable: "",
targetFilterColumn: "",
},
autoSelectFirst: true,
emptyMessage: "데이터가 없습니다",
},
defaultSize: { width: 400, height: 120 },
configPanel: RelatedDataButtonsConfigPanel,
icon: "LayoutList",
tags: ["버튼", "연관데이터", "마스터디테일", "라우팅"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { RelatedDataButtonsConfig, ButtonItem } from "./types";
export { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
export { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel";

View File

@ -0,0 +1,128 @@
/**
* RelatedDataButtons
*
* ,
*
*
* 예시: 품목 / +
*/
/**
* ( )
*/
export interface HeaderDisplayConfig {
show?: boolean; // 헤더 표시 여부
titleColumn: string; // 제목으로 표시할 컬럼 (예: item_name)
subtitleColumn?: string; // 부제목으로 표시할 컬럼 (예: item_code)
}
/**
*
*/
export interface ButtonDataSourceConfig {
tableName: string; // 조회할 테이블명 (예: item_routing_version)
filterColumn: string; // 필터링할 컬럼명 (예: item_code)
displayColumn: string; // 버튼에 표시할 컬럼명 (예: version_name)
valueColumn?: string; // 선택 시 전달할 값 컬럼 (기본: id)
orderColumn?: string; // 정렬 컬럼
orderDirection?: "ASC" | "DESC"; // 정렬 방향
}
/**
*
*/
export interface ButtonStyleConfig {
variant?: "default" | "outline" | "secondary" | "ghost";
activeVariant?: "default" | "outline" | "secondary";
size?: "sm" | "default" | "lg";
// 기본 버전 표시 설정
defaultIndicator?: {
column: string; // 기본 여부 판단 컬럼 (예: is_default)
value?: string; // 기본 값 (기본: "Y" 또는 true)
showStar?: boolean; // 별표 아이콘 표시
badgeText?: string; // 뱃지 텍스트 (예: "기본")
};
}
/**
*
*/
export interface AddButtonConfig {
show?: boolean;
label?: string; // 기본: "+ 버전 추가"
modalScreenId?: number;
position?: "header" | "inline"; // header: 헤더 우측, inline: 버튼들과 함께
}
/**
* ( )
*/
export interface EventConfig {
// 선택 시 하위 테이블 필터링
targetTable?: string; // 필터링할 테이블명 (예: item_routing_detail)
targetFilterColumn?: string; // 필터 컬럼명 (예: routing_version_id)
// 커스텀 이벤트
customEventName?: string;
}
/**
* ( )
*/
export interface ModalLinkConfig {
enabled?: boolean; // 모달 연동 활성화
targetScreenId?: number; // 열릴 모달 화면 ID
triggerType?: "button" | "external"; // button: 별도 버튼, external: 외부 버튼에서 호출
buttonLabel?: string; // 버튼 텍스트 (triggerType이 button일 때)
buttonPosition?: "header" | "inline"; // 버튼 위치
// 데이터 매핑: 선택된 버튼 데이터 → 모달 초기값
dataMapping?: {
sourceField: string; // 버튼 데이터의 필드명 (예: "id", "value")
targetField: string; // 모달에 전달할 필드명 (예: "routing_version_id")
}[];
}
/**
*
*/
export interface RelatedDataButtonsConfig {
// 소스 매핑 (좌측 패널 연결)
sourceMapping: {
sourceTable: string; // 좌측 패널 테이블명
sourceColumn: string; // 필터에 사용할 컬럼 (예: item_code)
};
// 헤더 표시 설정
headerDisplay?: HeaderDisplayConfig;
// 버튼 데이터 소스
buttonDataSource: ButtonDataSourceConfig;
// 버튼 스타일
buttonStyle?: ButtonStyleConfig;
// 추가 버튼
addButton?: AddButtonConfig;
// 이벤트 설정
events?: EventConfig;
// 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달)
modalLink?: ModalLinkConfig;
// 자동 선택
autoSelectFirst?: boolean; // 첫 번째 (또는 기본) 항목 자동 선택
// 빈 상태 메시지
emptyMessage?: string;
}
/**
*
*/
export interface ButtonItem {
id: string;
displayText: string;
value: string;
isDefault: boolean;
rawData: Record<string, any>;
}

View File

@ -1744,7 +1744,9 @@ function RowNumberingConfigSection({
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col, index) => (
{tableColumns
.filter((col) => col.field && col.field.trim() !== "")
.map((col, index) => (
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
{col.label || col.field}
</SelectItem>

View File

@ -29,15 +29,47 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
// 컴포넌트의 필드명 (formData 키)
const fieldName = (component as any).columnName || component.id;
// repeaterConfig 또는 componentConfig에서 설정 가져오기
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = config.groupByColumn;
const targetTable = config.targetTable;
const groupByColumn = rawConfig.groupByColumn;
const targetTable = rawConfig.targetTable;
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
const config = useMemo(() => {
const rawFields = rawConfig.fields || [];
console.log("📋 [RepeaterFieldGroup] config 생성:", {
rawFieldsCount: rawFields.length,
rawFieldNames: rawFields.map((f: any) => f.name),
columnInfoKeys: Object.keys(columnInfo),
hasColumnInfo: Object.keys(columnInfo).length > 0,
});
const fields = rawFields.map((field: any) => {
const colInfo = columnInfo[field.name];
// DB의 webType 또는 web_type을 field.type으로 적용
const dbWebType = colInfo?.webType || colInfo?.web_type;
// 타입 오버라이드 조건:
// 1. field.type이 없거나
// 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우
const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text");
if (colInfo && dbWebType && shouldOverride) {
console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name}${dbWebType}`);
return { ...field, type: dbWebType };
}
return field;
});
return { ...rawConfig, fields };
}, [rawConfig, columnInfo]);
// formData에서 값 가져오기 (value prop보다 우선)
const rawValue = formData?.[fieldName] ?? value;
@ -48,8 +80,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
const configFields = config.fields || [];
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
configFields.some((field: any) => formData?.[field.name] !== undefined);
const hasRepeaterFieldsInFormData =
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
@ -57,6 +89,112 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
const splitPanelPosition = screenContext?.splitPanelPosition;
const isRightPanel = splitPanelPosition === "right";
const selectedLeftData = splitPanelContext?.selectedLeftData;
// 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기
// screen-split-panel에서 설정한 linkedFilters 사용
const linkedFilters = splitPanelContext?.linkedFilters || [];
const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues;
// 🆕 FK 컬럼 설정 우선순위:
// 1. linkedFilters에서 targetTable에 해당하는 설정 찾기
// 2. config.fkColumn (컴포넌트 설정)
// 3. config.groupByColumn (그룹화 컬럼)
let fkSourceColumn: string | null = null;
let fkTargetColumn: string | null = null;
let linkedFilterTargetTable: string | null = null;
// linkedFilters에서 FK 컬럼 찾기
if (linkedFilters.length > 0 && selectedLeftData) {
// 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨)
const linkedFilter = linkedFilters[0];
fkSourceColumn = linkedFilter.sourceColumn;
// targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리
// 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no"
const targetColumnParts = linkedFilter.targetColumn.split(".");
if (targetColumnParts.length === 2) {
linkedFilterTargetTable = targetColumnParts[0];
fkTargetColumn = targetColumnParts[1];
} else {
fkTargetColumn = linkedFilter.targetColumn;
}
}
// 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블
const effectiveTargetTable = targetTable || linkedFilterTargetTable;
// 🆕 DB에서 컬럼 정보 로드 (webType 등)
useEffect(() => {
const loadColumnInfo = async () => {
if (!effectiveTargetTable) return;
try {
const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`);
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data);
// 응답 구조에 따라 데이터 추출
// 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } }
let columns: any[] = [];
if (response.data?.success && response.data?.data) {
// data.columns가 배열인 경우 (실제 응답 구조)
if (Array.isArray(response.data.data.columns)) {
columns = response.data.data.columns;
}
// data가 배열인 경우
else if (Array.isArray(response.data.data)) {
columns = response.data.data;
}
// data 자체가 객체이고 배열이 아닌 경우 (키-값 형태)
else if (typeof response.data.data === "object") {
columns = Object.values(response.data.data);
}
}
// success 없이 바로 배열인 경우
else if (Array.isArray(response.data)) {
columns = response.data;
}
console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개");
if (columns.length > 0) {
const colMap: Record<string, any> = {};
columns.forEach((col: any) => {
// columnName 또는 column_name 또는 name 키 사용
const colName = col.columnName || col.column_name || col.name;
if (colName) {
colMap[colName] = col;
}
});
setColumnInfo(colMap);
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", {
table: effectiveTargetTable,
columns: Object.keys(colMap),
webTypes: Object.entries(colMap).map(
([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`,
),
});
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error);
}
};
loadColumnInfo();
}, [effectiveTargetTable]);
// linkedFilters가 없으면 config에서 가져오기
const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn;
const fkValue =
fkSourceColumn && selectedLeftData
? selectedLeftData[fkSourceColumn]
: fkColumn && selectedLeftData
? selectedLeftData[fkColumn]
: null;
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
fieldName,
hasFormData: !!formData,
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
groupByColumn,
groupKeyValue,
targetTable,
linkedFilterTargetTable,
effectiveTargetTable,
hasGroupedData: groupedData !== null,
groupedDataLength: groupedData?.length,
// 🆕 분할 패널 관련 정보
linkedFiltersCount: linkedFilters.length,
linkedFilters: linkedFilters.map((f) => `${f.sourceColumn}${f.targetColumn}`),
fkSourceColumn,
fkTargetColumn,
splitPanelPosition,
isRightPanel,
hasSelectedLeftData: !!selectedLeftData,
// 🆕 selectedLeftData 상세 정보 (디버깅용)
selectedLeftDataId: selectedLeftData?.id,
selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A",
selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null,
fkColumn,
fkValue,
});
// 🆕 수정 모드에서 그룹화된 데이터 로드
@ -154,11 +308,122 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
loadGroupedData();
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
// 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드
// 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링
const prevFkValueRef = useRef<string | null>(null);
useEffect(() => {
const loadDataByFK = async () => {
// 우측 패널이 아니면 스킵
if (!isRightPanel) {
return;
}
// 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화
if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) {
console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", {
fkColumn,
fkValue,
prevFkValue: prevFkValueRef.current,
});
// 이전에 데이터가 있었다면 초기화
if (prevFkValueRef.current !== null) {
setGroupedData([]);
setOriginalItemIds([]);
onChange?.([]);
prevFkValueRef.current = null;
}
return;
}
// FK 컬럼이나 타겟 테이블이 없으면 스킵
if (!fkColumn || !effectiveTargetTable) {
console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", {
fkColumn,
effectiveTargetTable,
});
return;
}
// 같은 FK 값으로 이미 로드했으면 스킵
const currentFkValueStr = String(fkValue);
if (prevFkValueRef.current === currentFkValueStr) {
console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr);
return;
}
prevFkValueRef.current = currentFkValueStr;
console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", {
fkColumn,
fkValue,
effectiveTargetTable,
});
setIsLoadingGroupData(true);
try {
// API 호출: FK 값을 기준으로 데이터 조회
const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, {
page: 1,
size: 100,
search: { [fkColumn]: fkValue },
});
if (response.data?.success) {
const items = response.data?.data?.data || [];
console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", {
count: items.length,
fkColumn,
fkValue,
effectiveTargetTable,
});
// 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정)
setGroupedData(items);
// 원본 데이터 ID 목록 저장
const itemIds = items.map((item: any) => String(item.id)).filter(Boolean);
setOriginalItemIds(itemIds);
// onChange 호출 (effectiveTargetTable 사용)
if (onChange) {
if (items.length > 0) {
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable,
_existingRecord: !!item.id,
}));
onChange(dataWithMeta);
} else {
// 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어)
console.log(" [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화");
onChange([]);
}
}
} else {
// API 실패 시 빈 배열로 설정
console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화");
setGroupedData([]);
setOriginalItemIds([]);
onChange?.([]);
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error);
setGroupedData([]);
} finally {
setIsLoadingGroupData(false);
}
};
loadDataByFK();
}, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]);
// 값이 JSON 문자열인 경우 파싱
let parsedValue: any[] = [];
// 🆕 그룹화된 데이터가 있으면 우선 사용
if (groupedData !== null && groupedData.length > 0) {
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
if (groupedData !== null) {
parsedValue = groupedData;
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
// 그룹화 설정이 없는 경우에만 단일 행 사용
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const definedFields = configRef.current.fields || [];
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']);
const systemFields = new Set([
"_targetTable",
"_isNewItem",
"created_date",
"updated_date",
"writer",
"company_code",
]);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
Object.keys(item).forEach(key => {
Object.keys(item).forEach((key) => {
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
if (key === 'id') {
if (key === "id") {
return; // id 필드 제외
}
// 정의된 필드이거나 시스템 필드인 경우만 포함
@ -267,11 +539,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
const existingItemCodes = new Set(
currentValue
.map((item: any) => item.item_code)
.filter(Boolean)
);
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
const uniqueNewItems = filteredData.filter((item: any) => {
const itemCode = item.item_code;
@ -300,9 +568,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemCodes = newItems
.map((item: any) => String(item.item_code))
.filter(Boolean);
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
splitPanelContext.addItemIds(newItemCodes);
}
@ -337,11 +603,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
}, []);
// DataReceivable 인터페이스 구현
const dataReceiver = useMemo<DataReceivable>(() => ({
const dataReceiver = useMemo<DataReceivable>(
() => ({
componentId: component.id,
componentType: "repeater-field-group",
receiveData: handleReceiveData,
}), [component.id, handleReceiveData]);
}),
[component.id, handleReceiveData],
);
// ScreenContext에 데이터 수신자로 등록
useEffect(() => {
@ -402,13 +671,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
const handleRepeaterChange = useCallback((newValue: any[]) => {
const handleRepeaterChange = useCallback(
(newValue: any[]) => {
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
let valueWithMeta = newValue;
if (isRightPanel && effectiveTargetTable) {
valueWithMeta = newValue.map((item: any) => {
const itemWithMeta = {
...item,
_targetTable: effectiveTargetTable,
};
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
if (fkColumn && fkValue && item._isNewItem) {
itemWithMeta[fkColumn] = fkValue;
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
fkColumn,
fkValue,
});
}
return itemWithMeta;
});
}
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
const jsonValue = JSON.stringify(valueWithMeta);
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
fieldName,
itemCount: valueWithMeta.length,
isRightPanel,
hasScreenContextUpdateFormData: !!screenContext?.updateFormData,
});
// 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용
// (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴)
if (isRightPanel && screenContext?.updateFormData) {
screenContext.updateFormData(fieldName, jsonValue);
console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName });
} else {
// 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용
onChange?.(jsonValue);
if (onFormDataChange) {
onFormDataChange(fieldName, jsonValue);
console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName });
}
}
// 🆕 groupedData 상태도 업데이트
setGroupedData(newValue);
setGroupedData(valueWithMeta);
// 🆕 SplitPanelContext의 addedItemIds 동기화
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
@ -419,7 +731,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
const addedIds = splitPanelContext.addedItemIds;
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id));
if (removedIds.length > 0) {
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
@ -433,13 +745,32 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
splitPanelContext.addItemIds(newIds);
}
}
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
},
[
onChange,
onFormDataChange,
splitPanelContext,
screenContext?.splitPanelPosition,
screenContext?.updateFormData,
isRightPanel,
effectiveTargetTable,
fkColumn,
fkValue,
fieldName,
],
);
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
const effectiveConfig = {
...config,
targetTable: effectiveTargetTable || config.targetTable,
};
return (
<RepeaterInput
value={parsedValue}
onChange={handleRepeaterChange}
config={config}
config={effectiveConfig}
disabled={disabled}
readonly={readonly}
menuObjid={menuObjid}

View File

@ -66,32 +66,11 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
};
render() {
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
// componentConfig 또는 config 또는 component.componentConfig 사용
const finalConfig = componentConfig || config || component?.componentConfig || {};
console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", {
hasComponentConfig: !!componentConfig,
hasConfig: !!config,
hasComponentComponentConfig: !!component?.componentConfig,
finalConfig,
splitRatio: finalConfig.splitRatio,
leftScreenId: finalConfig.leftScreenId,
rightScreenId: finalConfig.rightScreenId,
componentType: component?.componentType,
componentId: component?.id,
});
// 🆕 formData 별도 로그 (명확한 확인)
console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", {
hasFormData: !!formData,
formDataKeys: formData ? Object.keys(formData) : [],
formData: formData,
});
return (
<div style={{ width: "100%", height: "100%", ...style }}>
<ScreenSplitPanel

View File

@ -1522,9 +1522,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{availableRightTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(value) => {
updateRightPanel({ tableName: value });
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateRightPanel({ tableName: table.tableName });
setRightTableOpen(false);
}}
>

View File

@ -100,3 +100,4 @@
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [split-panel-layout (v1)](../split-panel-layout/README.md)

View File

@ -40,3 +40,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
// 자동 등록 실행
SplitPanelLayout2Renderer.registerSelf();

View File

@ -9,6 +9,13 @@ import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
declare global {
interface Window {
__relatedButtonsTargetTables?: Set<string>;
}
}
import {
ChevronLeft,
ChevronRight,
@ -201,6 +208,9 @@ export interface TableListComponentProps {
) => void;
onConfigChange?: (config: any) => void;
refreshKey?: number;
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
}
// ========================================
@ -217,7 +227,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
config,
className,
style,
formData: propFormData, // 🆕 부모에서 전달받은 formData
formData: propFormData,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
@ -225,7 +235,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey,
tableName,
userId,
screenId, // 화면 ID 추출
screenId,
parentTabId,
parentTabsComponentId,
}) => {
// ========================================
// 설정 및 스타일
@ -304,6 +316,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
// 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
filterColumn: string;
filterValue: any;
} | null>(null);
// 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부
const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => {
// 초기값: 전역 레지스트리에서 확인
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) {
return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || "");
}
return false;
});
// TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
@ -994,7 +1021,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
getColumnUniqueValues, // 고유 값 조회 함수 등록
onGroupSumChange: setGroupSumConfig, // 🆕 그룹별 합산 설정
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 탭 관련 정보 (탭 내부의 테이블인 경우)
parentTabId,
parentTabsComponentId,
screenId: screenId ? Number(screenId) : undefined,
};
registerTable(registration);
@ -1268,18 +1299,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
url: `/table-categories/${targetTable}/${targetColumn}/values`,
});
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
console.log(`📡 [TableList] API 응답 [${columnName}]:`, {
success: response.data.success,
dataLength: response.data.data?.length,
rawData: response.data,
items: response.data.data,
});
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {};
@ -1291,18 +1313,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
label: item.valueLabel,
color: item.color,
};
console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`);
});
if (Object.keys(mapping).length > 0) {
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
mappings[columnName] = mapping;
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
columnName,
mappingCount: Object.keys(mapping).length,
mappingKeys: Object.keys(mapping),
mapping,
});
} else {
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
}
@ -1342,7 +1357,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
col.columnName,
})) || [];
console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns);
// 조인 테이블별로 그룹화
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
@ -1375,7 +1389,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns);
// 조인된 테이블별로 inputType 정보 가져오기
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
@ -1421,9 +1434,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (Object.keys(mapping).length > 0) {
mappings[col.columnName] = mapping;
console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, {
mappingCount: Object.keys(mapping).length,
});
}
}
} catch (error) {
@ -1442,16 +1452,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
}
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
mappingsCount: Object.keys(mappings).length,
mappingsKeys: Object.keys(mappings),
mappings,
});
if (Object.keys(mappings).length > 0) {
setCategoryMappings(mappings);
setCategoryMappingsKey((prev) => prev + 1);
console.log("✅ [TableList] setCategoryMappings 호출 완료");
} else {
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
}
@ -1473,11 +1476,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// ========================================
const fetchTableDataInternal = useCallback(async () => {
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
tableName: tableConfig.selectedTable,
isDesignMode,
currentPage,
});
if (!tableConfig.selectedTable || isDesignMode) {
setData([]);
@ -1501,12 +1499,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", {
hasSplitPanelContext: !!splitPanelContext,
tableName: tableConfig.selectedTable,
selectedLeftData: splitPanelContext?.selectedLeftData,
linkedFilters: splitPanelContext?.linkedFilters,
});
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
@ -1522,21 +1514,56 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters);
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
for (const [key, value] of Object.entries(allLinkedFilters)) {
if (key.includes(".")) {
const [tableName, columnName] = key.split(".");
if (tableName === tableConfig.selectedTable) {
linkedFilterValues[columnName] = value;
// 연결 필터는 코드 값이므로 equals 연산자 사용
linkedFilterValues[columnName] = { value, operator: "equals" };
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
}
} else {
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
linkedFilterValues[key] = value;
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
linkedFilterValues[key] = { value, operator: "equals" };
}
}
// 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도
// 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면
// 동일한 컬럼명이 있는 경우 자동으로 필터링 적용
if (
splitPanelPosition === "right" &&
hasSelectedLeftData &&
Object.keys(linkedFilterValues).length === 0 &&
!hasLinkedFiltersConfigured
) {
const leftData = splitPanelContext.selectedLeftData!;
const tableColumns = (tableConfig.columns || []).map((col) => col.columnName);
// 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인
for (const [colName, colValue] of Object.entries(leftData)) {
// null, undefined, 빈 문자열 제외
if (colValue === null || colValue === undefined || colValue === "") continue;
// id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명)
if (colName === "id" || colName === "objid" || colName === "company_code") continue;
// 현재 테이블에 동일한 컬럼이 있는지 확인
if (tableColumns.includes(colName)) {
// 자동 컬럼 매칭도 equals 연산자 사용
linkedFilterValues[colName] = { value: colValue, operator: "equals" };
hasLinkedFiltersConfigured = true;
console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`);
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues);
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
}
@ -1552,10 +1579,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return;
}
// 검색 필터와 연결 필터 병합
// 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (isRelatedButtonTarget && !relatedButtonFilter) {
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 필터 값 준비
let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
value: relatedButtonFilter.filterValue,
operator: "equals",
};
console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues);
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
const hasFilters = Object.keys(filters).length > 0;
@ -1618,7 +1666,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
});
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let excludeFilterParam: any = undefined;
@ -1749,7 +1796,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchTerm,
searchValues,
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요)
splitPanelPosition,
currentSplitPosition,
splitPanelContext?.selectedLeftData,
// 🆕 RelatedDataButtons 필터 추가
relatedButtonFilter,
isRelatedButtonTarget,
]);
const fetchTableDataDebounced = useCallback(
@ -2059,7 +2112,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
splitPanelPosition,
currentSplitPosition,
effectiveSplitPosition,
hasSplitPanelContext: !!splitPanelContext,
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
});
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
@ -2077,12 +2141,43 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
};
// 🆕 셀 클릭 핸들러 (포커스 설정)
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
e.stopPropagation();
setFocusedCell({ rowIndex, colIndex });
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
tableContainerRef.current?.focus();
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
// filteredData에서 해당 행의 데이터 가져오기
const row = filteredData[rowIndex];
if (!row) return;
const rowKey = getRowKey(row, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
if (!isCurrentlySelected) {
// 기존 선택 해제하고 새 행 선택
setSelectedRows(new Set([rowKey]));
setIsAllSelected(false);
// 분할 패널 컨텍스트에 데이터 저장
splitPanelContext.setSelectedLeftData(row);
// onSelectedRowsChange 콜백 호출
if (onSelectedRowsChange) {
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
}
}
}
};
// 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용
@ -2792,7 +2887,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
localStorage.setItem(tableStateKey, JSON.stringify(state));
console.log("✅ 테이블 상태 저장:", tableStateKey);
} catch (error) {
console.error("❌ 테이블 상태 저장 실패:", error);
}
@ -2834,7 +2928,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setHeaderFilters(filters);
}
console.log("✅ 테이블 상태 복원:", tableStateKey);
} catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error);
}
@ -2855,7 +2948,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setShowGridLines(true);
setHeaderFilters({});
toast.success("테이블 설정이 초기화되었습니다.");
console.log("✅ 테이블 상태 초기화:", tableStateKey);
} catch (error) {
console.error("❌ 테이블 상태 초기화 실패:", error);
}
@ -4100,19 +4192,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
return (
<div className="flex items-center gap-1.5 text-sm max-w-full">
<Paperclip className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span
className="text-blue-600 truncate"
title={fileNames}
>
<div className="flex max-w-full items-center gap-1.5 text-sm">
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
<span className="truncate text-blue-600" title={fileNames}>
{fileNames}
</span>
{files.length > 1 && (
<span className="text-muted-foreground text-xs flex-shrink-0">
({files.length})
</span>
)}
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
</div>
);
}
@ -4677,6 +4762,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
fetchTableLabel();
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성
const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right";
const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null;
useEffect(() => {
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
// isDesignMode,
@ -4700,7 +4789,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침
selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]);
@ -4730,6 +4819,88 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}, [tableConfig.selectedTable, isDesignMode]);
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
useEffect(() => {
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
if (isTarget) {
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
}
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
useEffect(() => {
const handleRelatedButtonRegister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
};
const handleRelatedButtonUnregister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null);
}
};
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
return () => {
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
};
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인
if (targetTable === tableConfig.selectedTable) {
// filterValue가 null이면 선택 해제 (빈 상태)
if (filterValue === null || filterValue === undefined) {
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
setRelatedButtonFilter(null);
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
} else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
setIsRelatedButtonTarget(true);
}
}
};
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
return () => {
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
};
}, [tableConfig.selectedTable]);
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => {
if (!isDesignMode) {
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
relatedButtonFilter,
isRelatedButtonTarget
});
setRefreshTrigger((prev) => prev + 1);
}
}, [relatedButtonFilter, isDesignMode]);
// 🎯 컬럼 너비 자동 계산 (내용 기반)
const calculateOptimalColumnWidth = useCallback(
(columnName: string, displayName: string): number => {

View File

@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
@ -49,8 +50,9 @@ interface TableSearchWidgetProps {
}
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = useTableOptions();
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight:
@ -64,6 +66,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setWidgetHeight = undefined;
}
// 탭별 필터 값 저장 (탭 ID -> 필터 값)
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [groupingOpen, setGroupingOpen] = useState(false);
@ -88,17 +93,24 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// Map을 배열로 변환
const allTableList = Array.from(registeredTables.values());
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
const tableList = useMemo(() => {
// "auto"면 모든 테이블 반환
if (targetPanelPosition === "auto") {
return allTableList;
}
// 현재 활성 탭 ID 목록
const activeTabIds = useMemo(() => getAllActiveTabIds(), [activeTabs]);
// 테이블 ID 패턴으로 필터링
// card-display-XXX: 좌측 패널 (카드 디스플레이)
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
const filteredTables = allTableList.filter(table => {
// 대상 패널 위치 + 활성 탭에 따라 테이블 필터링
const tableList = useMemo(() => {
// 1단계: 활성 탭 기반 필터링
// - 활성 탭에 속한 테이블만 표시
// - 탭에 속하지 않은 테이블(parentTabId가 없는)도 포함
let filteredByTab = allTableList.filter(table => {
// 탭에 속하지 않는 테이블은 항상 표시
if (!table.parentTabId) return true;
// 활성 탭에 속한 테이블만 표시
return activeTabIds.includes(table.parentTabId);
});
// 2단계: 대상 패널 위치에 따라 추가 필터링
if (targetPanelPosition !== "auto") {
filteredByTab = filteredByTab.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
@ -112,26 +124,17 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return true;
});
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
if (filteredTables.length === 0) {
console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", {
targetPanelPosition,
allTablesCount: allTableList.length,
allTableIds: allTableList.map(t => t.tableId),
});
return allTableList;
}
console.log("🔍 [TableSearchWidget] 테이블 필터링:", {
targetPanelPosition,
allTablesCount: allTableList.length,
filteredCount: filteredTables.length,
filteredTableIds: filteredTables.map(t => t.tableId),
});
// 필터링된 결과가 없으면 탭 기반 필터링 결과만 반환
if (filteredByTab.length === 0) {
return allTableList.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
}
return filteredTables;
}, [allTableList, targetPanelPosition]);
return filteredByTab;
}, [allTableList, targetPanelPosition, activeTabIds]);
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => {
@ -159,15 +162,38 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
if (!selectedTableId || !isCurrentTableInTarget) {
const targetTable = tableList[0];
console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", {
targetPanelPosition,
selectedTableId: targetTable.tableId,
tableName: targetTable.tableName,
});
setSelectedTableId(targetTable.tableId);
}
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
const currentTableTabId = currentTable?.parentTabId;
// 탭별 필터 값 저장 키 생성
const getTabFilterStorageKey = (tableName: string, tabId?: string) => {
const baseKey = screenId
? `table_filter_values_${tableName}_screen_${screenId}`
: `table_filter_values_${tableName}`;
return tabId ? `${baseKey}_tab_${tabId}` : baseKey;
};
// 탭 변경 시 이전 탭의 필터 값 저장 + 새 탭의 필터 값 복원
useEffect(() => {
if (!currentTable?.tableName) return;
// 현재 필터 값이 있으면 탭별로 저장
if (Object.keys(filterValues).length > 0 && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.setItem(storageKey, JSON.stringify(filterValues));
// 메모리 캐시에도 저장
setTabFilterValues(prev => ({
...prev,
[currentTableTabId]: filterValues
}));
}
}, [currentTableTabId, currentTable?.tableName]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => {
if (!currentTable?.tableName) return;
@ -182,14 +208,32 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
width: f.width || 200,
}));
setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
if (currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
const savedValues = localStorage.getItem(storageKey);
if (savedValues) {
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
} catch {
setFilterValues({});
}
} else {
setFilterValues({});
}
}
return;
}
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
const storageKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}`
// 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기
const filterConfigKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}`
: `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
const savedFilters = localStorage.getItem(filterConfigKey);
if (savedFilters) {
try {
@ -210,16 +254,39 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
width: f.width || 200,
}));
setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
if (currentTableTabId) {
const valuesStorageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
const savedValues = localStorage.getItem(valuesStorageKey);
if (savedValues) {
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
} catch {
setFilterValues({});
}
} else {
setFilterValues({});
}
} else {
setFilterValues({});
}
} catch (error) {
console.error("저장된 필터 불러오기 실패:", error);
}
} else {
// 필터 설정이 없으면 초기화
setFilterValues({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => {
@ -317,6 +384,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterValues(newValues);
// 탭별 필터 값 저장
if (currentTable?.tableName && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.setItem(storageKey, JSON.stringify(newValues));
}
// 실시간 검색: 값 변경 시 즉시 필터 적용
applyFilters(newValues);
};
@ -374,12 +447,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return true;
});
console.log("🔍 [TableSearchWidget] 필터 적용:", {
currentTableId: currentTable?.tableId,
currentTableName: currentTable?.tableName,
filtersCount: filtersWithValues.length,
filtersWithValues,
});
currentTable?.onFilterChange(filtersWithValues);
};
@ -388,6 +455,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterValues({});
setSelectedLabels({});
currentTable?.onFilterChange([]);
// 탭별 저장된 필터 값도 초기화
if (currentTable?.tableName && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.removeItem(storageKey);
}
};
// 필터 입력 필드 렌더링

View File

@ -23,12 +23,6 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
persistSelection: tabsConfig.persistSelection || false,
};
console.log("🎨 TabsWidget 렌더링:", {
componentId: component.id,
tabs: tabsComponent.tabs,
tabsLength: tabsComponent.tabs.length,
component,
});
// TabsWidget 동적 로드
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;

View File

@ -104,7 +104,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value;
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
isGeneratingRef.current = true; // 생성 시작 플래그
@ -361,7 +360,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
@ -386,15 +385,17 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
{/* @ 구분자 */}
<span className="text-base font-medium text-muted-foreground">@</span>
<span className="text-muted-foreground text-base font-medium">@</span>
{/* 도메인 선택/입력 (Combobox) */}
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
@ -406,14 +407,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
disabled={componentConfig.disabled || false}
className={cn(
"flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "cursor-not-allowed bg-muted text-muted-foreground opacity-50" : "bg-background text-foreground",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"hover:border-ring/80",
emailDomainOpen && "border-ring ring-2 ring-ring/50",
emailDomainOpen && "border-ring ring-ring/50 ring-2",
)}
>
<span className={cn("truncate", !emailDomain && "text-muted-foreground")}>{emailDomain || "도메인 선택"}</span>
<span className={cn("truncate", !emailDomain && "text-muted-foreground")}>
{emailDomain || "도메인 선택"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
@ -470,7 +475,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
@ -496,14 +501,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
<span className="text-base font-medium text-muted-foreground">-</span>
<span className="text-muted-foreground text-base font-medium">-</span>
{/* 두 번째 부분 */}
<input
@ -524,14 +531,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
<span className="text-base font-medium text-muted-foreground">-</span>
<span className="text-muted-foreground text-base font-medium">-</span>
{/* 세 번째 부분 */}
<input
@ -552,10 +561,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
</div>
@ -569,7 +580,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
@ -591,10 +602,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
>
<option value="https://">https://</option>
@ -619,10 +632,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
</div>
@ -636,7 +651,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
@ -669,11 +684,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}}
className={cn(
"box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
/>
</div>
@ -692,13 +709,15 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
{/* 수동/자동 모드 표시 배지 */}
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<span className={cn(
"text-[10px] px-2 py-0.5 rounded-full font-medium",
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center gap-1">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
isManualMode
? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
)}>
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
)}
>
{isManualMode ? "수동" : "자동"}
</span>
</div>
@ -706,12 +725,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<input
type={inputType}
defaultValue={(() => {
value={(() => {
let displayValue = "";
if (isInteractive && formData && component.columnName) {
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값
const rawValue = formData[component.columnName] || autoGeneratedValue || "";
const rawValue = formData[component.columnName] ?? autoGeneratedValue ?? "";
// 객체인 경우 빈 문자열로 변환 (에러 방지)
displayValue = typeof rawValue === "object" ? "" : String(rawValue);
} else {
@ -744,11 +763,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
readOnly={componentConfig.readonly || false}
className={cn(
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
"disabled:cursor-not-allowed"
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
onClick={(e) => {
handleClick(e);
@ -774,7 +795,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
console.log("🔄 수동 모드로 전환:", {
field: component.columnName,
original: originalAutoGeneratedValue,
modified: newValue
modified: newValue,
});
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
@ -789,7 +810,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
setIsManualMode(false);
console.log("🔄 자동 모드로 복구:", {
field: component.columnName,
value: newValue
value: newValue,
});
// 채번 규칙 ID 복구

View File

@ -55,29 +55,11 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
onClick?.();
};
// DOM에 전달하면 안 되는 React-specific props 필터링
const {
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig: _componentConfig,
component: _component,
isSelected: _isSelected,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
size: _size,
position: _position,
style: _style,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
...domProps
} = props;
// DOM에 전달하면 안 되는 React-specific props 필터링 - 모든 커스텀 props 제거
// domProps를 사용하지 않고 필요한 props만 명시적으로 전달
return (
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label

View File

@ -840,3 +840,4 @@ export function FieldDetailSettingsModal({
);
}

View File

@ -794,3 +794,4 @@ export function SaveSettingsModal({
);
}

View File

@ -514,3 +514,4 @@ export function SectionLayoutModal({
);
}

View File

@ -17,6 +17,7 @@ export type ButtonActionType =
| "edit" // 편집
| "copy" // 복사 (품목코드 초기화)
| "navigate" // 페이지 이동
| "openRelatedModal" // 연관 데이터 버튼의 선택 데이터로 모달 열기
| "openModalWithData" // 데이터를 전달하면서 모달 열기
| "modal" // 모달 열기
| "control" // 제어 흐름
@ -28,7 +29,8 @@ export type ButtonActionType =
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
/**
*
@ -211,6 +213,37 @@ export interface ButtonActionConfig {
maxSelection?: number; // 최대 선택 개수
};
};
// 연관 데이터 버튼 모달 열기 관련
relatedModalConfig?: {
targetScreenId: number; // 열릴 모달 화면 ID
componentId?: string; // 특정 RelatedDataButtons 컴포넌트 지정 (선택사항)
};
// 즉시 저장 (Quick Insert) 관련
quickInsertConfig?: {
targetTable: string; // 저장할 테이블명
columnMappings: Array<{
targetColumn: string; // 대상 테이블의 컬럼명
sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; // 값 소스 타입
sourceComponentId?: string; // 컴포넌트에서 값을 가져올 경우 컴포넌트 ID
sourceColumnName?: string; // 컴포넌트의 columnName (formData 접근용)
sourceColumn?: string; // 좌측 패널 또는 컴포넌트의 특정 컬럼
fixedValue?: any; // 고정값
userField?: "userId" | "userName" | "companyCode"; // currentUser 타입일 때 사용할 필드
}>;
duplicateCheck?: {
enabled: boolean; // 중복 체크 활성화 여부
columns?: string[]; // 중복 체크할 컬럼들
errorMessage?: string; // 중복 시 에러 메시지
};
afterInsert?: {
refreshData?: boolean; // 저장 후 데이터 새로고침 (테이블리스트, 카드 디스플레이)
clearComponents?: boolean; // 저장 후 컴포넌트 값 초기화
showSuccessMessage?: boolean; // 성공 메시지 표시 여부 (기본: true)
successMessage?: string; // 성공 메시지
};
};
}
/**
@ -265,6 +298,12 @@ export interface ButtonActionContext {
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData?: Record<string, any>;
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
splitPanelContext?: {
selectedLeftData?: Record<string, any>;
refreshRightPanel?: () => void;
};
}
/**
@ -329,6 +368,9 @@ export class ButtonActionExecutor {
case "openModalWithData":
return await this.handleOpenModalWithData(config, context);
case "openRelatedModal":
return await this.handleOpenRelatedModal(config, context);
case "modal":
return await this.handleModal(config, context);
@ -365,6 +407,9 @@ export class ButtonActionExecutor {
case "swap_fields":
return await this.handleSwapFields(config, context);
case "quickInsert":
return await this.handleQuickInsert(config, context);
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
@ -376,13 +421,100 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static validateRequiredFields(context: ButtonActionContext): { isValid: boolean; missingFields: string[] } {
const missingFields: string[] = [];
const { formData, allComponents } = context;
if (!allComponents || allComponents.length === 0) {
console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵");
return { isValid: true, missingFields: [] };
}
allComponents.forEach((component: any) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
console.log("🔍 [validateRequiredFields] 필수 항목 누락:", {
columnName,
label,
value,
isRequired,
});
missingFields.push(label || columnName);
}
}
});
return {
isValid: missingFields.length === 0,
missingFields,
};
}
/**
* (INSERT/UPDATE - DB )
*/
private static saveCallCount = 0; // 🆕 호출 횟수 추적
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
this.saveCallCount++;
const callId = this.saveCallCount;
const { formData, originalData, tableName, screenId, onSave } = context;
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
const formDataHash = JSON.stringify(Object.keys(formData).sort());
const lockKey = `${screenId}-${tableName}-${formDataHash}`;
const lastCallTime = this.saveLock.get(lockKey) || 0;
const now = Date.now();
const timeDiff = now - lastCallTime;
console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 });
if (timeDiff < 2000) {
console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, {
lockKey: lockKey.slice(0, 50),
timeDiff,
});
return true; // 중복 호출은 성공으로 처리
}
this.saveLock.set(lockKey, now);
console.log(`💾 [handleSave #${callId}] 저장 시작:`, {
callId,
formDataKeys: Object.keys(formData),
tableName,
screenId,
hasOnSave: !!onSave,
});
// ✅ 필수 항목 검증
console.log("🔍 [handleSave] 필수 항목 검증 시작:", {
hasAllComponents: !!context.allComponents,
allComponentsLength: context.allComponents?.length || 0,
});
const requiredValidation = this.validateRequiredFields(context);
if (!requiredValidation.isValid) {
console.log("❌ [handleSave] 필수 항목 누락:", requiredValidation.missingFields);
toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
return false;
}
console.log("✅ [handleSave] 필수 항목 검증 통과");
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
@ -807,6 +939,107 @@ export class ButtonActionExecutor {
}
}
// 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터)
// formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기
console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData));
console.log("🔎 [handleSave] formData 전체:", context.formData);
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, {
type: typeof fieldValue,
isArray: Array.isArray(fieldValue),
valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue,
});
// JSON 문자열인 경우 파싱
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try {
parsedData = JSON.parse(fieldValue);
} catch {
continue;
}
}
// 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
const firstItem = parsedData[0];
const repeaterTargetTable = firstItem?._targetTable;
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey}${repeaterTargetTable}`, {
itemCount: parsedData.length,
});
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
const {
_targetTable: _,
_isNewItem,
_existingRecord: __,
_originalItemIds: ___,
_deletedItemIds: ____,
...dataToSave
} = item;
// 🆕 빈 id 필드 제거 (새 항목인 경우)
if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) {
delete dataToSave.id;
}
// 사용자 정보 추가
const dataWithMeta: Record<string, unknown> = {
...dataToSave,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
};
try {
// 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
if (isNewRecord) {
// INSERT (새 항목)
// id 필드 완전히 제거 (자동 생성되도록)
delete dataWithMeta.id;
// 빈 문자열 id도 제거
if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) {
delete dataWithMeta.id;
}
console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta);
const insertResult = await apiClient.post(
`/table-management/tables/${repeaterTargetTable}/add`,
dataWithMeta,
);
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
} else if (item.id) {
// UPDATE (기존 항목)
const originalData = { id: item.id };
const updatedData = { ...dataWithMeta, id: item.id };
console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", {
id: item.id,
table: repeaterTargetTable,
});
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
originalData,
updatedData,
});
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
}
} catch (err) {
const error = err as { response?: { data?: unknown }; message?: string };
console.error(
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
error.response?.data || error.message,
);
}
}
}
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
const repeatScreenModalKeys = Object.keys(context.formData).filter(
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
@ -814,11 +1047,36 @@ export class ButtonActionExecutor {
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
// 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용)
const repeaterFieldGroupTables: string[] = [];
for (const [, fieldValue] of Object.entries(context.formData)) {
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try {
parsedData = JSON.parse(fieldValue);
} catch {
continue;
}
}
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) {
repeaterFieldGroupTables.push(parsedData[0]._targetTable);
}
}
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
const shouldSkipMainSave =
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
if (shouldSkipMainSave) {
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
console.log(
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
{
repeatScreenModalTables,
repeaterFieldGroupTables,
},
);
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
} else {
saveResult = await DynamicFormApi.saveFormData({
screenId,
@ -1621,18 +1879,111 @@ export class ButtonActionExecutor {
return false;
}
/**
*
* RelatedDataButtons
*/
private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
console.log("🔍 [openRelatedModal] 설정 확인:", {
config,
relatedModalConfig: config.relatedModalConfig,
targetScreenId: config.targetScreenId,
finalTargetScreenId: targetScreenId,
});
if (!targetScreenId) {
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
toast.error("모달 화면 ID가 설정되지 않았습니다.");
return false;
}
// RelatedDataButtons에서 선택된 데이터 가져오기
const relatedData = window.__relatedButtonsSelectedData;
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
relatedData,
selectedItem: relatedData?.selectedItem,
config: relatedData?.config,
});
if (!relatedData?.selectedItem) {
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
toast.warning("먼저 버튼을 선택해주세요.");
return false;
}
const { selectedItem, config: relatedConfig } = relatedData;
// 데이터 매핑 적용
const initialData: Record<string, any> = {};
console.log("🔍 [openRelatedModal] 매핑 설정:", {
modalLink: relatedConfig?.modalLink,
dataMapping: relatedConfig?.modalLink?.dataMapping,
});
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
relatedConfig.modalLink.dataMapping.forEach(mapping => {
console.log("🔍 [openRelatedModal] 매핑 처리:", {
mapping,
sourceField: mapping.sourceField,
targetField: mapping.targetField,
selectedItemValue: selectedItem.value,
selectedItemId: selectedItem.id,
rawDataValue: selectedItem.rawData[mapping.sourceField],
});
if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") {
initialData[mapping.targetField] = selectedItem.id;
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
}
});
} else {
// 기본 매핑: id를 routing_version_id로 전달
console.log("🔍 [openRelatedModal] 기본 매핑 사용");
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
}
console.log("📤 [openRelatedModal] 모달 열기:", {
targetScreenId,
selectedItem,
initialData,
});
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: {
screenId: targetScreenId,
title: config.modalTitle,
description: config.modalDescription,
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
onSuccess: () => {
// 성공 후 데이터 새로고침
window.dispatchEvent(new CustomEvent("refreshTableData"));
},
},
}));
return true;
}
/**
*
* 🔧 modal (INSERT)
* edit (UPDATE)
* ( )
*/
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 모달 열기 로직
console.log("모달 열기 (신규 등록 모드):", {
console.log("모달 열기:", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
// 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로)
selectedRowsData: context.selectedRowsData,
});
if (config.targetScreenId) {
@ -1649,11 +2000,10 @@ export class ButtonActionExecutor {
}
}
// 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음
// selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨
// edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작
console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음");
console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData);
// 선택된 행 데이터 수집
const selectedData = context.selectedRowsData || [];
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
@ -1662,11 +2012,10 @@ export class ButtonActionExecutor {
title: config.modalTitle || "화면",
description: description,
size: config.modalSize || "md",
// 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음
// edit 액션에서만 이 데이터를 사용
selectedData: [],
selectedIds: [],
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용)
// 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
splitPanelParentData: context.splitPanelParentData || {},
},
});
@ -1866,11 +2215,18 @@ export class ButtonActionExecutor {
});
}
// 🆕 modalDataStore에서 선택된 전체 데이터 가져오기 (RepeatScreenModal에서 사용)
const modalData = dataRegistry[dataSourceId] || [];
const selectedData = modalData.map((item: any) => item.originalData || item);
const selectedIds = selectedData.map((row: any) => row.id).filter(Boolean);
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
dataSourceId,
rawParentData,
mappedParentData: parentData,
fieldMappings: config.fieldMappings,
selectedDataCount: selectedData.length,
selectedIds,
});
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
@ -1882,6 +2238,9 @@ export class ButtonActionExecutor {
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
// 🆕 선택된 데이터 전달 (RepeatScreenModal에서 groupedData로 사용)
selectedData: selectedData,
selectedIds: selectedIds,
},
});
@ -2135,6 +2494,8 @@ export class ButtonActionExecutor {
editData: rowData,
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
tableName: context.tableName, // 🆕 테이블명 전달
buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용)
buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등)
onSave: () => {
context.onRefresh?.();
},
@ -2670,9 +3031,10 @@ export class ButtonActionExecutor {
/**
* (After Timing)
* EditModal public으로
*
*/
private static async executeAfterSaveControl(
public static async executeAfterSaveControl(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<void> {
@ -4116,25 +4478,65 @@ export class ButtonActionExecutor {
try {
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
// 추적 중인지 확인
if (!this.trackingIntervalId) {
toast.warning("진행 중인 위치 추적이 없습니다.");
return false;
}
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
const isTrackingActive = !!this.trackingIntervalId;
// 타이머 정리
if (!isTrackingActive) {
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
} else {
// 타이머 정리 (추적 중인 경우에만)
clearInterval(this.trackingIntervalId);
this.trackingIntervalId = null;
}
const tripId = this.currentTripId;
// 마지막 위치 저장 (trip_status를 completed로)
const departure =
// 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용)
let dbDeparture: string | null = null;
let dbArrival: string | null = null;
let dbVehicleId: string | null = null;
const userId = context.userId || this.trackingUserId;
if (userId) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles";
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
// DB에서 현재 차량 정보 조회
const vehicleResponse = await apiClient.post(
`/table-management/tables/${statusTableName}/data`,
{
page: 1,
size: 1,
search: { [keyField]: userId },
autoFilter: true,
},
);
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
if (vehicleData) {
dbDeparture = vehicleData.departure || null;
dbArrival = vehicleData.arrival || null;
dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null;
console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId });
}
} catch (dbError) {
console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError);
}
}
// 마지막 위치 저장 (추적 중이었던 경우에만)
if (isTrackingActive) {
// DB 값 우선, 없으면 formData 사용
const departure = dbDeparture ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const arrival = dbArrival ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId =
const vehicleId = dbVehicleId ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
await this.saveLocationToHistory(
@ -4146,9 +4548,10 @@ export class ButtonActionExecutor {
vehicleId,
"completed",
);
}
// 🆕 거리/시간 계산 및 저장
if (tripId) {
// 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만)
if (isTrackingActive && tripId) {
try {
const tripStats = await this.calculateTripStats(tripId);
console.log("📊 운행 통계:", tripStats);
@ -4260,9 +4663,9 @@ export class ButtonActionExecutor {
}
}
// 상태 변경 (vehicles 테이블 등)
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
const effectiveContext = context.userId ? context : this.trackingContext;
// 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config;
const effectiveContext = context.userId ? context : this.trackingContext || context;
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
try {
@ -4982,6 +5385,313 @@ export class ButtonActionExecutor {
}
}
/**
* (Quick Insert)
*
*/
private static async handleQuickInsert(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("⚡ Quick Insert 액션 실행:", { config, context });
const quickInsertConfig = config.quickInsertConfig;
if (!quickInsertConfig?.targetTable) {
toast.error("대상 테이블이 설정되지 않았습니다.");
return false;
}
const { formData, splitPanelContext, userId, userName, companyCode } = context;
console.log("⚡ Quick Insert 상세 정보:", {
targetTable: quickInsertConfig.targetTable,
columnMappings: quickInsertConfig.columnMappings,
formData: formData,
formDataKeys: Object.keys(formData || {}),
splitPanelContext: splitPanelContext,
selectedLeftData: splitPanelContext?.selectedLeftData,
allComponents: context.allComponents,
userId,
userName,
companyCode,
});
// 컬럼 매핑에 따라 저장할 데이터 구성
const insertData: Record<string, any> = {};
const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) {
console.log(`📍 매핑 처리 시작:`, mapping);
if (!mapping.targetColumn) {
console.log(`📍 targetColumn 없음, 스킵`);
continue;
}
let value: any = undefined;
switch (mapping.sourceType) {
case "component":
console.log(`📍 component 타입 처리:`, {
sourceComponentId: mapping.sourceComponentId,
sourceColumnName: mapping.sourceColumnName,
targetColumn: mapping.targetColumn,
});
// 컴포넌트의 현재 값
if (mapping.sourceComponentId) {
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
if (mapping.sourceColumnName) {
value = formData?.[mapping.sourceColumnName];
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
}
// 2. 없으면 컴포넌트 ID로 직접 찾기
if (value === undefined) {
value = formData?.[mapping.sourceComponentId];
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
}
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
if (value === undefined && context.allComponents) {
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
console.log(`📍 방법3 찾은 컴포넌트:`, comp);
if (comp?.columnName) {
value = formData?.[comp.columnName];
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId}${comp.columnName} = ${value}`);
}
}
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
value = formData[mapping.targetColumn];
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
}
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
if (value === undefined) {
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
}
// sourceColumn이 지정된 경우 해당 속성 추출
if (mapping.sourceColumn && value && typeof value === "object") {
value = value[mapping.sourceColumn];
console.log(`📍 sourceColumn 추출: ${mapping.sourceColumn} = ${value}`);
}
}
break;
case "leftPanel":
console.log(`📍 leftPanel 타입 처리:`, {
sourceColumn: mapping.sourceColumn,
selectedLeftData: splitPanelContext?.selectedLeftData,
});
// 좌측 패널 선택 데이터
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
console.log(`📍 leftPanel 값: ${mapping.sourceColumn} = ${value}`);
}
break;
case "fixed":
console.log(`📍 fixed 타입 처리: fixedValue = ${mapping.fixedValue}`);
// 고정값
value = mapping.fixedValue;
break;
case "currentUser":
console.log(`📍 currentUser 타입 처리: userField = ${mapping.userField}`);
// 현재 사용자 정보
switch (mapping.userField) {
case "userId":
value = userId;
break;
case "userName":
value = userName;
break;
case "companyCode":
value = companyCode;
break;
}
console.log(`📍 currentUser 값: ${value}`);
break;
default:
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
}
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
} else {
console.log(`📍 값이 비어있어서 insertData에 추가 안됨`);
}
}
// 🆕 좌측 패널 선택 데이터에서 자동 매핑 (대상 테이블에 존재하는 컬럼만)
if (splitPanelContext?.selectedLeftData) {
const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
// 대상 테이블의 컬럼 목록 조회
let targetTableColumns: string[] = [];
try {
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
}
} catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error);
}
for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) {
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
continue;
}
// 대상 테이블에 해당 컬럼이 없으면 스킵
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
continue;
}
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
if (systemColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
continue;
}
// _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) {
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
continue;
}
// 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
}
}
console.log("⚡ Quick Insert 최종 데이터:", insertData, "키 개수:", Object.keys(insertData).length);
// 필수 데이터 검증
if (Object.keys(insertData).length === 0) {
toast.error("저장할 데이터가 없습니다.");
return false;
}
// 중복 체크
console.log("📍 중복 체크 설정:", {
enabled: quickInsertConfig.duplicateCheck?.enabled,
columns: quickInsertConfig.duplicateCheck?.columns,
});
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
const duplicateCheckData: Record<string, any> = {};
for (const col of quickInsertConfig.duplicateCheck.columns) {
if (insertData[col] !== undefined) {
// 백엔드가 { value, operator } 형식을 기대하므로 변환
duplicateCheckData[col] = { value: insertData[col], operator: "equals" };
}
}
console.log("📍 중복 체크 조건:", duplicateCheckData);
if (Object.keys(duplicateCheckData).length > 0) {
try {
const checkResponse = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
{
page: 1,
pageSize: 1,
search: duplicateCheckData,
}
);
console.log("📍 중복 체크 응답:", checkResponse.data);
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0);
if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return false;
}
} catch (error) {
console.error("중복 체크 오류:", error);
// 중복 체크 실패해도 저장은 시도
}
}
} else {
console.log("📍 중복 체크 비활성화 또는 컬럼 미설정");
}
// 데이터 저장
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData
);
if (response.data?.success) {
console.log("✅ Quick Insert 저장 성공");
// 저장 후 동작 설정 로그
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
console.log("📍 데이터 새로고침 여부:", shouldRefresh);
if (shouldRefresh) {
console.log("📍 데이터 새로고침 이벤트 발송");
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("refreshTable"));
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
console.log("✅ refreshTable, refreshCardDisplay 이벤트 발송 완료");
}
}
// 컴포넌트 값 초기화
if (quickInsertConfig.afterInsert?.clearComponents && context.onFormDataChange) {
for (const mapping of columnMappings) {
if (mapping.sourceType === "component" && mapping.sourceComponentId) {
// sourceColumnName이 있으면 그것을 사용, 없으면 sourceComponentId 사용
const fieldName = mapping.sourceColumnName || mapping.sourceComponentId;
context.onFormDataChange(fieldName, null);
console.log(`📍 컴포넌트 값 초기화: ${fieldName}`);
}
}
}
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
}
return true;
} else {
toast.error(response.data?.message || "저장에 실패했습니다.");
return false;
}
} catch (error: any) {
console.error("❌ Quick Insert 오류:", error);
toast.error(error.response?.data?.message || "저장 중 오류가 발생했습니다.");
return false;
}
}
/**
* (: status를 active로 )
* 🆕
@ -5435,4 +6145,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "필드 값이 교환되었습니다.",
errorMessage: "필드 값 교환 중 오류가 발생했습니다.",
},
quickInsert: {
type: "quickInsert",
successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.",
},
};

View File

@ -397,7 +397,6 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
const isSimpleConfigPanel = [
"autocomplete-search-input",
"entity-search-input",
"modal-repeater-table",
"conditional-container",
].includes(componentId);
@ -406,6 +405,19 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
}
// entity-search-input은 currentComponent 정보 필요 (참조 테이블 자동 로드용)
// 그리고 allComponents 필요 (연쇄관계 부모 필드 선택용)
if (componentId === "entity-search-input") {
return (
<ConfigPanelComponent
config={config}
onConfigChange={onChange}
currentComponent={currentComponent}
allComponents={allComponents}
/>
);
}
// 🆕 selected-items-detail-input은 특별한 props 사용
if (componentId === "selected-items-detail-input") {
return (

View File

@ -54,7 +54,7 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
// 기타
label: "text-display",
code: "select-basic", // 코드 타입은 선택상자 사용
entity: "select-basic", // 엔티티 타입은 선택상자 사용
entity: "entity-search-input", // 엔티티 타입은 전용 검색 입력 사용
category: "select-basic", // 카테고리 타입은 선택상자 사용
};

View File

@ -8,10 +8,11 @@
import { WebType } from "./unified-core";
/**
* 9
*
*/
export type BaseInputType =
| "text" // 텍스트
| "textarea" // 텍스트 에리어 (여러 줄)
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
@ -34,16 +35,18 @@ export interface DetailTypeOption {
*
*/
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]> = {
// 텍스트 → text, email, tel, url, textarea, password
// 텍스트 → text, email, tel, url, password
text: [
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
{ value: "textarea", label: "여러 줄 텍스트", description: "긴 텍스트 입력" },
{ value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" },
],
// 텍스트 에리어 → textarea
textarea: [{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" }],
// 숫자 → number, decimal, currency, percentage
number: [
{ value: "number", label: "정수", description: "정수 숫자 입력" },
@ -102,8 +105,13 @@ export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>
*
*/
export function getBaseInputType(webType: WebType): BaseInputType {
// textarea (별도 타입으로 분리)
if (webType === "textarea") {
return "textarea";
}
// text 계열
if (["text", "email", "tel", "url", "textarea", "password"].includes(webType)) {
if (["text", "email", "tel", "url", "password"].includes(webType)) {
return "text";
}
@ -167,6 +175,7 @@ export function getDefaultDetailType(baseInputType: BaseInputType): WebType {
*/
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" },
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
{ value: "code", label: "코드", description: "공통 코드 선택" },

View File

@ -5,9 +5,10 @@
* 주의: .
*/
// 9개 핵심 입력 타입
// 핵심 입력 타입
export type InputType =
| "text" // 텍스트
| "textarea" // 텍스트 에리어 (여러 줄 입력)
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
@ -42,6 +43,13 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
category: "basic",
icon: "Type",
},
{
value: "textarea",
label: "텍스트 에리어",
description: "여러 줄 텍스트 입력",
category: "basic",
icon: "AlignLeft",
},
{
value: "number",
label: "숫자",
@ -130,6 +138,11 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>>
maxLength: 500,
placeholder: "텍스트를 입력하세요",
},
textarea: {
maxLength: 2000,
rows: 4,
placeholder: "내용을 입력하세요",
},
number: {
min: 0,
step: 1,
@ -163,13 +176,17 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>>
radio: {
inline: false,
},
image: {
placeholder: "이미지를 선택하세요",
accept: "image/*",
},
};
// 레거시 웹 타입 → 입력 타입 매핑
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
// 텍스트 관련
text: "text",
textarea: "text",
textarea: "textarea",
email: "text",
tel: "text",
url: "text",
@ -204,6 +221,7 @@ export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
// 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용)
export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
text: "text",
textarea: "textarea",
number: "number",
date: "date",
code: "code",
@ -212,6 +230,7 @@ export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
select: "select",
checkbox: "checkbox",
radio: "radio",
image: "image",
};
// 입력 타입 변환 함수
@ -226,6 +245,11 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
trim: true,
maxLength: 500,
},
textarea: {
type: "string",
trim: true,
maxLength: 2000,
},
number: {
type: "number",
allowFloat: true,
@ -258,4 +282,8 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
type: "string",
options: true,
},
image: {
type: "string",
required: false,
},
};

View File

@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
minItems?: number; // 최소 항목 수
maxItems?: number; // 최대 항목 수
addButtonText?: string; // 추가 버튼 텍스트

View File

@ -363,6 +363,8 @@ export interface EntityTypeConfig {
placeholder?: string;
displayFormat?: "simple" | "detailed" | "custom"; // 표시 형식
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
// UI 모드
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
}
/**
@ -428,6 +430,111 @@ export interface ButtonTypeConfig {
// ButtonActionType과 관련된 설정은 control-management.ts에서 정의
}
// ===== 즉시 저장(quickInsert) 설정 =====
/**
*
*
*/
export interface QuickInsertColumnMapping {
/** 저장할 테이블의 대상 컬럼명 */
targetColumn: string;
/** 값 소스 타입 */
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
// sourceType별 추가 설정
/** component: 값을 가져올 컴포넌트 ID */
sourceComponentId?: string;
/** component: 컴포넌트의 columnName (formData 접근용) */
sourceColumnName?: string;
/** leftPanel: 좌측 선택 데이터의 컬럼명 */
sourceColumn?: string;
/** fixed: 고정값 */
fixedValue?: any;
/** currentUser: 사용자 정보 필드 */
userField?: "userId" | "userName" | "companyCode" | "deptCode";
}
/**
*
*/
export interface QuickInsertAfterAction {
/** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */
refreshData?: boolean;
/** 초기화할 컴포넌트 ID 목록 */
clearComponents?: string[];
/** 성공 메시지 표시 여부 */
showSuccessMessage?: boolean;
/** 커스텀 성공 메시지 */
successMessage?: string;
}
/**
*
*/
export interface QuickInsertDuplicateCheck {
/** 중복 체크 활성화 */
enabled: boolean;
/** 중복 체크할 컬럼들 */
columns: string[];
/** 중복 시 에러 메시지 */
errorMessage?: string;
}
/**
* (quickInsert)
*
* entity ,
* INSERT하는
*
* @example
* ```typescript
* const config: QuickInsertConfig = {
* targetTable: "process_equipment",
* columnMappings: [
* {
* targetColumn: "equipment_code",
* sourceType: "component",
* sourceComponentId: "equipment-select"
* },
* {
* targetColumn: "process_code",
* sourceType: "leftPanel",
* sourceColumn: "process_code"
* }
* ],
* afterInsert: {
* refreshData: true,
* clearComponents: ["equipment-select"],
* showSuccessMessage: true
* }
* };
* ```
*/
export interface QuickInsertConfig {
/** 저장할 대상 테이블명 */
targetTable: string;
/** 컬럼 매핑 설정 */
columnMappings: QuickInsertColumnMapping[];
/** 저장 후 동작 설정 */
afterInsert?: QuickInsertAfterAction;
/** 중복 체크 설정 (선택사항) */
duplicateCheck?: QuickInsertDuplicateCheck;
}
/**
*
*

View File

@ -56,11 +56,16 @@ export interface TableRegistration {
columns: TableColumn[];
dataCount?: number; // 현재 표시된 데이터 건수
// 탭 관련 정보 (탭 내부에 있는 테이블의 경우)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
screenId?: number; // 소속 화면 ID
// 콜백 함수들
onFilterChange: (filters: TableFilter[]) => void;
onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
// 데이터 조회 함수 (선택 타입 필터용)
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
@ -77,4 +82,8 @@ export interface TableOptionsContextValue {
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
selectedTableId: string | null;
setSelectedTableId: (tableId: string | null) => void;
// 활성 탭 기반 필터링
getActiveTabTables: () => TableRegistration[]; // 현재 활성 탭의 테이블만 반환
getTablesForTab: (tabId: string) => TableRegistration[]; // 특정 탭의 테이블만 반환
}

View File

@ -71,7 +71,9 @@ export type ButtonActionType =
// 제어관리 전용
| "control"
// 데이터 전달
| "transferData"; // 선택된 데이터를 다른 컴포넌트/화면으로 전달
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
// 즉시 저장
| "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT
/**
*
@ -328,6 +330,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
"newWindow",
"control",
"transferData",
"quickInsert",
];
return actionTypes.includes(value as ButtonActionType);
};

View File

@ -1685,3 +1685,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -532,3 +532,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -519,3 +519,4 @@ function ScreenViewPage() {