feature/v2-unified-renewal #379
|
|
@ -70,7 +70,7 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
|
|
@ -249,6 +249,7 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
||||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
|
app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (UnifiedSelect용)
|
||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,101 @@ import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 옵션 조회 API (UnifiedSelect용)
|
||||||
|
* GET /api/entity/:tableName/options
|
||||||
|
*
|
||||||
|
* Query Params:
|
||||||
|
* - value: 값 컬럼 (기본: id)
|
||||||
|
* - label: 표시 컬럼 (기본: name)
|
||||||
|
*/
|
||||||
|
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { value = "id", label = "name" } = req.query;
|
||||||
|
|
||||||
|
// tableName 유효성 검증
|
||||||
|
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||||
|
logger.warn("엔티티 옵션 조회 실패: 테이블명이 없음", { tableName });
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 지정되지 않았습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 테이블의 실제 컬럼 목록 조회
|
||||||
|
const columnsResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
// 요청된 컬럼 검증
|
||||||
|
const valueColumn = existingColumns.has(value as string) ? value : "id";
|
||||||
|
const labelColumn = existingColumns.has(label as string) ? label : "name";
|
||||||
|
|
||||||
|
// 둘 다 없으면 에러
|
||||||
|
if (!existingColumns.has(valueColumn as string)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `테이블 "${tableName}"에 값 컬럼 "${value}"이 존재하지 않습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// label 컬럼이 없으면 value 컬럼을 label로도 사용
|
||||||
|
const effectiveLabelColumn = existingColumns.has(labelColumn as string) ? labelColumn : valueColumn;
|
||||||
|
|
||||||
|
// WHERE 조건 (멀티테넌시)
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode !== "*" && existingColumns.has("company_code")) {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 쿼리 실행 (최대 500개)
|
||||||
|
const query = `
|
||||||
|
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
|
||||||
|
FROM ${tableName}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${effectiveLabelColumn} ASC
|
||||||
|
LIMIT 500
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info("엔티티 옵션 조회 성공", {
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn: effectiveLabelColumn,
|
||||||
|
companyCode,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("엔티티 옵션 조회 오류", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 엔티티 검색 API
|
* 엔티티 검색 API
|
||||||
* GET /api/entity-search/:tableName
|
* GET /api/entity-search/:tableName
|
||||||
|
|
|
||||||
|
|
@ -97,11 +97,16 @@ export async function getColumnList(
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
|
// 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시
|
||||||
|
const bustCache = !!req.query._t;
|
||||||
|
|
||||||
const result = await tableManagementService.getColumnList(
|
const result = await tableManagementService.getColumnList(
|
||||||
tableName,
|
tableName,
|
||||||
parseInt(page as string),
|
parseInt(page as string),
|
||||||
parseInt(size as string),
|
parseInt(size as string),
|
||||||
companyCode // 🔥 회사 코드 전달
|
companyCode, // 🔥 회사 코드 전달
|
||||||
|
bustCache // 🔥 캐시 버스팅 옵션
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { searchEntity } from "../controllers/entitySearchController";
|
import { searchEntity, getEntityOptions } from "../controllers/entitySearchController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -12,3 +12,12 @@ router.get("/:tableName", authenticateToken, searchEntity);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
// 엔티티 옵션 라우터 (UnifiedSelect용)
|
||||||
|
export const entityOptionsRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 옵션 조회 API
|
||||||
|
* GET /api/entity/:tableName/options
|
||||||
|
*/
|
||||||
|
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1658,10 +1658,16 @@ export class ScreenManagementService {
|
||||||
? inputTypeMap.get(`${tableName}.${columnName}`)
|
? inputTypeMap.get(`${tableName}.${columnName}`)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// 🆕 Unified 컴포넌트는 덮어쓰지 않음 (새로운 컴포넌트 시스템 보호)
|
||||||
|
const savedComponentType = properties?.componentType;
|
||||||
|
const isUnifiedComponent = savedComponentType?.startsWith("unified-");
|
||||||
|
|
||||||
const component = {
|
const component = {
|
||||||
id: layout.component_id,
|
id: layout.component_id,
|
||||||
// 🔥 최신 componentType이 있으면 type 덮어쓰기
|
// 🔥 최신 componentType이 있으면 type 덮어쓰기 (단, Unified 컴포넌트는 제외)
|
||||||
type: latestTypeInfo?.componentType || layout.component_type as any,
|
type: isUnifiedComponent
|
||||||
|
? layout.component_type as any // Unified는 저장된 값 유지
|
||||||
|
: (latestTypeInfo?.componentType || layout.component_type as any),
|
||||||
position: {
|
position: {
|
||||||
x: layout.position_x,
|
x: layout.position_x,
|
||||||
y: layout.position_y,
|
y: layout.position_y,
|
||||||
|
|
@ -1670,8 +1676,8 @@ export class ScreenManagementService {
|
||||||
size: { width: layout.width, height: layout.height },
|
size: { width: layout.width, height: layout.height },
|
||||||
parentId: layout.parent_id,
|
parentId: layout.parent_id,
|
||||||
...properties,
|
...properties,
|
||||||
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
|
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 (단, Unified 컴포넌트는 제외)
|
||||||
...(latestTypeInfo && {
|
...(!isUnifiedComponent && latestTypeInfo && {
|
||||||
widgetType: latestTypeInfo.inputType,
|
widgetType: latestTypeInfo.inputType,
|
||||||
inputType: latestTypeInfo.inputType,
|
inputType: latestTypeInfo.inputType,
|
||||||
componentType: latestTypeInfo.componentType,
|
componentType: latestTypeInfo.componentType,
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@ export class TableManagementService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
size: number = 50,
|
size: number = 50,
|
||||||
companyCode?: string // 🔥 회사 코드 추가
|
companyCode?: string, // 🔥 회사 코드 추가
|
||||||
|
bustCache: boolean = false // 🔥 캐시 버스팅 옵션
|
||||||
): Promise<{
|
): Promise<{
|
||||||
columns: ColumnTypeInfo[];
|
columns: ColumnTypeInfo[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -124,7 +125,7 @@ export class TableManagementService {
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}`
|
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}, bustCache: ${bustCache}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 캐시 키 생성 (companyCode 포함)
|
// 캐시 키 생성 (companyCode 포함)
|
||||||
|
|
@ -132,32 +133,37 @@ export class TableManagementService {
|
||||||
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
||||||
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
||||||
|
|
||||||
// 캐시에서 먼저 확인
|
// 🔥 캐시 버스팅: bustCache가 true면 캐시 무시
|
||||||
const cachedResult = cache.get<{
|
if (!bustCache) {
|
||||||
columns: ColumnTypeInfo[];
|
// 캐시에서 먼저 확인
|
||||||
total: number;
|
const cachedResult = cache.get<{
|
||||||
page: number;
|
columns: ColumnTypeInfo[];
|
||||||
size: number;
|
total: number;
|
||||||
totalPages: number;
|
page: number;
|
||||||
}>(cacheKey);
|
size: number;
|
||||||
if (cachedResult) {
|
totalPages: number;
|
||||||
logger.info(
|
}>(cacheKey);
|
||||||
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개`
|
if (cachedResult) {
|
||||||
);
|
logger.info(
|
||||||
|
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개`
|
||||||
|
);
|
||||||
|
|
||||||
// 디버깅: 캐시된 currency_code 확인
|
// 디버깅: 캐시된 currency_code 확인
|
||||||
const cachedCurrency = cachedResult.columns.find(
|
const cachedCurrency = cachedResult.columns.find(
|
||||||
(col: any) => col.columnName === "currency_code"
|
(col: any) => col.columnName === "currency_code"
|
||||||
);
|
);
|
||||||
if (cachedCurrency) {
|
if (cachedCurrency) {
|
||||||
console.log(`💾 [캐시] currency_code:`, {
|
console.log(`💾 [캐시] currency_code:`, {
|
||||||
columnName: cachedCurrency.columnName,
|
columnName: cachedCurrency.columnName,
|
||||||
inputType: cachedCurrency.inputType,
|
inputType: cachedCurrency.inputType,
|
||||||
webType: cachedCurrency.webType,
|
webType: cachedCurrency.webType,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedResult;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
return cachedResult;
|
logger.info(`🔥 캐시 버스팅: ${tableName} 캐시 무시`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전체 컬럼 수 조회 (캐시 확인)
|
// 전체 컬럼 수 조회 (캐시 확인)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Phase 0: 컴포넌트 사용 현황 분석
|
||||||
|
|
||||||
|
## 분석 일시
|
||||||
|
|
||||||
|
2024-12-19
|
||||||
|
|
||||||
|
## 분석 대상
|
||||||
|
|
||||||
|
- 활성화된 화면 정의 (screen_definitions.is_active = 'Y')
|
||||||
|
- 화면 레이아웃 (screen_layouts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 컴포넌트별 사용량 순위
|
||||||
|
|
||||||
|
### 상위 15개 컴포넌트
|
||||||
|
|
||||||
|
| 순위 | 컴포넌트 | 사용 횟수 | 사용 화면 수 | Unified 매핑 |
|
||||||
|
| :--: | :-------------------------- | :-------: | :----------: | :------------------------------ |
|
||||||
|
| 1 | button-primary | 571 | 364 | UnifiedInput (type: button) |
|
||||||
|
| 2 | text-input | 805 | 166 | **UnifiedInput (type: text)** |
|
||||||
|
| 3 | table-list | 130 | 130 | UnifiedList (viewMode: table) |
|
||||||
|
| 4 | table-search-widget | 127 | 127 | UnifiedList (searchable: true) |
|
||||||
|
| 5 | select-basic | 121 | 76 | **UnifiedSelect** |
|
||||||
|
| 6 | number-input | 86 | 34 | **UnifiedInput (type: number)** |
|
||||||
|
| 7 | date-input | 83 | 51 | **UnifiedDate** |
|
||||||
|
| 8 | file-upload | 41 | 18 | UnifiedMedia (type: file) |
|
||||||
|
| 9 | tabs-widget | 39 | 39 | UnifiedGroup (type: tabs) |
|
||||||
|
| 10 | split-panel-layout | 39 | 39 | UnifiedLayout (type: split) |
|
||||||
|
| 11 | category-manager | 38 | 38 | UnifiedBiz (type: category) |
|
||||||
|
| 12 | numbering-rule | 31 | 31 | UnifiedBiz (type: numbering) |
|
||||||
|
| 13 | selected-items-detail-input | 29 | 29 | 복합 컴포넌트 |
|
||||||
|
| 14 | modal-repeater-table | 25 | 25 | UnifiedList (modal: true) |
|
||||||
|
| 15 | image-widget | 29 | 29 | UnifiedMedia (type: image) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Unified 컴포넌트별 통합 대상 분석
|
||||||
|
|
||||||
|
### UnifiedInput (예상 통합 대상: 891개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 | 비율 |
|
||||||
|
| :------------ | :-------: | :---: |
|
||||||
|
| text-input | 805 | 90.3% |
|
||||||
|
| number-input | 86 | 9.7% |
|
||||||
|
|
||||||
|
**우선순위: 1위** - 가장 많이 사용되는 컴포넌트
|
||||||
|
|
||||||
|
### UnifiedSelect (예상 통합 대상: 140개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 | widgetType |
|
||||||
|
| :------------------------ | :-------: | :--------- |
|
||||||
|
| select-basic (category) | 65 | category |
|
||||||
|
| select-basic (null) | 50 | - |
|
||||||
|
| autocomplete-search-input | 19 | entity |
|
||||||
|
| entity-search-input | 20 | entity |
|
||||||
|
| checkbox-basic | 7 | checkbox |
|
||||||
|
| radio-basic | 5 | radio |
|
||||||
|
|
||||||
|
**우선순위: 2위** - 다양한 모드 지원 필요
|
||||||
|
|
||||||
|
### UnifiedDate (예상 통합 대상: 83개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 |
|
||||||
|
| :---------------- | :-------: |
|
||||||
|
| date-input (null) | 58 |
|
||||||
|
| date-input (date) | 23 |
|
||||||
|
| date-input (text) | 2 |
|
||||||
|
|
||||||
|
**우선순위: 3위**
|
||||||
|
|
||||||
|
### UnifiedList (예상 통합 대상: 283개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 | 비고 |
|
||||||
|
| :-------------------- | :-------: | :---------- |
|
||||||
|
| table-list | 130 | 기본 테이블 |
|
||||||
|
| table-search-widget | 127 | 검색 테이블 |
|
||||||
|
| modal-repeater-table | 25 | 모달 반복 |
|
||||||
|
| repeater-field-group | 15 | 반복 필드 |
|
||||||
|
| card-display | 11 | 카드 표시 |
|
||||||
|
| simple-repeater-table | 1 | 단순 반복 |
|
||||||
|
|
||||||
|
**우선순위: 4위** - 핵심 데이터 표시 컴포넌트
|
||||||
|
|
||||||
|
### UnifiedMedia (예상 통합 대상: 70개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 |
|
||||||
|
| :------------ | :-------: |
|
||||||
|
| file-upload | 41 |
|
||||||
|
| image-widget | 29 |
|
||||||
|
|
||||||
|
### UnifiedLayout (예상 통합 대상: 62개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 |
|
||||||
|
| :------------------ | :-------: |
|
||||||
|
| split-panel-layout | 39 |
|
||||||
|
| screen-split-panel | 21 |
|
||||||
|
| split-panel-layout2 | 2 |
|
||||||
|
|
||||||
|
### UnifiedGroup (예상 통합 대상: 99개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 |
|
||||||
|
| :-------------------- | :-------: |
|
||||||
|
| tabs-widget | 39 |
|
||||||
|
| conditional-container | 23 |
|
||||||
|
| section-paper | 11 |
|
||||||
|
| section-card | 10 |
|
||||||
|
| text-display | 13 |
|
||||||
|
| universal-form-modal | 7 |
|
||||||
|
| repeat-screen-modal | 5 |
|
||||||
|
|
||||||
|
### UnifiedBiz (예상 통합 대상: 79개)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 사용 횟수 |
|
||||||
|
| :--------------------- | :-------: |
|
||||||
|
| category-manager | 38 |
|
||||||
|
| numbering-rule | 31 |
|
||||||
|
| flow-widget | 8 |
|
||||||
|
| rack-structure | 2 |
|
||||||
|
| related-data-buttons | 2 |
|
||||||
|
| location-swap-selector | 2 |
|
||||||
|
| tax-invoice-list | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 우선순위 결정
|
||||||
|
|
||||||
|
### Phase 1 우선순위 (즉시 효과가 큰 컴포넌트)
|
||||||
|
|
||||||
|
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 영향 화면 수 | 이유 |
|
||||||
|
| :---: | :---------------- | :----------: | :----------: | :--------------- |
|
||||||
|
| **1** | **UnifiedInput** | 891개 | 200+ | 가장 많이 사용 |
|
||||||
|
| **2** | **UnifiedSelect** | 140개 | 100+ | 다양한 모드 필요 |
|
||||||
|
| **3** | **UnifiedDate** | 83개 | 51 | 비교적 단순 |
|
||||||
|
|
||||||
|
### Phase 2 우선순위 (데이터 표시 컴포넌트)
|
||||||
|
|
||||||
|
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 |
|
||||||
|
| :---: | :---------------- | :----------: | :--------------- |
|
||||||
|
| **4** | **UnifiedList** | 283개 | 핵심 데이터 표시 |
|
||||||
|
| **5** | **UnifiedLayout** | 62개 | 레이아웃 구조 |
|
||||||
|
| **6** | **UnifiedGroup** | 99개 | 콘텐츠 그룹화 |
|
||||||
|
|
||||||
|
### Phase 3 우선순위 (특수 컴포넌트)
|
||||||
|
|
||||||
|
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 |
|
||||||
|
| :---: | :------------------- | :----------: | :------------ |
|
||||||
|
| **7** | **UnifiedMedia** | 70개 | 파일/이미지 |
|
||||||
|
| **8** | **UnifiedBiz** | 79개 | 비즈니스 특화 |
|
||||||
|
| **9** | **UnifiedHierarchy** | 0개 | 신규 기능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 주요 발견 사항
|
||||||
|
|
||||||
|
### 4.1 button-primary 분리 검토
|
||||||
|
|
||||||
|
- 사용량: 571개 (1위)
|
||||||
|
- 현재 계획: UnifiedInput에 포함
|
||||||
|
- **제안**: 별도 `UnifiedButton` 컴포넌트로 분리 검토
|
||||||
|
- 버튼은 입력과 성격이 다름
|
||||||
|
- 액션 타입, 스타일, 권한 등 복잡한 설정 필요
|
||||||
|
|
||||||
|
### 4.2 conditional-container 처리
|
||||||
|
|
||||||
|
- 사용량: 23개
|
||||||
|
- 현재 계획: 공통 conditional 속성으로 통합
|
||||||
|
- **확인 필요**: 기존 화면에서 어떻게 마이그레이션할지
|
||||||
|
|
||||||
|
### 4.3 category 관련 컴포넌트
|
||||||
|
|
||||||
|
- select-basic (category): 65개
|
||||||
|
- category-manager: 38개
|
||||||
|
- **총 103개**의 카테고리 관련 컴포넌트
|
||||||
|
- 카테고리 시스템 통합 중요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 다음 단계
|
||||||
|
|
||||||
|
1. [ ] 데이터 마이그레이션 전략 설계 (Phase 0-2)
|
||||||
|
2. [ ] sys_input_type JSON Schema 설계 (Phase 0-3)
|
||||||
|
3. [ ] DynamicConfigPanel 프로토타입 (Phase 0-4)
|
||||||
|
4. [ ] UnifiedInput 구현 시작 (Phase 1-1)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
# Phase 0: 데이터 마이그레이션 전략
|
||||||
|
|
||||||
|
## 1. 현재 데이터 구조 분석
|
||||||
|
|
||||||
|
### screen_layouts.properties 구조
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
// 기본 정보
|
||||||
|
"type": "component",
|
||||||
|
"componentType": "text-input", // 기존 컴포넌트 타입
|
||||||
|
|
||||||
|
// 위치/크기
|
||||||
|
"position": { "x": 68, "y": 80, "z": 1 },
|
||||||
|
"size": { "width": 324, "height": 40 },
|
||||||
|
|
||||||
|
// 라벨 및 스타일
|
||||||
|
"label": "품목코드",
|
||||||
|
"style": {
|
||||||
|
"labelColor": "#000000",
|
||||||
|
"labelDisplay": true,
|
||||||
|
"labelFontSize": "14px",
|
||||||
|
"labelFontWeight": "500",
|
||||||
|
"labelMarginBottom": "8px"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 데이터 바인딩
|
||||||
|
"tableName": "order_table",
|
||||||
|
"columnName": "part_code",
|
||||||
|
|
||||||
|
// 필드 속성
|
||||||
|
"required": true,
|
||||||
|
"readonly": false,
|
||||||
|
|
||||||
|
// 컴포넌트별 설정
|
||||||
|
"componentConfig": {
|
||||||
|
"type": "text-input",
|
||||||
|
"format": "none",
|
||||||
|
"webType": "text",
|
||||||
|
"multiline": false,
|
||||||
|
"placeholder": "텍스트를 입력하세요"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그리드 레이아웃
|
||||||
|
"gridColumns": 5,
|
||||||
|
"gridRowIndex": 0,
|
||||||
|
"gridColumnStart": 1,
|
||||||
|
"gridColumnSpan": "third",
|
||||||
|
|
||||||
|
// 기타
|
||||||
|
"parentId": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 마이그레이션 전략: 하이브리드 방식
|
||||||
|
|
||||||
|
### 2.1 비파괴적 전환 (권장)
|
||||||
|
|
||||||
|
기존 필드를 유지하면서 새로운 필드를 추가하는 방식
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
// 기존 필드 유지 (하위 호환성)
|
||||||
|
"componentType": "text-input",
|
||||||
|
"componentConfig": { ... },
|
||||||
|
|
||||||
|
// 신규 필드 추가
|
||||||
|
"unifiedType": "UnifiedInput", // 새로운 통합 컴포넌트 타입
|
||||||
|
"unifiedConfig": { // 새로운 설정 구조
|
||||||
|
"type": "text",
|
||||||
|
"format": "none",
|
||||||
|
"placeholder": "텍스트를 입력하세요"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 마이그레이션 메타데이터
|
||||||
|
"_migration": {
|
||||||
|
"version": "2.0",
|
||||||
|
"migratedAt": "2024-12-19T00:00:00Z",
|
||||||
|
"migratedBy": "system",
|
||||||
|
"originalType": "text-input"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 렌더링 로직 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 렌더러에서 unifiedType 우선 사용
|
||||||
|
function renderComponent(props: ComponentProps) {
|
||||||
|
// 신규 타입이 있으면 Unified 컴포넌트 사용
|
||||||
|
if (props.unifiedType) {
|
||||||
|
return <UnifiedComponentRenderer
|
||||||
|
type={props.unifiedType}
|
||||||
|
config={props.unifiedConfig}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 없으면 기존 레거시 컴포넌트 사용
|
||||||
|
return <LegacyComponentRenderer
|
||||||
|
type={props.componentType}
|
||||||
|
config={props.componentConfig}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 컴포넌트별 매핑 규칙
|
||||||
|
|
||||||
|
### 3.1 text-input → UnifiedInput
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS
|
||||||
|
{
|
||||||
|
"componentType": "text-input",
|
||||||
|
"componentConfig": {
|
||||||
|
"type": "text-input",
|
||||||
|
"format": "none",
|
||||||
|
"webType": "text",
|
||||||
|
"multiline": false,
|
||||||
|
"placeholder": "텍스트를 입력하세요"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TO-BE
|
||||||
|
{
|
||||||
|
"unifiedType": "UnifiedInput",
|
||||||
|
"unifiedConfig": {
|
||||||
|
"type": "text", // componentConfig.webType 또는 "text"
|
||||||
|
"format": "none", // componentConfig.format
|
||||||
|
"placeholder": "..." // componentConfig.placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 number-input → UnifiedInput
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS
|
||||||
|
{
|
||||||
|
"componentType": "number-input",
|
||||||
|
"componentConfig": {
|
||||||
|
"type": "number-input",
|
||||||
|
"webType": "number",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"step": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TO-BE
|
||||||
|
{
|
||||||
|
"unifiedType": "UnifiedInput",
|
||||||
|
"unifiedConfig": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"step": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 select-basic → UnifiedSelect
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS (code 타입)
|
||||||
|
{
|
||||||
|
"componentType": "select-basic",
|
||||||
|
"codeCategory": "ORDER_STATUS",
|
||||||
|
"componentConfig": {
|
||||||
|
"type": "select-basic",
|
||||||
|
"webType": "code",
|
||||||
|
"codeCategory": "ORDER_STATUS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TO-BE
|
||||||
|
{
|
||||||
|
"unifiedType": "UnifiedSelect",
|
||||||
|
"unifiedConfig": {
|
||||||
|
"mode": "dropdown",
|
||||||
|
"source": "code",
|
||||||
|
"codeGroup": "ORDER_STATUS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS-IS (entity 타입)
|
||||||
|
{
|
||||||
|
"componentType": "select-basic",
|
||||||
|
"componentConfig": {
|
||||||
|
"type": "select-basic",
|
||||||
|
"webType": "entity",
|
||||||
|
"searchable": true,
|
||||||
|
"valueField": "id",
|
||||||
|
"displayField": "name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TO-BE
|
||||||
|
{
|
||||||
|
"unifiedType": "UnifiedSelect",
|
||||||
|
"unifiedConfig": {
|
||||||
|
"mode": "dropdown",
|
||||||
|
"source": "entity",
|
||||||
|
"searchable": true,
|
||||||
|
"valueField": "id",
|
||||||
|
"displayField": "name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 date-input → UnifiedDate
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS
|
||||||
|
{
|
||||||
|
"componentType": "date-input",
|
||||||
|
"componentConfig": {
|
||||||
|
"type": "date-input",
|
||||||
|
"webType": "date",
|
||||||
|
"format": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TO-BE
|
||||||
|
{
|
||||||
|
"unifiedType": "UnifiedDate",
|
||||||
|
"unifiedConfig": {
|
||||||
|
"type": "date",
|
||||||
|
"format": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 마이그레이션 스크립트
|
||||||
|
|
||||||
|
### 4.1 자동 마이그레이션 함수
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/migration/componentMigration.ts
|
||||||
|
|
||||||
|
interface MigrationResult {
|
||||||
|
success: boolean;
|
||||||
|
unifiedType: string;
|
||||||
|
unifiedConfig: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateToUnified(
|
||||||
|
componentType: string,
|
||||||
|
componentConfig: Record<string, any>
|
||||||
|
): MigrationResult {
|
||||||
|
|
||||||
|
switch (componentType) {
|
||||||
|
case 'text-input':
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
unifiedType: 'UnifiedInput',
|
||||||
|
unifiedConfig: {
|
||||||
|
type: componentConfig.webType || 'text',
|
||||||
|
format: componentConfig.format || 'none',
|
||||||
|
placeholder: componentConfig.placeholder
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'number-input':
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
unifiedType: 'UnifiedInput',
|
||||||
|
unifiedConfig: {
|
||||||
|
type: 'number',
|
||||||
|
min: componentConfig.min,
|
||||||
|
max: componentConfig.max,
|
||||||
|
step: componentConfig.step
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'select-basic':
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
unifiedType: 'UnifiedSelect',
|
||||||
|
unifiedConfig: {
|
||||||
|
mode: 'dropdown',
|
||||||
|
source: componentConfig.webType || 'static',
|
||||||
|
codeGroup: componentConfig.codeCategory,
|
||||||
|
searchable: componentConfig.searchable,
|
||||||
|
valueField: componentConfig.valueField,
|
||||||
|
displayField: componentConfig.displayField
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'date-input':
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
unifiedType: 'UnifiedDate',
|
||||||
|
unifiedConfig: {
|
||||||
|
type: componentConfig.webType || 'date',
|
||||||
|
format: componentConfig.format
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
unifiedType: '',
|
||||||
|
unifiedConfig: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 DB 마이그레이션 스크립트
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 마이그레이션 백업 테이블 생성
|
||||||
|
CREATE TABLE screen_layouts_backup_v2 AS
|
||||||
|
SELECT * FROM screen_layouts;
|
||||||
|
|
||||||
|
-- 마이그레이션 실행 (text-input 예시)
|
||||||
|
UPDATE screen_layouts
|
||||||
|
SET properties = properties || jsonb_build_object(
|
||||||
|
'unifiedType', 'UnifiedInput',
|
||||||
|
'unifiedConfig', jsonb_build_object(
|
||||||
|
'type', COALESCE(properties->'componentConfig'->>'webType', 'text'),
|
||||||
|
'format', COALESCE(properties->'componentConfig'->>'format', 'none'),
|
||||||
|
'placeholder', properties->'componentConfig'->>'placeholder'
|
||||||
|
),
|
||||||
|
'_migration', jsonb_build_object(
|
||||||
|
'version', '2.0',
|
||||||
|
'migratedAt', NOW(),
|
||||||
|
'originalType', 'text-input'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE properties->>'componentType' = 'text-input';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 롤백 전략
|
||||||
|
|
||||||
|
### 5.1 롤백 스크립트
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 마이그레이션 전 상태로 복원
|
||||||
|
UPDATE screen_layouts sl
|
||||||
|
SET properties = slb.properties
|
||||||
|
FROM screen_layouts_backup_v2 slb
|
||||||
|
WHERE sl.layout_id = slb.layout_id;
|
||||||
|
|
||||||
|
-- 또는 신규 필드만 제거
|
||||||
|
UPDATE screen_layouts
|
||||||
|
SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 단계적 롤백
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 특정 화면만 롤백
|
||||||
|
async function rollbackScreen(screenId: number) {
|
||||||
|
await db.query(`
|
||||||
|
UPDATE screen_layouts sl
|
||||||
|
SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration'
|
||||||
|
WHERE screen_id = $1
|
||||||
|
`, [screenId]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 마이그레이션 일정
|
||||||
|
|
||||||
|
| 단계 | 작업 | 대상 | 시점 |
|
||||||
|
|:---:|:---|:---|:---|
|
||||||
|
| 1 | 백업 테이블 생성 | 전체 | Phase 1 시작 전 |
|
||||||
|
| 2 | UnifiedInput 마이그레이션 | text-input, number-input | Phase 1 중 |
|
||||||
|
| 3 | UnifiedSelect 마이그레이션 | select-basic | Phase 1 중 |
|
||||||
|
| 4 | UnifiedDate 마이그레이션 | date-input | Phase 1 중 |
|
||||||
|
| 5 | 검증 및 테스트 | 전체 | Phase 1 완료 후 |
|
||||||
|
| 6 | 레거시 필드 제거 | 전체 | Phase 5 (추후) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 주의사항
|
||||||
|
|
||||||
|
1. **항상 백업 먼저**: 마이그레이션 전 반드시 백업 테이블 생성
|
||||||
|
2. **점진적 전환**: 한 번에 모든 컴포넌트를 마이그레이션하지 않음
|
||||||
|
3. **하위 호환성**: 기존 필드 유지로 롤백 가능하게
|
||||||
|
4. **테스트 필수**: 각 마이그레이션 단계별 화면 테스트
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Unified Components 구현 완료 보고서
|
||||||
|
|
||||||
|
## 구현 일시
|
||||||
|
|
||||||
|
2024-12-19
|
||||||
|
|
||||||
|
## 구현된 컴포넌트 목록 (10개)
|
||||||
|
|
||||||
|
### Phase 1: 핵심 입력 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
|
||||||
|
| :---------------- | :------------------ | :-------------------------------------------- | :---------------------- |
|
||||||
|
| **UnifiedInput** | `UnifiedInput.tsx` | text, number, password, slider, color, button | 통합 입력 컴포넌트 |
|
||||||
|
| **UnifiedSelect** | `UnifiedSelect.tsx` | dropdown, radio, check, tag, toggle, swap | 통합 선택 컴포넌트 |
|
||||||
|
| **UnifiedDate** | `UnifiedDate.tsx` | date, time, datetime + range | 통합 날짜/시간 컴포넌트 |
|
||||||
|
|
||||||
|
### Phase 2: 레이아웃 및 그룹 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
|
||||||
|
| :---------------- | :------------------ | :-------------------------------------------------------- | :--------------------- |
|
||||||
|
| **UnifiedList** | `UnifiedList.tsx` | table, card, kanban, list | 통합 리스트 컴포넌트 |
|
||||||
|
| **UnifiedLayout** | `UnifiedLayout.tsx` | grid, split, flex, divider, screen-embed | 통합 레이아웃 컴포넌트 |
|
||||||
|
| **UnifiedGroup** | `UnifiedGroup.tsx` | tabs, accordion, section, card-section, modal, form-modal | 통합 그룹 컴포넌트 |
|
||||||
|
|
||||||
|
### Phase 3: 미디어 및 비즈니스 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
|
||||||
|
| :------------------- | :--------------------- | :------------------------------------------------------------- | :---------------------- |
|
||||||
|
| **UnifiedMedia** | `UnifiedMedia.tsx` | file, image, video, audio | 통합 미디어 컴포넌트 |
|
||||||
|
| **UnifiedBiz** | `UnifiedBiz.tsx` | flow, rack, map, numbering, category, mapping, related-buttons | 통합 비즈니스 컴포넌트 |
|
||||||
|
| **UnifiedHierarchy** | `UnifiedHierarchy.tsx` | tree, org, bom, cascading | 통합 계층 구조 컴포넌트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공통 인프라
|
||||||
|
|
||||||
|
### 설정 패널
|
||||||
|
|
||||||
|
- **DynamicConfigPanel**: JSON Schema 기반 동적 설정 UI 생성
|
||||||
|
|
||||||
|
### 렌더러
|
||||||
|
|
||||||
|
- **UnifiedComponentRenderer**: unifiedType에 따른 동적 컴포넌트 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/components/unified/
|
||||||
|
├── index.ts # 모듈 인덱스
|
||||||
|
├── UnifiedComponentRenderer.tsx # 동적 렌더러
|
||||||
|
├── DynamicConfigPanel.tsx # JSON Schema 설정 패널
|
||||||
|
├── UnifiedInput.tsx # 통합 입력
|
||||||
|
├── UnifiedSelect.tsx # 통합 선택
|
||||||
|
├── UnifiedDate.tsx # 통합 날짜
|
||||||
|
├── UnifiedList.tsx # 통합 리스트
|
||||||
|
├── UnifiedLayout.tsx # 통합 레이아웃
|
||||||
|
├── UnifiedGroup.tsx # 통합 그룹
|
||||||
|
├── UnifiedMedia.tsx # 통합 미디어
|
||||||
|
├── UnifiedBiz.tsx # 통합 비즈니스
|
||||||
|
└── UnifiedHierarchy.tsx # 통합 계층
|
||||||
|
|
||||||
|
frontend/types/
|
||||||
|
└── unified-components.ts # 타입 정의
|
||||||
|
|
||||||
|
db/migrations/
|
||||||
|
└── unified_component_schema.sql # DB 스키마 (미실행)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 예시
|
||||||
|
|
||||||
|
### 기본 사용법
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
UnifiedInput,
|
||||||
|
UnifiedSelect,
|
||||||
|
UnifiedDate,
|
||||||
|
UnifiedList,
|
||||||
|
UnifiedComponentRenderer
|
||||||
|
} from "@/components/unified";
|
||||||
|
|
||||||
|
// UnifiedInput 사용
|
||||||
|
<UnifiedInput
|
||||||
|
id="name"
|
||||||
|
label="이름"
|
||||||
|
required
|
||||||
|
config={{ type: "text", placeholder: "이름을 입력하세요" }}
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// UnifiedSelect 사용
|
||||||
|
<UnifiedSelect
|
||||||
|
id="status"
|
||||||
|
label="상태"
|
||||||
|
config={{
|
||||||
|
mode: "dropdown",
|
||||||
|
source: "code",
|
||||||
|
codeGroup: "ORDER_STATUS",
|
||||||
|
searchable: true
|
||||||
|
}}
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// UnifiedDate 사용
|
||||||
|
<UnifiedDate
|
||||||
|
id="orderDate"
|
||||||
|
label="주문일"
|
||||||
|
config={{ type: "date", format: "YYYY-MM-DD" }}
|
||||||
|
value={orderDate}
|
||||||
|
onChange={setOrderDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// UnifiedList 사용
|
||||||
|
<UnifiedList
|
||||||
|
id="orderList"
|
||||||
|
label="주문 목록"
|
||||||
|
config={{
|
||||||
|
viewMode: "table",
|
||||||
|
searchable: true,
|
||||||
|
pageable: true,
|
||||||
|
pageSize: 10,
|
||||||
|
columns: [
|
||||||
|
{ field: "orderId", header: "주문번호", sortable: true },
|
||||||
|
{ field: "customerName", header: "고객명" },
|
||||||
|
{ field: "orderDate", header: "주문일", format: "date" },
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
data={orders}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 동적 렌더링
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { UnifiedComponentRenderer } from "@/components/unified";
|
||||||
|
|
||||||
|
// unifiedType에 따라 자동으로 적절한 컴포넌트 렌더링
|
||||||
|
<UnifiedComponentRenderer
|
||||||
|
props={{
|
||||||
|
unifiedType: "UnifiedInput",
|
||||||
|
id: "dynamicField",
|
||||||
|
label: "동적 필드",
|
||||||
|
config: { type: "text" },
|
||||||
|
value: fieldValue,
|
||||||
|
onChange: setFieldValue,
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
### 기존 컴포넌트와의 공존
|
||||||
|
|
||||||
|
1. **기존 컴포넌트는 그대로 유지**: 모든 레거시 컴포넌트는 정상 동작
|
||||||
|
2. **신규 화면에서만 Unified 컴포넌트 사용**: 기존 화면에 영향 없음
|
||||||
|
3. **마이그레이션 없음**: 자동 마이그레이션 진행하지 않음
|
||||||
|
|
||||||
|
### 데이터베이스 마이그레이션
|
||||||
|
|
||||||
|
`db/migrations/unified_component_schema.sql` 파일은 아직 실행되지 않았습니다.
|
||||||
|
필요시 수동으로 실행해야 합니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U postgres -d plm_db -f db/migrations/unified_component_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계 (선택)
|
||||||
|
|
||||||
|
1. **화면 관리 에디터 통합**: Unified 컴포넌트를 화면 에디터의 컴포넌트 팔레트에 추가
|
||||||
|
2. **기존 비즈니스 컴포넌트 연동**: UnifiedBiz의 플레이스홀더를 실제 구현으로 교체
|
||||||
|
3. **테스트 페이지 작성**: 모든 Unified 컴포넌트 데모 페이지
|
||||||
|
4. **문서화**: 각 컴포넌트별 상세 사용 가이드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- `PLAN_RENEWAL.md`: 리뉴얼 계획서
|
||||||
|
- `docs/phase0-component-usage-analysis.md`: 컴포넌트 사용 현황 분석
|
||||||
|
- `docs/phase0-migration-strategy.md`: 마이그레이션 전략 (참고용)
|
||||||
|
|
||||||
|
|
@ -586,3 +586,4 @@ const result = await executeNodeFlow(flowId, {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -359,3 +359,4 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -345,3 +345,4 @@ const getComponentValue = (componentId: string) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, TestTube2 } from "lucide-react";
|
||||||
import ScreenList from "@/components/screen/ScreenList";
|
import ScreenList from "@/components/screen/ScreenList";
|
||||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||||
import TemplateManager from "@/components/screen/TemplateManager";
|
import TemplateManager from "@/components/screen/TemplateManager";
|
||||||
|
import { UnifiedComponentsDemo } from "@/components/unified";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
// 단계별 진행을 위한 타입 정의
|
// 단계별 진행을 위한 타입 정의
|
||||||
type Step = "list" | "design" | "template";
|
type Step = "list" | "design" | "template" | "unified-test";
|
||||||
|
|
||||||
export default function ScreenManagementPage() {
|
export default function ScreenManagementPage() {
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
|
|
@ -34,6 +35,10 @@ export default function ScreenManagementPage() {
|
||||||
title: "템플릿 관리",
|
title: "템플릿 관리",
|
||||||
description: "화면 템플릿을 관리하고 재사용하세요",
|
description: "화면 템플릿을 관리하고 재사용하세요",
|
||||||
},
|
},
|
||||||
|
"unified-test": {
|
||||||
|
title: "Unified 컴포넌트 테스트",
|
||||||
|
description: "10개의 통합 컴포넌트를 테스트합니다",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다음 단계로 이동
|
// 다음 단계로 이동
|
||||||
|
|
@ -71,13 +76,32 @@ export default function ScreenManagementPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unified 컴포넌트 테스트 모드
|
||||||
|
if (currentStep === "unified-test") {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
|
<UnifiedComponentsDemo onBack={() => goToStep("list")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">화면 관리</h1>
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">화면을 설계하고 템플릿을 관리합니다</p>
|
<h1 className="text-3xl font-bold tracking-tight">화면 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">화면을 설계하고 템플릿을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => goToNextStep("unified-test")}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<TestTube2 className="h-4 w-4" />
|
||||||
|
Unified 컴포넌트 테스트
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 단계별 내용 */}
|
{/* 단계별 내용 */}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,84 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
|
||||||
|
// 조건부 표시 평가 함수
|
||||||
|
function evaluateConditional(
|
||||||
|
conditional: ComponentData["conditional"],
|
||||||
|
formData: Record<string, any>,
|
||||||
|
allComponents: ComponentData[],
|
||||||
|
): { visible: boolean; disabled: boolean } {
|
||||||
|
if (!conditional || !conditional.enabled) {
|
||||||
|
return { visible: true, disabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, operator, value, action } = conditional;
|
||||||
|
|
||||||
|
// 참조 필드의 현재 값 가져오기
|
||||||
|
// 필드 ID로 컴포넌트를 찾아 columnName 또는 id로 formData에서 값 조회
|
||||||
|
const refComponent = allComponents.find((c) => c.id === field);
|
||||||
|
const fieldName = (refComponent as any)?.columnName || field;
|
||||||
|
const fieldValue = formData[fieldName];
|
||||||
|
|
||||||
|
// 조건 평가
|
||||||
|
let conditionMet = false;
|
||||||
|
switch (operator) {
|
||||||
|
case "=":
|
||||||
|
conditionMet = fieldValue === value || String(fieldValue) === String(value);
|
||||||
|
break;
|
||||||
|
case "!=":
|
||||||
|
conditionMet = fieldValue !== value && String(fieldValue) !== String(value);
|
||||||
|
break;
|
||||||
|
case ">":
|
||||||
|
conditionMet = Number(fieldValue) > Number(value);
|
||||||
|
break;
|
||||||
|
case "<":
|
||||||
|
conditionMet = Number(fieldValue) < Number(value);
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
conditionMet = value.includes(fieldValue) || value.map(String).includes(String(fieldValue));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "notIn":
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
conditionMet = !value.includes(fieldValue) && !value.map(String).includes(String(fieldValue));
|
||||||
|
} else {
|
||||||
|
conditionMet = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "isEmpty":
|
||||||
|
conditionMet =
|
||||||
|
fieldValue === null ||
|
||||||
|
fieldValue === undefined ||
|
||||||
|
fieldValue === "" ||
|
||||||
|
(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||||
|
break;
|
||||||
|
case "isNotEmpty":
|
||||||
|
conditionMet =
|
||||||
|
fieldValue !== null &&
|
||||||
|
fieldValue !== undefined &&
|
||||||
|
fieldValue !== "" &&
|
||||||
|
!(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conditionMet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 액션에 따른 결과 반환
|
||||||
|
switch (action) {
|
||||||
|
case "show":
|
||||||
|
return { visible: conditionMet, disabled: false };
|
||||||
|
case "hide":
|
||||||
|
return { visible: !conditionMet, disabled: false };
|
||||||
|
case "enable":
|
||||||
|
return { visible: true, disabled: !conditionMet };
|
||||||
|
case "disable":
|
||||||
|
return { visible: true, disabled: conditionMet };
|
||||||
|
default:
|
||||||
|
return { visible: true, disabled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||||
import "@/lib/registry/components/ButtonRenderer";
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
import "@/lib/registry/components/CardRenderer";
|
import "@/lib/registry/components/CardRenderer";
|
||||||
|
|
@ -56,7 +134,7 @@ interface InteractiveScreenViewerProps {
|
||||||
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||||
originalData?: Record<string, any> | null;
|
originalData?: Record<string, any> | null;
|
||||||
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||||
parentTabId?: string; // 부모 탭 ID
|
parentTabId?: string; // 부모 탭 ID
|
||||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,6 +412,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
// 동적 대화형 위젯 렌더링
|
// 동적 대화형 위젯 렌더링
|
||||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||||
|
// 조건부 표시 평가
|
||||||
|
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
|
||||||
|
|
||||||
|
// 조건에 따라 숨김 처리
|
||||||
|
if (!conditionalResult.visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 테이블 컴포넌트 처리
|
// 데이터 테이블 컴포넌트 처리
|
||||||
if (isDataTableComponent(comp)) {
|
if (isDataTableComponent(comp)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -431,6 +517,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 조건부 비활성화 적용
|
||||||
|
const isConditionallyDisabled = conditionalResult.disabled;
|
||||||
|
|
||||||
// 동적 웹타입 렌더링 사용
|
// 동적 웹타입 렌더링 사용
|
||||||
if (widgetType) {
|
if (widgetType) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -444,7 +533,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
onFormDataChange: handleFormDataChange,
|
onFormDataChange: handleFormDataChange,
|
||||||
formData: formData, // 🆕 전체 formData 전달
|
formData: formData, // 🆕 전체 formData 전달
|
||||||
isInteractive: true,
|
isInteractive: true,
|
||||||
readonly: readonly,
|
readonly: readonly || isConditionallyDisabled, // 조건부 비활성화 적용
|
||||||
|
disabled: isConditionallyDisabled, // 조건부 비활성화 전달
|
||||||
required: required,
|
required: required,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
className: "w-full h-full",
|
className: "w-full h-full",
|
||||||
|
|
@ -470,7 +560,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||||
placeholder={`${widgetType} (렌더링 오류)`}
|
placeholder={`${widgetType} (렌더링 오류)`}
|
||||||
disabled={readonly}
|
disabled={readonly || isConditionallyDisabled}
|
||||||
required={required}
|
required={required}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
|
@ -486,7 +576,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||||
placeholder={placeholder || "입력하세요"}
|
placeholder={placeholder || "입력하세요"}
|
||||||
disabled={readonly}
|
disabled={readonly || isConditionallyDisabled}
|
||||||
required={required}
|
required={required}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
|
@ -593,7 +683,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const handleQuickInsertAction = async () => {
|
const handleQuickInsertAction = async () => {
|
||||||
// componentConfig에서 quickInsertConfig 가져오기
|
// componentConfig에서 quickInsertConfig 가져오기
|
||||||
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
|
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
|
||||||
|
|
||||||
if (!quickInsertConfig?.targetTable) {
|
if (!quickInsertConfig?.targetTable) {
|
||||||
toast.error("대상 테이블이 설정되지 않았습니다.");
|
toast.error("대상 테이블이 설정되지 않았습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -604,7 +694,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
try {
|
try {
|
||||||
const { default: apiClient } = await import("@/lib/api/client");
|
const { default: apiClient } = await import("@/lib/api/client");
|
||||||
const columnsResponse = await apiClient.get(
|
const columnsResponse = await apiClient.get(
|
||||||
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
|
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
|
||||||
);
|
);
|
||||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||||
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
||||||
|
|
@ -618,7 +708,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// 2. 컬럼 매핑에서 값 수집
|
// 2. 컬럼 매핑에서 값 수집
|
||||||
const insertData: Record<string, any> = {};
|
const insertData: Record<string, any> = {};
|
||||||
const columnMappings = quickInsertConfig.columnMappings || [];
|
const columnMappings = quickInsertConfig.columnMappings || [];
|
||||||
|
|
||||||
for (const mapping of columnMappings) {
|
for (const mapping of columnMappings) {
|
||||||
let value: any;
|
let value: any;
|
||||||
|
|
||||||
|
|
@ -681,31 +771,31 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
|
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
|
||||||
const leftData = splitPanelContext.selectedLeftData;
|
const leftData = splitPanelContext.selectedLeftData;
|
||||||
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(leftData)) {
|
for (const [key, val] of Object.entries(leftData)) {
|
||||||
// 이미 매핑된 컬럼은 스킵
|
// 이미 매핑된 컬럼은 스킵
|
||||||
if (insertData[key] !== undefined) {
|
if (insertData[key] !== undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
||||||
if (!targetTableColumns.includes(key)) {
|
if (!targetTableColumns.includes(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시스템 컬럼 제외
|
// 시스템 컬럼 제외
|
||||||
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
|
const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
|
||||||
if (systemColumns.includes(key)) {
|
if (systemColumns.includes(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
||||||
if (key.endsWith('_label') || key.endsWith('_name')) {
|
if (key.endsWith("_label") || key.endsWith("_name")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 값이 있으면 자동 추가
|
// 값이 있으면 자동 추가
|
||||||
if (val !== undefined && val !== null && val !== '') {
|
if (val !== undefined && val !== null && val !== "") {
|
||||||
insertData[key] = val;
|
insertData[key] = val;
|
||||||
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
||||||
}
|
}
|
||||||
|
|
@ -724,7 +814,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
||||||
try {
|
try {
|
||||||
const { default: apiClient } = await import("@/lib/api/client");
|
const { default: apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
// 중복 체크를 위한 검색 조건 구성
|
// 중복 체크를 위한 검색 조건 구성
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
||||||
|
|
@ -736,14 +826,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
console.log("📍 중복 체크 조건:", searchConditions);
|
console.log("📍 중복 체크 조건:", searchConditions);
|
||||||
|
|
||||||
// 기존 데이터 조회
|
// 기존 데이터 조회
|
||||||
const checkResponse = await apiClient.post(
|
const checkResponse = await apiClient.post(`/table-management/tables/${quickInsertConfig.targetTable}/data`, {
|
||||||
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
|
page: 1,
|
||||||
{
|
pageSize: 1,
|
||||||
page: 1,
|
search: searchConditions,
|
||||||
pageSize: 1,
|
});
|
||||||
search: searchConditions,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("📍 중복 체크 응답:", checkResponse.data);
|
console.log("📍 중복 체크 응답:", checkResponse.data);
|
||||||
|
|
||||||
|
|
@ -765,7 +852,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
||||||
insertData
|
insertData,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data?.success) {
|
if (response.data?.success) {
|
||||||
|
|
@ -1000,7 +1087,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
{popupScreen && (
|
{popupScreen && (
|
||||||
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="overflow-hidden p-0 max-w-none"
|
className="max-w-none overflow-hidden p-0"
|
||||||
style={{
|
style={{
|
||||||
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
||||||
height: "800px",
|
height: "800px",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
SCREEN_RESOLUTIONS,
|
SCREEN_RESOLUTIONS,
|
||||||
} from "@/types/screen";
|
} from "@/types/screen";
|
||||||
import { generateComponentId } from "@/lib/utils/generateId";
|
import { generateComponentId } from "@/lib/utils/generateId";
|
||||||
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
|
import { getComponentIdFromWebType, createUnifiedConfigFromColumn, getUnifiedConfigFromWebType } from "@/lib/utils/webTypeMapping";
|
||||||
import {
|
import {
|
||||||
createGroupComponent,
|
createGroupComponent,
|
||||||
calculateBoundingBox,
|
calculateBoundingBox,
|
||||||
|
|
@ -938,11 +938,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
: null;
|
: null;
|
||||||
const tableLabel = currentTable?.displayName || tableName;
|
const tableLabel = currentTable?.displayName || tableName;
|
||||||
|
|
||||||
// 현재 화면의 테이블 컬럼 정보 조회
|
// 현재 화면의 테이블 컬럼 정보 조회 (캐시 버스팅으로 최신 데이터 가져오기)
|
||||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
const columnsResponse = await tableTypeApi.getColumns(tableName, true);
|
||||||
|
|
||||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
||||||
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
|
// widgetType 결정: inputType(entity 등) > webType > widget_type
|
||||||
|
const inputType = col.inputType || col.input_type;
|
||||||
|
const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type;
|
||||||
|
|
||||||
|
// detailSettings 파싱 (문자열이면 JSON 파싱)
|
||||||
|
let detailSettings = col.detailSettings || col.detail_settings;
|
||||||
|
if (typeof detailSettings === "string") {
|
||||||
|
try {
|
||||||
|
detailSettings = JSON.parse(detailSettings);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("detailSettings 파싱 실패:", e);
|
||||||
|
detailSettings = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티 타입 디버깅
|
||||||
|
if (inputType === "entity" || widgetType === "entity") {
|
||||||
|
console.log("🔍 엔티티 컬럼 감지:", {
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
inputType,
|
||||||
|
widgetType,
|
||||||
|
detailSettings,
|
||||||
|
referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tableName: col.tableName || tableName,
|
tableName: col.tableName || tableName,
|
||||||
|
|
@ -950,7 +974,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||||
dataType: col.dataType || col.data_type || col.dbType,
|
dataType: col.dataType || col.data_type || col.dbType,
|
||||||
webType: col.webType || col.web_type,
|
webType: col.webType || col.web_type,
|
||||||
input_type: col.inputType || col.input_type,
|
input_type: inputType,
|
||||||
|
inputType: inputType,
|
||||||
widgetType,
|
widgetType,
|
||||||
isNullable: col.isNullable || col.is_nullable,
|
isNullable: col.isNullable || col.is_nullable,
|
||||||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||||
|
|
@ -958,10 +983,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||||
codeCategory: col.codeCategory || col.code_category,
|
codeCategory: col.codeCategory || col.code_category,
|
||||||
codeValue: col.codeValue || col.code_value,
|
codeValue: col.codeValue || col.code_value,
|
||||||
// 엔티티 타입용 참조 테이블 정보
|
// 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출)
|
||||||
referenceTable: col.referenceTable || col.reference_table,
|
referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table,
|
||||||
referenceColumn: col.referenceColumn || col.reference_column,
|
referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column,
|
||||||
displayColumn: col.displayColumn || col.display_column,
|
displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column,
|
||||||
|
// detailSettings 전체 보존 (Unified 컴포넌트용)
|
||||||
|
detailSettings,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2578,28 +2605,40 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const relativeX = e.clientX - containerRect.left;
|
const relativeX = e.clientX - containerRect.left;
|
||||||
const relativeY = e.clientY - containerRect.top;
|
const relativeY = e.clientY - containerRect.top;
|
||||||
|
|
||||||
// 웹타입을 새로운 컴포넌트 ID로 매핑
|
// 🆕 Unified 컴포넌트 매핑 사용
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const unifiedMapping = createUnifiedConfigFromColumn({
|
||||||
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
widgetType: column.widgetType,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.columnLabel,
|
||||||
|
codeCategory: column.codeCategory,
|
||||||
|
inputType: column.inputType,
|
||||||
|
required: column.required,
|
||||||
|
detailSettings: column.detailSettings, // 엔티티 참조 정보 전달
|
||||||
|
// column_labels 직접 필드도 전달
|
||||||
|
referenceTable: column.referenceTable,
|
||||||
|
referenceColumn: column.referenceColumn,
|
||||||
|
displayColumn: column.displayColumn,
|
||||||
|
});
|
||||||
|
|
||||||
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const componentWidth = getDefaultWidth(column.widgetType);
|
const componentWidth = getDefaultWidth(column.widgetType);
|
||||||
|
|
||||||
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
console.log("🎯 폼 컨테이너 Unified 컴포넌트 생성:", {
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
|
unifiedType: unifiedMapping.componentType,
|
||||||
componentWidth,
|
componentWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
type: "component", // ✅ Unified 컴포넌트 시스템 사용
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: column.required,
|
required: column.required,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
|
|
@ -2615,43 +2654,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
labelMarginBottom: "6px",
|
labelMarginBottom: "6px",
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
webType: column.widgetType, // 원본 웹타입 보존
|
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
|
||||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
|
||||||
...getDefaultWebTypeConfig(column.widgetType),
|
|
||||||
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
|
||||||
...(column.widgetType === "code" &&
|
|
||||||
column.codeCategory && {
|
|
||||||
codeCategory: column.codeCategory,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
|
// 일반 캔버스에 드롭한 경우 - 🆕 Unified 컴포넌트 시스템 사용
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const unifiedMapping = createUnifiedConfigFromColumn({
|
||||||
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
widgetType: column.widgetType,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.columnLabel,
|
||||||
|
codeCategory: column.codeCategory,
|
||||||
|
inputType: column.inputType,
|
||||||
|
required: column.required,
|
||||||
|
detailSettings: column.detailSettings, // 엔티티 참조 정보 전달
|
||||||
|
// column_labels 직접 필드도 전달
|
||||||
|
referenceTable: column.referenceTable,
|
||||||
|
referenceColumn: column.referenceColumn,
|
||||||
|
displayColumn: column.displayColumn,
|
||||||
|
});
|
||||||
|
|
||||||
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const componentWidth = getDefaultWidth(column.widgetType);
|
const componentWidth = getDefaultWidth(column.widgetType);
|
||||||
|
|
||||||
console.log("🎯 캔버스 컴포넌트 생성:", {
|
console.log("🎯 캔버스 Unified 컴포넌트 생성:", {
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
|
unifiedType: unifiedMapping.componentType,
|
||||||
componentWidth,
|
componentWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
type: "component", // ✅ Unified 컴포넌트 시스템 사용
|
||||||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: column.required,
|
required: column.required,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
|
|
@ -2667,16 +2710,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
labelMarginBottom: "8px",
|
labelMarginBottom: "8px",
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
webType: column.widgetType, // 원본 웹타입 보존
|
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
|
||||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
|
||||||
...getDefaultWebTypeConfig(column.widgetType),
|
|
||||||
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
|
||||||
...(column.widgetType === "code" &&
|
|
||||||
column.codeCategory && {
|
|
||||||
codeCategory: column.codeCategory,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react";
|
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench, Sparkles } from "lucide-react";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
import TablesPanel from "./TablesPanel";
|
import TablesPanel from "./TablesPanel";
|
||||||
|
|
||||||
|
|
@ -52,6 +52,82 @@ export function ComponentsPanel({
|
||||||
return components;
|
return components;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Unified 컴포넌트 정의 (새로운 통합 컴포넌트 시스템)
|
||||||
|
const unifiedComponents: ComponentDefinition[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: "unified-input",
|
||||||
|
name: "통합 입력",
|
||||||
|
description: "텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 다양한 입력 타입 지원",
|
||||||
|
category: "input" as ComponentCategory,
|
||||||
|
tags: ["input", "text", "number", "unified"],
|
||||||
|
defaultSize: { width: 200, height: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-select",
|
||||||
|
name: "통합 선택",
|
||||||
|
description: "드롭다운, 라디오, 체크박스, 태그, 토글 등 다양한 선택 타입 지원",
|
||||||
|
category: "input" as ComponentCategory,
|
||||||
|
tags: ["select", "dropdown", "radio", "checkbox", "unified"],
|
||||||
|
defaultSize: { width: 200, height: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-date",
|
||||||
|
name: "통합 날짜",
|
||||||
|
description: "날짜, 시간, 날짜시간, 날짜 범위 등 다양한 날짜 타입 지원",
|
||||||
|
category: "input" as ComponentCategory,
|
||||||
|
tags: ["date", "time", "datetime", "unified"],
|
||||||
|
defaultSize: { width: 200, height: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-list",
|
||||||
|
name: "통합 목록",
|
||||||
|
description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식 지원",
|
||||||
|
category: "display" as ComponentCategory,
|
||||||
|
tags: ["table", "list", "card", "kanban", "unified"],
|
||||||
|
defaultSize: { width: 600, height: 400 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-layout",
|
||||||
|
name: "통합 레이아웃",
|
||||||
|
description: "그리드, 분할 패널, 플렉스 등 다양한 레이아웃 지원",
|
||||||
|
category: "layout" as ComponentCategory,
|
||||||
|
tags: ["grid", "split", "flex", "unified"],
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-group",
|
||||||
|
name: "통합 그룹",
|
||||||
|
description: "탭, 아코디언, 섹션, 카드섹션, 모달 등 다양한 그룹핑 지원",
|
||||||
|
category: "layout" as ComponentCategory,
|
||||||
|
tags: ["tabs", "accordion", "section", "modal", "unified"],
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-media",
|
||||||
|
name: "통합 미디어",
|
||||||
|
description: "이미지, 비디오, 오디오, 파일 업로드 등 미디어 컴포넌트",
|
||||||
|
category: "display" as ComponentCategory,
|
||||||
|
tags: ["image", "video", "audio", "file", "unified"],
|
||||||
|
defaultSize: { width: 300, height: 200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-biz",
|
||||||
|
name: "통합 비즈니스",
|
||||||
|
description: "플로우 다이어그램, 랙 구조, 채번규칙 등 비즈니스 컴포넌트",
|
||||||
|
category: "utility" as ComponentCategory,
|
||||||
|
tags: ["flow", "rack", "numbering", "unified"],
|
||||||
|
defaultSize: { width: 600, height: 400 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-hierarchy",
|
||||||
|
name: "통합 계층",
|
||||||
|
description: "트리, 조직도, BOM, 연쇄 선택박스 등 계층 구조 컴포넌트",
|
||||||
|
category: "data" as ComponentCategory,
|
||||||
|
tags: ["tree", "org", "bom", "cascading", "unified"],
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
// 카테고리별 컴포넌트 그룹화
|
// 카테고리별 컴포넌트 그룹화
|
||||||
const componentsByCategory = useMemo(() => {
|
const componentsByCategory = useMemo(() => {
|
||||||
// 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들)
|
// 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들)
|
||||||
|
|
@ -66,8 +142,9 @@ export function ComponentsPanel({
|
||||||
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
|
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
|
||||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||||
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
|
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
|
||||||
|
unified: unifiedComponents, // 🆕 Unified 컴포넌트 카테고리 추가
|
||||||
};
|
};
|
||||||
}, [allComponents]);
|
}, [allComponents, unifiedComponents]);
|
||||||
|
|
||||||
// 카테고리별 검색 필터링
|
// 카테고리별 검색 필터링
|
||||||
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
|
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
|
||||||
|
|
@ -187,19 +264,28 @@ export function ComponentsPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카테고리 탭 */}
|
{/* 카테고리 탭 */}
|
||||||
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
<Tabs defaultValue="unified" className="flex min-h-0 flex-1 flex-col">
|
||||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
|
<TabsList className="mb-3 grid h-16 w-full flex-shrink-0 grid-cols-4 grid-rows-2 gap-1 p-1">
|
||||||
|
{/* 1행: Unified, 테이블, 입력, 데이터 */}
|
||||||
|
<TabsTrigger
|
||||||
|
value="unified"
|
||||||
|
className="flex items-center justify-center gap-0.5 px-0 text-[10px] bg-primary/10 data-[state=active]:bg-primary data-[state=active]:text-white"
|
||||||
|
title="Unified 컴포넌트"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
<span className="text-[9px]">Unified</span>
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="tables"
|
value="tables"
|
||||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||||
title="테이블"
|
title="테이블"
|
||||||
>
|
>
|
||||||
<Database className="h-3 w-3" />
|
<Database className="h-3 w-3" />
|
||||||
<span className="hidden">테이블</span>
|
<span className="text-[9px]">테이블</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="input" className="flex items-center justify-center gap-0.5 px-0 text-[10px]" title="입력">
|
<TabsTrigger value="input" className="flex items-center justify-center gap-0.5 px-0 text-[10px]" title="입력">
|
||||||
<Edit3 className="h-3 w-3" />
|
<Edit3 className="h-3 w-3" />
|
||||||
<span className="hidden">입력</span>
|
<span className="text-[9px]">입력</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="data"
|
value="data"
|
||||||
|
|
@ -207,15 +293,16 @@ export function ComponentsPanel({
|
||||||
title="데이터"
|
title="데이터"
|
||||||
>
|
>
|
||||||
<Grid className="h-3 w-3" />
|
<Grid className="h-3 w-3" />
|
||||||
<span className="hidden">데이터</span>
|
<span className="text-[9px]">데이터</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{/* 2행: 액션, 표시, 레이아웃, 유틸리티 */}
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="action"
|
value="action"
|
||||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||||
title="액션"
|
title="액션"
|
||||||
>
|
>
|
||||||
<Zap className="h-3 w-3" />
|
<Zap className="h-3 w-3" />
|
||||||
<span className="hidden">액션</span>
|
<span className="text-[9px]">액션</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="display"
|
value="display"
|
||||||
|
|
@ -223,7 +310,7 @@ export function ComponentsPanel({
|
||||||
title="표시"
|
title="표시"
|
||||||
>
|
>
|
||||||
<BarChart3 className="h-3 w-3" />
|
<BarChart3 className="h-3 w-3" />
|
||||||
<span className="hidden">표시</span>
|
<span className="text-[9px]">표시</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="layout"
|
value="layout"
|
||||||
|
|
@ -231,7 +318,7 @@ export function ComponentsPanel({
|
||||||
title="레이아웃"
|
title="레이아웃"
|
||||||
>
|
>
|
||||||
<Layers className="h-3 w-3" />
|
<Layers className="h-3 w-3" />
|
||||||
<span className="hidden">레이아웃</span>
|
<span className="text-[9px]">레이아웃</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="utility"
|
value="utility"
|
||||||
|
|
@ -239,10 +326,22 @@ export function ComponentsPanel({
|
||||||
title="유틸리티"
|
title="유틸리티"
|
||||||
>
|
>
|
||||||
<Wrench className="h-3 w-3" />
|
<Wrench className="h-3 w-3" />
|
||||||
<span className="hidden">유틸리티</span>
|
<span className="text-[9px]">유틸</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Unified 컴포넌트 탭 */}
|
||||||
|
<TabsContent value="unified" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||||
|
<div className="mb-2 rounded-md bg-primary/5 border border-primary/20 p-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||||
|
<span className="font-semibold text-primary">Unified 컴포넌트</span>는 속성 기반으로 다양한 기능을 지원하는 새로운 컴포넌트입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{getFilteredComponents("unified").length > 0
|
||||||
|
? getFilteredComponents("unified").map(renderComponentCard)
|
||||||
|
: renderEmptyState()}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* 테이블 탭 */}
|
{/* 테이블 탭 */}
|
||||||
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
|
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
|
||||||
{tables.length > 0 && onTableDragStart ? (
|
{tables.length > 0 && onTableDragStart ? (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Settings, Database } from "lucide-react";
|
import { Settings, Database, Zap } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -22,6 +22,8 @@ import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
|
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
|
||||||
|
import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel";
|
||||||
|
import { ConditionalConfig } from "@/types/unified-components";
|
||||||
|
|
||||||
// 새로운 컴포넌트 설정 패널들 import
|
// 새로운 컴포넌트 설정 패널들 import
|
||||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||||
|
|
@ -1192,7 +1194,28 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */}
|
{/* 조건부 표시 설정 (component 타입용) */}
|
||||||
|
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||||
|
<ConditionalConfigPanel
|
||||||
|
config={selectedComponent.conditional as ConditionalConfig | undefined}
|
||||||
|
onChange={(newConfig) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "conditional", newConfig);
|
||||||
|
}}
|
||||||
|
availableFields={components
|
||||||
|
.filter((c) => c.id !== selectedComponent.id && (c.type === "widget" || c.type === "component"))
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: (c as any).label || (c as any).columnName || c.id,
|
||||||
|
type: (c as any).widgetType || (c as any).componentConfig?.webType,
|
||||||
|
options: (c as any).webTypeConfig?.options || [],
|
||||||
|
}))}
|
||||||
|
currentComponentId={selectedComponent.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 테이블 데이터 자동 입력 섹션 (component 타입용) */}
|
||||||
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||||
<h4 className="flex items-center gap-2 text-sm font-medium">
|
<h4 className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
|
|
@ -1400,9 +1423,29 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
{/* 상세 설정 영역 */}
|
{/* 상세 설정 영역 */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
|
||||||
<div className="space-y-6 w-full min-w-0">
|
<div className="space-y-6 w-full min-w-0">
|
||||||
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
|
{/* 조건부 표시 설정 */}
|
||||||
{/* 🆕 자동 입력 섹션 */}
|
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||||
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
|
<ConditionalConfigPanel
|
||||||
|
config={widget.conditional as ConditionalConfig | undefined}
|
||||||
|
onChange={(newConfig) => {
|
||||||
|
onUpdateProperty(widget.id, "conditional", newConfig);
|
||||||
|
}}
|
||||||
|
availableFields={components
|
||||||
|
.filter((c) => c.id !== widget.id && (c.type === "widget" || c.type === "component"))
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: (c as any).label || (c as any).columnName || c.id,
|
||||||
|
type: (c as any).widgetType || (c as any).componentConfig?.webType,
|
||||||
|
options: (c as any).webTypeConfig?.options || [],
|
||||||
|
}))}
|
||||||
|
currentComponentId={widget.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 자동 입력 섹션 */}
|
||||||
|
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
🔥 테이블 데이터 자동 입력 (테스트)
|
🔥 테이블 데이터 자동 입력 (테스트)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ import StyleEditor from "../StyleEditor";
|
||||||
import ResolutionPanel from "./ResolutionPanel";
|
import ResolutionPanel from "./ResolutionPanel";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
|
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
|
||||||
|
import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel";
|
||||||
|
import { ConditionalConfig } from "@/types/unified-components";
|
||||||
|
|
||||||
interface UnifiedPropertiesPanelProps {
|
interface UnifiedPropertiesPanelProps {
|
||||||
selectedComponent?: ComponentData;
|
selectedComponent?: ComponentData;
|
||||||
|
|
@ -313,6 +315,51 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
selectedComponent.componentConfig?.id ||
|
selectedComponent.componentConfig?.id ||
|
||||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||||
|
|
||||||
|
// 🆕 Unified 컴포넌트 직접 감지 및 설정 패널 렌더링
|
||||||
|
if (componentId?.startsWith("unified-")) {
|
||||||
|
const unifiedConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
|
||||||
|
"unified-input": require("@/components/unified/config-panels/UnifiedInputConfigPanel").UnifiedInputConfigPanel,
|
||||||
|
"unified-select": require("@/components/unified/config-panels/UnifiedSelectConfigPanel").UnifiedSelectConfigPanel,
|
||||||
|
"unified-date": require("@/components/unified/config-panels/UnifiedDateConfigPanel").UnifiedDateConfigPanel,
|
||||||
|
"unified-list": require("@/components/unified/config-panels/UnifiedListConfigPanel").UnifiedListConfigPanel,
|
||||||
|
"unified-layout": require("@/components/unified/config-panels/UnifiedLayoutConfigPanel").UnifiedLayoutConfigPanel,
|
||||||
|
"unified-group": require("@/components/unified/config-panels/UnifiedGroupConfigPanel").UnifiedGroupConfigPanel,
|
||||||
|
"unified-media": require("@/components/unified/config-panels/UnifiedMediaConfigPanel").UnifiedMediaConfigPanel,
|
||||||
|
"unified-biz": require("@/components/unified/config-panels/UnifiedBizConfigPanel").UnifiedBizConfigPanel,
|
||||||
|
"unified-hierarchy": require("@/components/unified/config-panels/UnifiedHierarchyConfigPanel").UnifiedHierarchyConfigPanel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UnifiedConfigPanel = unifiedConfigPanels[componentId];
|
||||||
|
if (UnifiedConfigPanel) {
|
||||||
|
const currentConfig = selectedComponent.componentConfig || {};
|
||||||
|
const handleUnifiedConfigChange = (newConfig: any) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
||||||
|
};
|
||||||
|
|
||||||
|
const unifiedNames: Record<string, string> = {
|
||||||
|
"unified-input": "통합 입력",
|
||||||
|
"unified-select": "통합 선택",
|
||||||
|
"unified-date": "통합 날짜",
|
||||||
|
"unified-list": "통합 목록",
|
||||||
|
"unified-layout": "통합 레이아웃",
|
||||||
|
"unified-group": "통합 그룹",
|
||||||
|
"unified-media": "통합 미디어",
|
||||||
|
"unified-biz": "통합 비즈니스",
|
||||||
|
"unified-hierarchy": "통합 계층",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={selectedComponent.id} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
<Settings className="text-primary h-4 w-4" />
|
||||||
|
<h3 className="text-sm font-semibold">{unifiedNames[componentId] || componentId} 설정</h3>
|
||||||
|
</div>
|
||||||
|
<UnifiedConfigPanel config={currentConfig} onChange={handleUnifiedConfigChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
const definition = ComponentRegistry.getComponent(componentId);
|
const definition = ComponentRegistry.getComponent(componentId);
|
||||||
|
|
||||||
|
|
@ -989,6 +1036,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 3.5. Unified 컴포넌트 - 반드시 다른 체크보다 먼저 처리
|
||||||
|
const unifiedComponentType =
|
||||||
|
(selectedComponent as any).componentType ||
|
||||||
|
selectedComponent.componentConfig?.type ||
|
||||||
|
"";
|
||||||
|
if (unifiedComponentType.startsWith("unified-")) {
|
||||||
|
const configPanel = renderComponentConfigPanel();
|
||||||
|
if (configPanel) {
|
||||||
|
return <div className="space-y-4">{configPanel}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 새로운 컴포넌트 시스템 (button, card 등)
|
// 4. 새로운 컴포넌트 시스템 (button, card 등)
|
||||||
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
||||||
const hasNewConfigPanel =
|
const hasNewConfigPanel =
|
||||||
|
|
@ -1468,6 +1527,36 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
{renderDetailTab()}
|
{renderDetailTab()}
|
||||||
|
|
||||||
|
{/* 조건부 표시 설정 */}
|
||||||
|
{selectedComponent && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Zap className="text-primary h-3 w-3" />
|
||||||
|
<h4 className="text-xs font-semibold">조건부 표시</h4>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-gray-200 p-2">
|
||||||
|
<ConditionalConfigPanel
|
||||||
|
config={(selectedComponent as any).conditional || { enabled: false, field: "", operator: "=", value: "", action: "show" }}
|
||||||
|
onChange={(newConfig: ConditionalConfig) => {
|
||||||
|
handleUpdate("conditional", newConfig);
|
||||||
|
}}
|
||||||
|
availableFields={
|
||||||
|
allComponents
|
||||||
|
?.filter((c) => c.type === "widget" && c.id !== selectedComponent.id)
|
||||||
|
.map((c) => ({
|
||||||
|
id: (c as any).columnName || c.id,
|
||||||
|
label: (c as any).label || c.id,
|
||||||
|
type: (c as any).widgetType || "text",
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 스타일 설정 */}
|
{/* 스타일 설정 */}
|
||||||
{selectedComponent && (
|
{selectedComponent && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConditionalConfigPanel
|
||||||
|
*
|
||||||
|
* 비개발자도 쉽게 조건부 표시/숨김/활성화/비활성화를 설정할 수 있는 UI
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - 화면관리 > 상세설정 패널
|
||||||
|
* - 화면관리 > 속성 패널
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Zap, Plus, Trash2, HelpCircle } from "lucide-react";
|
||||||
|
import { ConditionalConfig } from "@/types/unified-components";
|
||||||
|
|
||||||
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
interface FieldOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type?: string; // text, number, select, checkbox 등
|
||||||
|
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConditionalConfigPanelProps {
|
||||||
|
/** 현재 조건부 설정 */
|
||||||
|
config?: ConditionalConfig;
|
||||||
|
/** 설정 변경 콜백 */
|
||||||
|
onChange: (config: ConditionalConfig | undefined) => void;
|
||||||
|
/** 같은 화면에 있는 다른 필드들 (조건 필드로 선택 가능) */
|
||||||
|
availableFields: FieldOption[];
|
||||||
|
/** 현재 컴포넌트 ID (자기 자신은 조건 필드에서 제외) */
|
||||||
|
currentComponentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연산자 옵션
|
||||||
|
const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string; description: string }> = [
|
||||||
|
{ value: "=", label: "같음", description: "값이 정확히 일치할 때" },
|
||||||
|
{ value: "!=", label: "다름", description: "값이 일치하지 않을 때" },
|
||||||
|
{ value: ">", label: "보다 큼", description: "값이 더 클 때 (숫자)" },
|
||||||
|
{ value: "<", label: "보다 작음", description: "값이 더 작을 때 (숫자)" },
|
||||||
|
{ value: "in", label: "포함됨", description: "여러 값 중 하나일 때" },
|
||||||
|
{ value: "notIn", label: "포함 안됨", description: "여러 값 중 아무것도 아닐 때" },
|
||||||
|
{ value: "isEmpty", label: "비어있음", description: "값이 없을 때" },
|
||||||
|
{ value: "isNotEmpty", label: "값이 있음", description: "값이 있을 때" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 동작 옵션
|
||||||
|
const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string; description: string }> = [
|
||||||
|
{ value: "show", label: "표시", description: "조건 만족 시 이 필드를 표시" },
|
||||||
|
{ value: "hide", label: "숨김", description: "조건 만족 시 이 필드를 숨김" },
|
||||||
|
{ value: "enable", label: "활성화", description: "조건 만족 시 이 필드를 활성화" },
|
||||||
|
{ value: "disable", label: "비활성화", description: "조건 만족 시 이 필드를 비활성화" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 컴포넌트 =====
|
||||||
|
|
||||||
|
export function ConditionalConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
availableFields,
|
||||||
|
currentComponentId,
|
||||||
|
}: ConditionalConfigPanelProps) {
|
||||||
|
// 로컬 상태
|
||||||
|
const [enabled, setEnabled] = useState(config?.enabled ?? false);
|
||||||
|
const [field, setField] = useState(config?.field ?? "");
|
||||||
|
const [operator, setOperator] = useState<ConditionalConfig["operator"]>(config?.operator ?? "=");
|
||||||
|
const [value, setValue] = useState<string>(String(config?.value ?? ""));
|
||||||
|
const [action, setAction] = useState<ConditionalConfig["action"]>(config?.action ?? "show");
|
||||||
|
|
||||||
|
// 자기 자신을 제외한 필드 목록
|
||||||
|
const selectableFields = useMemo(() => {
|
||||||
|
return availableFields.filter((f) => f.id !== currentComponentId);
|
||||||
|
}, [availableFields, currentComponentId]);
|
||||||
|
|
||||||
|
// 선택된 필드 정보
|
||||||
|
const selectedField = useMemo(() => {
|
||||||
|
return selectableFields.find((f) => f.id === field);
|
||||||
|
}, [selectableFields, field]);
|
||||||
|
|
||||||
|
// config prop 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabled(config?.enabled ?? false);
|
||||||
|
setField(config?.field ?? "");
|
||||||
|
setOperator(config?.operator ?? "=");
|
||||||
|
setValue(String(config?.value ?? ""));
|
||||||
|
setAction(config?.action ?? "show");
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 설정 변경 시 부모에게 알림
|
||||||
|
const updateConfig = (updates: Partial<ConditionalConfig>) => {
|
||||||
|
const newConfig: ConditionalConfig = {
|
||||||
|
enabled: updates.enabled ?? enabled,
|
||||||
|
field: updates.field ?? field,
|
||||||
|
operator: updates.operator ?? operator,
|
||||||
|
value: updates.value ?? value,
|
||||||
|
action: updates.action ?? action,
|
||||||
|
};
|
||||||
|
|
||||||
|
// enabled가 false이면 undefined 반환 (설정 제거)
|
||||||
|
if (!newConfig.enabled) {
|
||||||
|
onChange(undefined);
|
||||||
|
} else {
|
||||||
|
onChange(newConfig);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 활성화 토글
|
||||||
|
const handleEnabledChange = (checked: boolean) => {
|
||||||
|
setEnabled(checked);
|
||||||
|
updateConfig({ enabled: checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 필드 변경
|
||||||
|
const handleFieldChange = (newField: string) => {
|
||||||
|
setField(newField);
|
||||||
|
setValue(""); // 필드 변경 시 값 초기화
|
||||||
|
updateConfig({ field: newField, value: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연산자 변경
|
||||||
|
const handleOperatorChange = (newOperator: ConditionalConfig["operator"]) => {
|
||||||
|
setOperator(newOperator);
|
||||||
|
// 비어있음/값이있음 연산자는 value 필요 없음
|
||||||
|
if (newOperator === "isEmpty" || newOperator === "isNotEmpty") {
|
||||||
|
setValue("");
|
||||||
|
updateConfig({ operator: newOperator, value: "" });
|
||||||
|
} else {
|
||||||
|
updateConfig({ operator: newOperator });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 변경
|
||||||
|
const handleValueChange = (newValue: string) => {
|
||||||
|
setValue(newValue);
|
||||||
|
|
||||||
|
// 타입에 따라 적절한 값으로 변환
|
||||||
|
let parsedValue: unknown = newValue;
|
||||||
|
if (selectedField?.type === "number") {
|
||||||
|
parsedValue = Number(newValue);
|
||||||
|
} else if (newValue === "true") {
|
||||||
|
parsedValue = true;
|
||||||
|
} else if (newValue === "false") {
|
||||||
|
parsedValue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig({ value: parsedValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 동작 변경
|
||||||
|
const handleActionChange = (newAction: ConditionalConfig["action"]) => {
|
||||||
|
setAction(newAction);
|
||||||
|
updateConfig({ action: newAction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 입력 필드 렌더링 (필드 타입에 따라 다르게)
|
||||||
|
const renderValueInput = () => {
|
||||||
|
// 비어있음/값이있음은 값 입력 불필요
|
||||||
|
if (operator === "isEmpty" || operator === "isNotEmpty") {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-muted-foreground italic">
|
||||||
|
(값 입력 불필요)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 필드에 옵션이 있으면 Select로 표시
|
||||||
|
if (selectedField?.options && selectedField.options.length > 0) {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={handleValueChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectedField.options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 체크박스 타입이면 true/false Select
|
||||||
|
if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={handleValueChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true" className="text-xs">체크됨</SelectItem>
|
||||||
|
<SelectItem value="false" className="text-xs">체크 안됨</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 타입
|
||||||
|
if (selectedField?.type === "number") {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleValueChange(e.target.value)}
|
||||||
|
placeholder="숫자 입력"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본: 텍스트 입력
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleValueChange(e.target.value)}
|
||||||
|
placeholder="값 입력"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-medium">조건부 표시</span>
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground cursor-help"
|
||||||
|
title="다른 필드의 값에 따라 이 필드를 표시/숨김/활성화/비활성화할 수 있습니다."
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={handleEnabledChange}
|
||||||
|
aria-label="조건부 표시 활성화"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 설정 영역 */}
|
||||||
|
{enabled && (
|
||||||
|
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||||
|
{/* 조건 필드 선택 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium">조건 필드</Label>
|
||||||
|
<Select value={field} onValueChange={handleFieldChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectableFields.length === 0 ? (
|
||||||
|
<div className="p-2 text-xs text-muted-foreground">
|
||||||
|
선택 가능한 필드가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
selectableFields.map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id} className="text-xs">
|
||||||
|
{f.label || f.id}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
이 필드의 값에 따라 조건이 적용됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연산자 선택 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium">조건</Label>
|
||||||
|
<Select value={operator} onValueChange={handleOperatorChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{op.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 입력 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium">값</Label>
|
||||||
|
{renderValueInput()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 동작 선택 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium">동작</Label>
|
||||||
|
<Select value={action} onValueChange={handleActionChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACTIONS.map((act) => (
|
||||||
|
<SelectItem key={act.value} value={act.value} className="text-xs">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{act.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
조건이 만족되면 이 필드를 {ACTIONS.find(a => a.value === action)?.label}합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 */}
|
||||||
|
{field && (
|
||||||
|
<div className="mt-3 rounded bg-slate-100 p-2">
|
||||||
|
<p className="text-[10px] font-medium text-slate-600">설정 요약:</p>
|
||||||
|
<p className="text-[11px] text-slate-800">
|
||||||
|
"{selectableFields.find(f => f.id === field)?.label || field}" 필드가{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{operator === "isEmpty" ? "비어있으면" :
|
||||||
|
operator === "isNotEmpty" ? "값이 있으면" :
|
||||||
|
`"${value}"${operator === "=" ? "이면" :
|
||||||
|
operator === "!=" ? "이 아니면" :
|
||||||
|
operator === ">" ? "보다 크면" :
|
||||||
|
operator === "<" ? "보다 작으면" :
|
||||||
|
operator === "in" ? "에 포함되면" : "에 포함되지 않으면"}`}
|
||||||
|
</span>{" "}
|
||||||
|
→ 이 필드를{" "}
|
||||||
|
<span className="font-medium text-orange-600">
|
||||||
|
{action === "show" ? "표시" :
|
||||||
|
action === "hide" ? "숨김" :
|
||||||
|
action === "enable" ? "활성화" : "비활성화"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConditionalConfigPanel;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,372 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicConfigPanel
|
||||||
|
*
|
||||||
|
* JSON Schema 기반으로 동적으로 설정 UI를 생성하는 패널
|
||||||
|
* 모든 Unified 컴포넌트의 설정을 단일 컴포넌트로 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
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 { Slider } from "@/components/ui/slider";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { JSONSchemaProperty, UnifiedConfigSchema } from "@/types/unified-components";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DynamicConfigPanelProps {
|
||||||
|
schema: UnifiedConfigSchema;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (key: string, value: unknown) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 스키마 속성을 렌더링하는 컴포넌트
|
||||||
|
*/
|
||||||
|
function SchemaField({
|
||||||
|
name,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
path = [],
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
property: JSONSchemaProperty;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (key: string, value: unknown) => void;
|
||||||
|
path?: string[];
|
||||||
|
}) {
|
||||||
|
const fieldPath = [...path, name].join(".");
|
||||||
|
|
||||||
|
// 값 변경 핸들러
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(newValue: unknown) => {
|
||||||
|
onChange(fieldPath, newValue);
|
||||||
|
},
|
||||||
|
[fieldPath, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 타입에 따른 컴포넌트 렌더링
|
||||||
|
const renderField = () => {
|
||||||
|
// enum이 있으면 Select 렌더링
|
||||||
|
if (property.enum && property.enum.length > 0) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={String(value ?? property.default ?? "")}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{property.enum.map((option) => (
|
||||||
|
<SelectItem key={option} value={option} className="text-xs">
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입별 렌더링
|
||||||
|
switch (property.type) {
|
||||||
|
case "string":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={String(value ?? property.default ?? "")}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={property.description}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value !== undefined && value !== null ? Number(value) : ""}
|
||||||
|
onChange={(e) => handleChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder={property.description}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(value ?? property.default ?? false)}
|
||||||
|
onCheckedChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "array":
|
||||||
|
// 배열은 간단한 텍스트 입력으로 처리 (쉼표 구분)
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={Array.isArray(value) ? value.join(", ") : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const arr = e.target.value.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
handleChange(arr);
|
||||||
|
}}
|
||||||
|
placeholder="쉼표로 구분하여 입력"
|
||||||
|
className="text-xs min-h-[60px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "object":
|
||||||
|
// 중첩 객체는 별도 섹션으로 렌더링
|
||||||
|
if (property.properties) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 pl-4 border-l-2 border-muted space-y-3">
|
||||||
|
{Object.entries(property.properties).map(([subName, subProp]) => (
|
||||||
|
<SchemaField
|
||||||
|
key={subName}
|
||||||
|
name={subName}
|
||||||
|
property={subProp}
|
||||||
|
value={(value as Record<string, unknown>)?.[subName]}
|
||||||
|
onChange={onChange}
|
||||||
|
path={[...path, name]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">
|
||||||
|
{property.title || name}
|
||||||
|
</Label>
|
||||||
|
{property.type === "boolean" && renderField()}
|
||||||
|
</div>
|
||||||
|
{property.description && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">{property.description}</p>
|
||||||
|
)}
|
||||||
|
{property.type !== "boolean" && renderField()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 DynamicConfigPanel 컴포넌트
|
||||||
|
*/
|
||||||
|
export function DynamicConfigPanel({
|
||||||
|
schema,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: DynamicConfigPanelProps) {
|
||||||
|
// 속성들을 카테고리별로 그룹화
|
||||||
|
const groupedProperties = useMemo(() => {
|
||||||
|
const groups: Record<string, Array<[string, JSONSchemaProperty]>> = {
|
||||||
|
기본: [],
|
||||||
|
고급: [],
|
||||||
|
스타일: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(schema.properties).forEach(([name, property]) => {
|
||||||
|
// 이름 기반으로 그룹 분류
|
||||||
|
if (name.includes("style") || name.includes("Style")) {
|
||||||
|
groups["스타일"].push([name, property]);
|
||||||
|
} else if (
|
||||||
|
name.includes("cascade") ||
|
||||||
|
name.includes("mutual") ||
|
||||||
|
name.includes("conditional") ||
|
||||||
|
name.includes("autoFill")
|
||||||
|
) {
|
||||||
|
groups["고급"].push([name, property]);
|
||||||
|
} else {
|
||||||
|
groups["기본"].push([name, property]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [schema.properties]);
|
||||||
|
|
||||||
|
// 값 변경 핸들러 (중첩 경로 지원)
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(path: string, value: unknown) => {
|
||||||
|
onChange(path, value);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
{Object.entries(groupedProperties).map(
|
||||||
|
([groupName, properties]) =>
|
||||||
|
properties.length > 0 && (
|
||||||
|
<Collapsible key={groupName} defaultOpen={groupName === "기본"}>
|
||||||
|
<Card>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<CardHeader className="cursor-pointer py-3 px-4">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||||
|
{groupName} 설정
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0 space-y-4">
|
||||||
|
{properties.map(([name, property]) => (
|
||||||
|
<SchemaField
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
property={property}
|
||||||
|
value={config[name]}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 스키마들 (자주 사용되는 설정)
|
||||||
|
*/
|
||||||
|
export const COMMON_SCHEMAS = {
|
||||||
|
// UnifiedInput 기본 스키마
|
||||||
|
UnifiedInput: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: "string" as const,
|
||||||
|
enum: ["text", "number", "password", "slider", "color", "button"],
|
||||||
|
default: "text",
|
||||||
|
title: "입력 타입",
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: "string" as const,
|
||||||
|
enum: ["none", "email", "tel", "url", "currency", "biz_no"],
|
||||||
|
default: "none",
|
||||||
|
title: "형식",
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: "string" as const,
|
||||||
|
title: "플레이스홀더",
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
type: "number" as const,
|
||||||
|
title: "최소값",
|
||||||
|
description: "숫자 타입 전용",
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: "number" as const,
|
||||||
|
title: "최대값",
|
||||||
|
description: "숫자 타입 전용",
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
type: "number" as const,
|
||||||
|
title: "증가 단위",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// UnifiedSelect 기본 스키마
|
||||||
|
UnifiedSelect: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: "string" as const,
|
||||||
|
enum: ["dropdown", "radio", "check", "tag", "toggle", "swap"],
|
||||||
|
default: "dropdown",
|
||||||
|
title: "표시 모드",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: "string" as const,
|
||||||
|
enum: ["static", "code", "db", "api", "entity"],
|
||||||
|
default: "static",
|
||||||
|
title: "데이터 소스",
|
||||||
|
},
|
||||||
|
codeGroup: {
|
||||||
|
type: "string" as const,
|
||||||
|
title: "코드 그룹",
|
||||||
|
description: "source가 code일 때 사용",
|
||||||
|
},
|
||||||
|
searchable: {
|
||||||
|
type: "boolean" as const,
|
||||||
|
default: false,
|
||||||
|
title: "검색 가능",
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: "boolean" as const,
|
||||||
|
default: false,
|
||||||
|
title: "다중 선택",
|
||||||
|
},
|
||||||
|
maxSelect: {
|
||||||
|
type: "number" as const,
|
||||||
|
title: "최대 선택 수",
|
||||||
|
},
|
||||||
|
cascading: {
|
||||||
|
type: "object" as const,
|
||||||
|
title: "연쇄 관계",
|
||||||
|
properties: {
|
||||||
|
parentField: { type: "string" as const, title: "부모 필드" },
|
||||||
|
filterColumn: { type: "string" as const, title: "필터 컬럼" },
|
||||||
|
clearOnChange: { type: "boolean" as const, default: true, title: "부모 변경시 초기화" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// UnifiedDate 기본 스키마
|
||||||
|
UnifiedDate: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: "string" as const,
|
||||||
|
enum: ["date", "time", "datetime"],
|
||||||
|
default: "date",
|
||||||
|
title: "타입",
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: "string" as const,
|
||||||
|
default: "YYYY-MM-DD",
|
||||||
|
title: "날짜 형식",
|
||||||
|
},
|
||||||
|
range: {
|
||||||
|
type: "boolean" as const,
|
||||||
|
default: false,
|
||||||
|
title: "범위 선택",
|
||||||
|
},
|
||||||
|
showToday: {
|
||||||
|
type: "boolean" as const,
|
||||||
|
default: true,
|
||||||
|
title: "오늘 버튼",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Record<string, UnifiedConfigSchema>;
|
||||||
|
|
||||||
|
export default DynamicConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedBiz
|
||||||
|
*
|
||||||
|
* 통합 비즈니스 컴포넌트
|
||||||
|
* - flow: 플로우/워크플로우
|
||||||
|
* - rack: 랙 구조
|
||||||
|
* - map: 맵/위치
|
||||||
|
* - numbering: 채번 규칙
|
||||||
|
* - category: 카테고리 관리
|
||||||
|
* - mapping: 데이터 매핑
|
||||||
|
* - related-buttons: 관련 데이터 버튼
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedBizProps } from "@/types/unified-components";
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
LayoutGrid,
|
||||||
|
MapPin,
|
||||||
|
Hash,
|
||||||
|
FolderTree,
|
||||||
|
Link2,
|
||||||
|
FileText,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 컴포넌트 (플레이스홀더)
|
||||||
|
* 실제 구현은 기존 FlowWidget과 연동
|
||||||
|
*/
|
||||||
|
const FlowBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
플로우
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<GitBranch className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm">플로우 디자이너</p>
|
||||||
|
<p className="text-xs">기존 FlowWidget과 연동</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FlowBiz.displayName = "FlowBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랙 구조 컴포넌트 (플레이스홀더)
|
||||||
|
* 실제 구현은 기존 RackStructure와 연동
|
||||||
|
*/
|
||||||
|
const RackBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
랙 구조
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<LayoutGrid className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm">랙 구조 뷰어</p>
|
||||||
|
<p className="text-xs">기존 RackStructure와 연동</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RackBiz.displayName = "RackBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 맵 컴포넌트 (플레이스홀더)
|
||||||
|
*/
|
||||||
|
const MapBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
위치 맵
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<MapPin className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm">위치 맵 뷰어</p>
|
||||||
|
<p className="text-xs">지도 라이브러리 연동 예정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
MapBiz.displayName = "MapBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙 컴포넌트 (플레이스홀더)
|
||||||
|
* 실제 구현은 기존 NumberingRuleComponent와 연동
|
||||||
|
*/
|
||||||
|
const NumberingBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Hash className="h-4 w-4" />
|
||||||
|
채번 규칙
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">자동 채번</p>
|
||||||
|
<p className="text-xs text-muted-foreground">규칙에 따라 자동 생성</p>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-sm bg-background px-2 py-1 rounded border">
|
||||||
|
PO-2024-0001
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
기존 NumberingRuleComponent와 연동
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
NumberingBiz.displayName = "NumberingBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 관리 컴포넌트 (플레이스홀더)
|
||||||
|
* 실제 구현은 기존 CategoryManager와 연동
|
||||||
|
*/
|
||||||
|
const CategoryBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<FolderTree className="h-4 w-4" />
|
||||||
|
카테고리
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="pl-0 py-1 px-2 bg-muted/50 rounded">
|
||||||
|
<span className="text-sm">대분류</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-4 py-1 px-2 text-sm text-muted-foreground">
|
||||||
|
└ 중분류
|
||||||
|
</div>
|
||||||
|
<div className="pl-8 py-1 px-2 text-sm text-muted-foreground">
|
||||||
|
└ 소분류
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||||
|
기존 CategoryManager와 연동
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CategoryBiz.displayName = "CategoryBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 매핑 컴포넌트 (플레이스홀더)
|
||||||
|
*/
|
||||||
|
const MappingBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
데이터 매핑
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4 justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
|
||||||
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">소스</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
|
||||||
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">대상</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
MappingBiz.displayName = "MappingBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관련 데이터 버튼 컴포넌트 (플레이스홀더)
|
||||||
|
*/
|
||||||
|
const RelatedButtonsBiz = forwardRef<HTMLDivElement, {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}>(({ config, className }, ref) => {
|
||||||
|
const buttons = (config?.buttons as Array<{ label: string; icon?: string }>) || [
|
||||||
|
{ label: "관련 주문" },
|
||||||
|
{ label: "관련 출고" },
|
||||||
|
{ label: "이력 보기" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
|
||||||
|
{buttons.map((button, index) => (
|
||||||
|
<Button key={index} variant="outline" size="sm">
|
||||||
|
{button.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RelatedButtonsBiz.displayName = "RelatedButtonsBiz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedBiz 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedBiz = forwardRef<HTMLDivElement, UnifiedBizProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { type: "flow" as const };
|
||||||
|
|
||||||
|
// 타입별 비즈니스 컴포넌트 렌더링
|
||||||
|
const renderBiz = () => {
|
||||||
|
const bizConfig = config.config || {};
|
||||||
|
const bizType = config.type || "flow";
|
||||||
|
|
||||||
|
switch (bizType) {
|
||||||
|
case "flow":
|
||||||
|
return <FlowBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
case "rack":
|
||||||
|
return <RackBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
case "map":
|
||||||
|
return <MapBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
case "numbering":
|
||||||
|
return <NumberingBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
case "category":
|
||||||
|
return <CategoryBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
case "mapping":
|
||||||
|
return <MappingBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
case "related-buttons":
|
||||||
|
return <RelatedButtonsBiz config={bizConfig} />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded text-center text-muted-foreground">
|
||||||
|
알 수 없는 비즈니스 타입: {config.type}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
fontSize: style?.labelFontSize,
|
||||||
|
color: style?.labelColor,
|
||||||
|
fontWeight: style?.labelFontWeight,
|
||||||
|
marginBottom: style?.labelMarginBottom,
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{renderBiz()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedBiz.displayName = "UnifiedBiz";
|
||||||
|
|
||||||
|
export default UnifiedBiz;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedComponentRenderer
|
||||||
|
*
|
||||||
|
* Unified 컴포넌트를 동적으로 렌더링하는 컴포넌트
|
||||||
|
* props.unifiedType에 따라 적절한 컴포넌트를 렌더링
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
UnifiedComponentProps,
|
||||||
|
isUnifiedInput,
|
||||||
|
isUnifiedSelect,
|
||||||
|
isUnifiedDate,
|
||||||
|
isUnifiedText,
|
||||||
|
isUnifiedMedia,
|
||||||
|
isUnifiedList,
|
||||||
|
isUnifiedLayout,
|
||||||
|
isUnifiedGroup,
|
||||||
|
isUnifiedBiz,
|
||||||
|
isUnifiedHierarchy,
|
||||||
|
} from "@/types/unified-components";
|
||||||
|
import { UnifiedInput } from "./UnifiedInput";
|
||||||
|
import { UnifiedSelect } from "./UnifiedSelect";
|
||||||
|
import { UnifiedDate } from "./UnifiedDate";
|
||||||
|
import { UnifiedList } from "./UnifiedList";
|
||||||
|
import { UnifiedLayout } from "./UnifiedLayout";
|
||||||
|
import { UnifiedGroup } from "./UnifiedGroup";
|
||||||
|
import { UnifiedMedia } from "./UnifiedMedia";
|
||||||
|
import { UnifiedBiz } from "./UnifiedBiz";
|
||||||
|
import { UnifiedHierarchy } from "./UnifiedHierarchy";
|
||||||
|
|
||||||
|
interface UnifiedComponentRendererProps {
|
||||||
|
props: UnifiedComponentProps;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified 컴포넌트 렌더러
|
||||||
|
*/
|
||||||
|
export const UnifiedComponentRenderer = forwardRef<HTMLDivElement, UnifiedComponentRendererProps>(
|
||||||
|
({ props, className }, ref) => {
|
||||||
|
const component = useMemo(() => {
|
||||||
|
// 타입 가드를 사용하여 적절한 컴포넌트 렌더링
|
||||||
|
if (isUnifiedInput(props)) {
|
||||||
|
return <UnifiedInput {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedSelect(props)) {
|
||||||
|
return <UnifiedSelect {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedDate(props)) {
|
||||||
|
return <UnifiedDate {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedText(props)) {
|
||||||
|
// UnifiedText는 UnifiedInput의 textarea 모드로 대체
|
||||||
|
// 필요시 별도 구현
|
||||||
|
return (
|
||||||
|
<div className="p-2 border rounded text-sm text-muted-foreground">
|
||||||
|
UnifiedText (UnifiedInput textarea 모드 사용 권장)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedMedia(props)) {
|
||||||
|
return <UnifiedMedia {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedList(props)) {
|
||||||
|
return <UnifiedList {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedLayout(props)) {
|
||||||
|
return <UnifiedLayout {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedGroup(props)) {
|
||||||
|
return <UnifiedGroup {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedBiz(props)) {
|
||||||
|
return <UnifiedBiz {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedHierarchy(props)) {
|
||||||
|
return <UnifiedHierarchy {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알 수 없는 타입
|
||||||
|
return (
|
||||||
|
<div className="p-2 border border-destructive rounded text-sm text-destructive">
|
||||||
|
알 수 없는 컴포넌트 타입: {(props as { unifiedType?: string }).unifiedType}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className}>
|
||||||
|
{component}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedComponentRenderer.displayName = "UnifiedComponentRenderer";
|
||||||
|
|
||||||
|
export default UnifiedComponentRenderer;
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,511 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedDate
|
||||||
|
*
|
||||||
|
* 통합 날짜/시간 컴포넌트
|
||||||
|
* - date: 날짜 선택
|
||||||
|
* - time: 시간 선택
|
||||||
|
* - datetime: 날짜+시간 선택
|
||||||
|
* - range 옵션: 범위 선택 (시작~종료)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||||
|
import { format, parse, isValid } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedDateProps, UnifiedDateType } from "@/types/unified-components";
|
||||||
|
|
||||||
|
// 날짜 형식 매핑
|
||||||
|
const DATE_FORMATS: Record<string, string> = {
|
||||||
|
"YYYY-MM-DD": "yyyy-MM-dd",
|
||||||
|
"YYYY/MM/DD": "yyyy/MM/dd",
|
||||||
|
"DD-MM-YYYY": "dd-MM-yyyy",
|
||||||
|
"DD/MM/YYYY": "dd/MM/yyyy",
|
||||||
|
"MM-DD-YYYY": "MM-dd-yyyy",
|
||||||
|
"MM/DD/YYYY": "MM/dd/yyyy",
|
||||||
|
"YYYY-MM-DD HH:mm": "yyyy-MM-dd HH:mm",
|
||||||
|
"YYYY-MM-DD HH:mm:ss": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 문자열 → Date 객체
|
||||||
|
function parseDate(value: string | undefined, formatStr: string): Date | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ISO 형식 먼저 시도
|
||||||
|
const isoDate = new Date(value);
|
||||||
|
if (isValid(isoDate)) return isoDate;
|
||||||
|
|
||||||
|
// 포맷에 맞게 파싱
|
||||||
|
const parsed = parse(value, dateFnsFormat, new Date());
|
||||||
|
return isValid(parsed) ? parsed : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date 객체 → 날짜 문자열
|
||||||
|
function formatDate(date: Date | undefined, formatStr: string): string {
|
||||||
|
if (!date || !isValid(date)) return "";
|
||||||
|
const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr;
|
||||||
|
return format(date, dateFnsFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 날짜 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const SingleDatePicker = forwardRef<HTMLButtonElement, {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
dateFormat: string;
|
||||||
|
showToday?: boolean;
|
||||||
|
minDate?: string;
|
||||||
|
maxDate?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
dateFormat = "YYYY-MM-DD",
|
||||||
|
showToday = true,
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
disabled,
|
||||||
|
readonly,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
|
||||||
|
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
||||||
|
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((selectedDate: Date | undefined) => {
|
||||||
|
if (selectedDate) {
|
||||||
|
onChange?.(formatDate(selectedDate, dateFormat));
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [dateFormat, onChange]);
|
||||||
|
|
||||||
|
const handleToday = useCallback(() => {
|
||||||
|
onChange?.(formatDate(new Date(), dateFormat));
|
||||||
|
setOpen(false);
|
||||||
|
}, [dateFormat, onChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onChange?.("");
|
||||||
|
setOpen(false);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-full justify-start text-left font-normal",
|
||||||
|
!value && "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value || "날짜 선택"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
initialFocus
|
||||||
|
locale={ko}
|
||||||
|
disabled={(date) => {
|
||||||
|
if (minDateObj && date < minDateObj) return true;
|
||||||
|
if (maxDateObj && date > maxDateObj) return true;
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 p-3 pt-0">
|
||||||
|
{showToday && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleToday}>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SingleDatePicker.displayName = "SingleDatePicker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 범위 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const RangeDatePicker = forwardRef<HTMLDivElement, {
|
||||||
|
value?: [string, string];
|
||||||
|
onChange?: (value: [string, string]) => void;
|
||||||
|
dateFormat: string;
|
||||||
|
minDate?: string;
|
||||||
|
maxDate?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
value = ["", ""],
|
||||||
|
onChange,
|
||||||
|
dateFormat = "YYYY-MM-DD",
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
disabled,
|
||||||
|
readonly,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const [openStart, setOpenStart] = useState(false);
|
||||||
|
const [openEnd, setOpenEnd] = useState(false);
|
||||||
|
|
||||||
|
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
|
||||||
|
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
|
||||||
|
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
||||||
|
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
||||||
|
|
||||||
|
const handleStartSelect = useCallback((date: Date | undefined) => {
|
||||||
|
if (date) {
|
||||||
|
const newStart = formatDate(date, dateFormat);
|
||||||
|
// 시작일이 종료일보다 크면 종료일도 같이 변경
|
||||||
|
if (endDate && date > endDate) {
|
||||||
|
onChange?.([newStart, newStart]);
|
||||||
|
} else {
|
||||||
|
onChange?.([newStart, value[1]]);
|
||||||
|
}
|
||||||
|
setOpenStart(false);
|
||||||
|
}
|
||||||
|
}, [value, dateFormat, endDate, onChange]);
|
||||||
|
|
||||||
|
const handleEndSelect = useCallback((date: Date | undefined) => {
|
||||||
|
if (date) {
|
||||||
|
const newEnd = formatDate(date, dateFormat);
|
||||||
|
// 종료일이 시작일보다 작으면 시작일도 같이 변경
|
||||||
|
if (startDate && date < startDate) {
|
||||||
|
onChange?.([newEnd, newEnd]);
|
||||||
|
} else {
|
||||||
|
onChange?.([value[0], newEnd]);
|
||||||
|
}
|
||||||
|
setOpenEnd(false);
|
||||||
|
}
|
||||||
|
}, [value, dateFormat, startDate, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex items-center gap-2", className)}>
|
||||||
|
{/* 시작 날짜 */}
|
||||||
|
<Popover open={openStart} onOpenChange={setOpenStart}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
className={cn(
|
||||||
|
"h-10 flex-1 justify-start text-left font-normal",
|
||||||
|
!value[0] && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value[0] || "시작일"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={startDate}
|
||||||
|
onSelect={handleStartSelect}
|
||||||
|
initialFocus
|
||||||
|
locale={ko}
|
||||||
|
disabled={(date) => {
|
||||||
|
if (minDateObj && date < minDateObj) return true;
|
||||||
|
if (maxDateObj && date > maxDateObj) return true;
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground">~</span>
|
||||||
|
|
||||||
|
{/* 종료 날짜 */}
|
||||||
|
<Popover open={openEnd} onOpenChange={setOpenEnd}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
className={cn(
|
||||||
|
"h-10 flex-1 justify-start text-left font-normal",
|
||||||
|
!value[1] && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value[1] || "종료일"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={endDate}
|
||||||
|
onSelect={handleEndSelect}
|
||||||
|
initialFocus
|
||||||
|
locale={ko}
|
||||||
|
disabled={(date) => {
|
||||||
|
if (minDateObj && date < minDateObj) return true;
|
||||||
|
if (maxDateObj && date > maxDateObj) return true;
|
||||||
|
// 시작일보다 이전 날짜는 선택 불가
|
||||||
|
if (startDate && date < startDate) return true;
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RangeDatePicker.displayName = "RangeDatePicker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const TimePicker = forwardRef<HTMLInputElement, {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, onChange, disabled, readonly, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="time"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readonly}
|
||||||
|
className="h-10 pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TimePicker.displayName = "TimePicker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜+시간 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const DateTimePicker = forwardRef<HTMLDivElement, {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
dateFormat: string;
|
||||||
|
minDate?: string;
|
||||||
|
maxDate?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
dateFormat = "YYYY-MM-DD HH:mm",
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
disabled,
|
||||||
|
readonly,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
// 날짜와 시간 분리
|
||||||
|
const [datePart, timePart] = useMemo(() => {
|
||||||
|
if (!value) return ["", ""];
|
||||||
|
const parts = value.split(" ");
|
||||||
|
return [parts[0] || "", parts[1] || ""];
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleDateChange = useCallback((newDate: string) => {
|
||||||
|
const newValue = `${newDate} ${timePart || "00:00"}`;
|
||||||
|
onChange?.(newValue.trim());
|
||||||
|
}, [timePart, onChange]);
|
||||||
|
|
||||||
|
const handleTimeChange = useCallback((newTime: string) => {
|
||||||
|
const newValue = `${datePart || format(new Date(), "yyyy-MM-dd")} ${newTime}`;
|
||||||
|
onChange?.(newValue.trim());
|
||||||
|
}, [datePart, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex gap-2", className)}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<SingleDatePicker
|
||||||
|
value={datePart}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
dateFormat="YYYY-MM-DD"
|
||||||
|
minDate={minDate}
|
||||||
|
maxDate={maxDate}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<TimePicker
|
||||||
|
value={timePart}
|
||||||
|
onChange={handleTimeChange}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DateTimePicker.displayName = "DateTimePicker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedDate 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedDate = forwardRef<HTMLDivElement, UnifiedDateProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { type: "date" as const };
|
||||||
|
|
||||||
|
const dateFormat = config.format || "YYYY-MM-DD";
|
||||||
|
|
||||||
|
// 타입별 컴포넌트 렌더링
|
||||||
|
const renderDatePicker = () => {
|
||||||
|
const isDisabled = disabled || readonly;
|
||||||
|
|
||||||
|
// 범위 선택
|
||||||
|
if (config.range) {
|
||||||
|
return (
|
||||||
|
<RangeDatePicker
|
||||||
|
value={Array.isArray(value) ? value as [string, string] : ["", ""]}
|
||||||
|
onChange={onChange as (value: [string, string]) => void}
|
||||||
|
dateFormat={dateFormat}
|
||||||
|
minDate={config.minDate}
|
||||||
|
maxDate={config.maxDate}
|
||||||
|
disabled={isDisabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입별 렌더링
|
||||||
|
switch (config.type) {
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<SingleDatePicker
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
dateFormat={dateFormat}
|
||||||
|
showToday={config.showToday}
|
||||||
|
minDate={config.minDate}
|
||||||
|
maxDate={config.maxDate}
|
||||||
|
disabled={isDisabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "time":
|
||||||
|
return (
|
||||||
|
<TimePicker
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "datetime":
|
||||||
|
return (
|
||||||
|
<DateTimePicker
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
dateFormat={dateFormat}
|
||||||
|
minDate={config.minDate}
|
||||||
|
maxDate={config.maxDate}
|
||||||
|
disabled={isDisabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<SingleDatePicker
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
dateFormat={dateFormat}
|
||||||
|
showToday={config.showToday}
|
||||||
|
disabled={isDisabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
fontSize: style?.labelFontSize,
|
||||||
|
color: style?.labelColor,
|
||||||
|
fontWeight: style?.labelFontWeight,
|
||||||
|
marginBottom: style?.labelMarginBottom,
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{renderDatePicker()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedDate.displayName = "UnifiedDate";
|
||||||
|
|
||||||
|
export default UnifiedDate;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedFormContext
|
||||||
|
*
|
||||||
|
* Unified 컴포넌트들이 폼 상태를 공유하고
|
||||||
|
* 조건부 로직을 처리할 수 있도록 하는 Context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||||
|
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
|
||||||
|
|
||||||
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
export interface FormFieldState {
|
||||||
|
value: unknown;
|
||||||
|
disabled?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormState {
|
||||||
|
[fieldId: string]: FormFieldState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedFormContextValue {
|
||||||
|
// 폼 상태
|
||||||
|
formData: Record<string, unknown>;
|
||||||
|
fieldStates: FormState;
|
||||||
|
|
||||||
|
// 값 관리
|
||||||
|
getValue: (fieldId: string) => unknown;
|
||||||
|
setValue: (fieldId: string, value: unknown) => void;
|
||||||
|
setValues: (values: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
// 조건부 로직
|
||||||
|
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
|
||||||
|
visible: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연쇄 관계
|
||||||
|
getCascadingFilter: (config?: CascadingConfig) => unknown;
|
||||||
|
|
||||||
|
// 필드 등록
|
||||||
|
registerField: (fieldId: string, initialValue?: unknown) => void;
|
||||||
|
unregisterField: (fieldId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Context 생성 =====
|
||||||
|
|
||||||
|
const UnifiedFormContext = createContext<UnifiedFormContextValue | null>(null);
|
||||||
|
|
||||||
|
// ===== 조건 평가 함수 =====
|
||||||
|
|
||||||
|
function evaluateOperator(
|
||||||
|
fieldValue: unknown,
|
||||||
|
operator: ConditionalConfig["operator"],
|
||||||
|
conditionValue: unknown
|
||||||
|
): boolean {
|
||||||
|
switch (operator) {
|
||||||
|
case "=":
|
||||||
|
return fieldValue === conditionValue;
|
||||||
|
case "!=":
|
||||||
|
return fieldValue !== conditionValue;
|
||||||
|
case ">":
|
||||||
|
return Number(fieldValue) > Number(conditionValue);
|
||||||
|
case "<":
|
||||||
|
return Number(fieldValue) < Number(conditionValue);
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(conditionValue)) {
|
||||||
|
return conditionValue.includes(fieldValue);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
case "notIn":
|
||||||
|
if (Array.isArray(conditionValue)) {
|
||||||
|
return !conditionValue.includes(fieldValue);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case "isEmpty":
|
||||||
|
return fieldValue === null || fieldValue === undefined || fieldValue === "" ||
|
||||||
|
(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||||
|
case "isNotEmpty":
|
||||||
|
return fieldValue !== null && fieldValue !== undefined && fieldValue !== "" &&
|
||||||
|
!(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Provider 컴포넌트 =====
|
||||||
|
|
||||||
|
interface UnifiedFormProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
initialValues?: Record<string, unknown>;
|
||||||
|
onChange?: (formData: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedFormProvider({
|
||||||
|
children,
|
||||||
|
initialValues = {},
|
||||||
|
onChange,
|
||||||
|
}: UnifiedFormProviderProps) {
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
|
||||||
|
const [fieldStates, setFieldStates] = useState<FormState>({});
|
||||||
|
|
||||||
|
// 값 가져오기
|
||||||
|
const getValue = useCallback((fieldId: string): unknown => {
|
||||||
|
return formData[fieldId];
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
// 단일 값 설정
|
||||||
|
const setValue = useCallback((fieldId: string, value: unknown) => {
|
||||||
|
setFormData(prev => {
|
||||||
|
const newData = { ...prev, [fieldId]: value };
|
||||||
|
onChange?.(newData);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// 여러 값 한 번에 설정
|
||||||
|
const setValues = useCallback((values: Record<string, unknown>) => {
|
||||||
|
setFormData(prev => {
|
||||||
|
const newData = { ...prev, ...values };
|
||||||
|
onChange?.(newData);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// 조건 평가
|
||||||
|
const evaluateCondition = useCallback((
|
||||||
|
fieldId: string,
|
||||||
|
config?: ConditionalConfig
|
||||||
|
): { visible: boolean; disabled: boolean } => {
|
||||||
|
// 조건부 설정이 없으면 기본값 반환
|
||||||
|
if (!config || !config.enabled) {
|
||||||
|
return { visible: true, disabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, operator, value, action } = config;
|
||||||
|
const fieldValue = formData[field];
|
||||||
|
|
||||||
|
// 조건 평가
|
||||||
|
const conditionMet = evaluateOperator(fieldValue, operator, value);
|
||||||
|
|
||||||
|
// 액션에 따른 결과
|
||||||
|
switch (action) {
|
||||||
|
case "show":
|
||||||
|
return { visible: conditionMet, disabled: false };
|
||||||
|
case "hide":
|
||||||
|
return { visible: !conditionMet, disabled: false };
|
||||||
|
case "enable":
|
||||||
|
return { visible: true, disabled: !conditionMet };
|
||||||
|
case "disable":
|
||||||
|
return { visible: true, disabled: conditionMet };
|
||||||
|
default:
|
||||||
|
return { visible: true, disabled: false };
|
||||||
|
}
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
// 연쇄 관계 필터값 가져오기
|
||||||
|
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
|
||||||
|
if (!config) return undefined;
|
||||||
|
return formData[config.parentField];
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
// 필드 등록
|
||||||
|
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
|
||||||
|
if (initialValue !== undefined && formData[fieldId] === undefined) {
|
||||||
|
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
|
||||||
|
}
|
||||||
|
setFieldStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldId]: { value: initialValue, visible: true, disabled: false },
|
||||||
|
}));
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
// 필드 해제
|
||||||
|
const unregisterField = useCallback((fieldId: string) => {
|
||||||
|
setFieldStates(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[fieldId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Context 값
|
||||||
|
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
|
||||||
|
formData,
|
||||||
|
fieldStates,
|
||||||
|
getValue,
|
||||||
|
setValue,
|
||||||
|
setValues,
|
||||||
|
evaluateCondition,
|
||||||
|
getCascadingFilter,
|
||||||
|
registerField,
|
||||||
|
unregisterField,
|
||||||
|
}), [
|
||||||
|
formData,
|
||||||
|
fieldStates,
|
||||||
|
getValue,
|
||||||
|
setValue,
|
||||||
|
setValues,
|
||||||
|
evaluateCondition,
|
||||||
|
getCascadingFilter,
|
||||||
|
registerField,
|
||||||
|
unregisterField,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnifiedFormContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</UnifiedFormContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 커스텀 훅 =====
|
||||||
|
|
||||||
|
export function useUnifiedForm(): UnifiedFormContextValue {
|
||||||
|
const context = useContext(UnifiedFormContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useUnifiedForm must be used within UnifiedFormProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
|
||||||
|
*/
|
||||||
|
export function useUnifiedField(
|
||||||
|
fieldId: string,
|
||||||
|
conditional?: ConditionalConfig
|
||||||
|
): {
|
||||||
|
value: unknown;
|
||||||
|
setValue: (value: unknown) => void;
|
||||||
|
visible: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
} {
|
||||||
|
const { getValue, setValue, evaluateCondition } = useUnifiedForm();
|
||||||
|
|
||||||
|
const value = getValue(fieldId);
|
||||||
|
const { visible, disabled } = evaluateCondition(fieldId, conditional);
|
||||||
|
|
||||||
|
const handleSetValue = useCallback((newValue: unknown) => {
|
||||||
|
setValue(fieldId, newValue);
|
||||||
|
}, [fieldId, setValue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
setValue: handleSetValue,
|
||||||
|
visible,
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 선택 훅 - 부모 필드 값에 따라 옵션 필터링
|
||||||
|
*/
|
||||||
|
export function useCascadingOptions<T extends { parentValue?: unknown }>(
|
||||||
|
options: T[],
|
||||||
|
cascading?: CascadingConfig
|
||||||
|
): T[] {
|
||||||
|
const { getCascadingFilter } = useUnifiedForm();
|
||||||
|
|
||||||
|
if (!cascading) return options;
|
||||||
|
|
||||||
|
const parentValue = getCascadingFilter(cascading);
|
||||||
|
|
||||||
|
if (parentValue === undefined || parentValue === null || parentValue === "") {
|
||||||
|
return []; // 부모 값이 없으면 빈 배열
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentValue로 필터링
|
||||||
|
return options.filter(opt => opt.parentValue === parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnifiedFormContext;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,456 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedGroup
|
||||||
|
*
|
||||||
|
* 통합 그룹 컴포넌트
|
||||||
|
* - tabs: 탭 그룹
|
||||||
|
* - accordion: 아코디언 그룹
|
||||||
|
* - section: 섹션 그룹
|
||||||
|
* - card-section: 카드 섹션
|
||||||
|
* - modal: 모달 그룹
|
||||||
|
* - form-modal: 폼 모달 그룹
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useState, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedGroupProps, TabItem } from "@/types/unified-components";
|
||||||
|
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 그룹 컴포넌트
|
||||||
|
*/
|
||||||
|
const TabsGroup = forwardRef<HTMLDivElement, {
|
||||||
|
tabs?: TabItem[];
|
||||||
|
activeTab?: string;
|
||||||
|
onTabChange?: (tabId: string) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({ tabs = [], activeTab, onTabChange, children, className }, ref) => {
|
||||||
|
const [internalActiveTab, setInternalActiveTab] = useState(activeTab || tabs[0]?.id || "");
|
||||||
|
|
||||||
|
const currentTab = activeTab || internalActiveTab;
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((tabId: string) => {
|
||||||
|
setInternalActiveTab(tabId);
|
||||||
|
onTabChange?.(tabId);
|
||||||
|
}, [onTabChange]);
|
||||||
|
|
||||||
|
// 탭 정보가 있으면 탭 사용, 없으면 children 그대로 렌더링
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
ref={ref}
|
||||||
|
value={currentTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${tabs.length}, 1fr)` }}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger key={tab.id} value={tab.id}>
|
||||||
|
{tab.title}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsContent key={tab.id} value={tab.id} className="mt-4">
|
||||||
|
{tab.content || children}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TabsGroup.displayName = "TabsGroup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아코디언 그룹 컴포넌트
|
||||||
|
*/
|
||||||
|
const AccordionGroup = forwardRef<HTMLDivElement, {
|
||||||
|
title?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({ title, collapsible = true, defaultExpanded = true, children, className }, ref) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
if (!collapsible) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("border rounded-lg", className)}>
|
||||||
|
{title && (
|
||||||
|
<div className="p-4 border-b bg-muted/50">
|
||||||
|
<h3 className="font-medium">{title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("border rounded-lg", className)}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50">
|
||||||
|
<h3 className="font-medium">{title || "그룹"}</h3>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="p-4 pt-0 border-t">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
AccordionGroup.displayName = "AccordionGroup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 섹션 그룹 컴포넌트
|
||||||
|
*/
|
||||||
|
const SectionGroup = forwardRef<HTMLDivElement, {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
if (collapsible) {
|
||||||
|
return (
|
||||||
|
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("space-y-2", className)}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div className="flex items-center justify-between cursor-pointer">
|
||||||
|
<div>
|
||||||
|
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||||
|
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="pt-2">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<div>
|
||||||
|
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||||
|
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SectionGroup.displayName = "SectionGroup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 섹션 그룹 컴포넌트
|
||||||
|
*/
|
||||||
|
const CardSectionGroup = forwardRef<HTMLDivElement, {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
if (collapsible) {
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<CardHeader className="cursor-pointer hover:bg-muted/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{title && <CardTitle>{title}</CardTitle>}
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0">{children}</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={className}>
|
||||||
|
{(title || description) && (
|
||||||
|
<CardHeader>
|
||||||
|
{title && <CardTitle>{title}</CardTitle>}
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={title || description ? "pt-0" : ""}>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CardSectionGroup.displayName = "CardSectionGroup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 그룹 컴포넌트
|
||||||
|
*/
|
||||||
|
const ModalGroup = forwardRef<HTMLDivElement, {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({ title, description, open = false, onOpenChange, modalSize = "md", children, className }, ref) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "max-w-sm",
|
||||||
|
md: "max-w-md",
|
||||||
|
lg: "max-w-lg",
|
||||||
|
xl: "max-w-xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<DialogHeader>
|
||||||
|
{title && <DialogTitle>{title}</DialogTitle>}
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ModalGroup.displayName = "ModalGroup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 모달 그룹 컴포넌트
|
||||||
|
*/
|
||||||
|
const FormModalGroup = forwardRef<HTMLDivElement, {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||||
|
onSubmit?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
open = false,
|
||||||
|
onOpenChange,
|
||||||
|
modalSize = "md",
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = "저장",
|
||||||
|
cancelLabel = "취소",
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "max-w-sm",
|
||||||
|
md: "max-w-md",
|
||||||
|
lg: "max-w-lg",
|
||||||
|
xl: "max-w-xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
onCancel?.();
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}, [onCancel, onOpenChange]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
onSubmit?.();
|
||||||
|
}, [onSubmit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<DialogHeader>
|
||||||
|
{title && <DialogTitle>{title}</DialogTitle>}
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
<div className="py-4">{children}</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormModalGroup.displayName = "FormModalGroup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedGroup 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedGroup = forwardRef<HTMLDivElement, UnifiedGroupProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { type: "section" as const, tabs: [] };
|
||||||
|
|
||||||
|
// 타입별 그룹 렌더링
|
||||||
|
const renderGroup = () => {
|
||||||
|
const groupType = config.type || "section";
|
||||||
|
switch (groupType) {
|
||||||
|
case "tabs":
|
||||||
|
return (
|
||||||
|
<TabsGroup
|
||||||
|
tabs={config.tabs}
|
||||||
|
activeTab={config.activeTab}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TabsGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "accordion":
|
||||||
|
return (
|
||||||
|
<AccordionGroup
|
||||||
|
title={config.title}
|
||||||
|
collapsible={config.collapsible}
|
||||||
|
defaultExpanded={config.defaultExpanded}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AccordionGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "section":
|
||||||
|
return (
|
||||||
|
<SectionGroup
|
||||||
|
title={config.title}
|
||||||
|
collapsible={config.collapsible}
|
||||||
|
defaultExpanded={config.defaultExpanded}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SectionGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "card-section":
|
||||||
|
return (
|
||||||
|
<CardSectionGroup
|
||||||
|
title={config.title}
|
||||||
|
collapsible={config.collapsible}
|
||||||
|
defaultExpanded={config.defaultExpanded}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CardSectionGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "modal":
|
||||||
|
return (
|
||||||
|
<ModalGroup
|
||||||
|
title={config.title}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
modalSize={config.modalSize}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ModalGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "form-modal":
|
||||||
|
return (
|
||||||
|
<FormModalGroup
|
||||||
|
title={config.title}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
modalSize={config.modalSize}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FormModalGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<SectionGroup title={config.title}>
|
||||||
|
{children}
|
||||||
|
</SectionGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderGroup()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedGroup.displayName = "UnifiedGroup";
|
||||||
|
|
||||||
|
export default UnifiedGroup;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,501 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedHierarchy
|
||||||
|
*
|
||||||
|
* 통합 계층 구조 컴포넌트
|
||||||
|
* - tree: 트리 뷰
|
||||||
|
* - org: 조직도
|
||||||
|
* - bom: BOM 구조
|
||||||
|
* - cascading: 연쇄 드롭다운
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useState } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedHierarchyProps, HierarchyNode } from "@/types/unified-components";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
File,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
GripVertical,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
Building
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 노드 컴포넌트
|
||||||
|
*/
|
||||||
|
const TreeNode = forwardRef<HTMLDivElement, {
|
||||||
|
node: HierarchyNode;
|
||||||
|
level: number;
|
||||||
|
maxLevel?: number;
|
||||||
|
selectedNode?: HierarchyNode;
|
||||||
|
onSelect?: (node: HierarchyNode) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
|
showQty?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
node,
|
||||||
|
level,
|
||||||
|
maxLevel,
|
||||||
|
selectedNode,
|
||||||
|
onSelect,
|
||||||
|
editable,
|
||||||
|
draggable,
|
||||||
|
showQty,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(level < 2);
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
const isSelected = selectedNode?.id === node.id;
|
||||||
|
|
||||||
|
// 최대 레벨 제한
|
||||||
|
if (maxLevel && level >= maxLevel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className}>
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||||
|
onClick={() => onSelect?.(node)}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
{draggable && (
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 확장/축소 아이콘 */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 p-0">
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
) : (
|
||||||
|
<span className="w-5" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 폴더/파일 아이콘 */}
|
||||||
|
{hasChildren ? (
|
||||||
|
isOpen ? (
|
||||||
|
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<Folder className="h-4 w-4 text-amber-500" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<File className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span className="flex-1 text-sm truncate">{node.label}</span>
|
||||||
|
|
||||||
|
{/* 수량 (BOM용) */}
|
||||||
|
{showQty && node.data?.qty && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
x{String(node.data.qty)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 편집 버튼 */}
|
||||||
|
{editable && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => { e.stopPropagation(); }}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자식 노드 */}
|
||||||
|
{hasChildren && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
{node.children!.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
maxLevel={maxLevel}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onSelect={onSelect}
|
||||||
|
editable={editable}
|
||||||
|
draggable={draggable}
|
||||||
|
showQty={showQty}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TreeNode.displayName = "TreeNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 뷰 컴포넌트
|
||||||
|
*/
|
||||||
|
const TreeView = forwardRef<HTMLDivElement, {
|
||||||
|
data: HierarchyNode[];
|
||||||
|
selectedNode?: HierarchyNode;
|
||||||
|
onNodeSelect?: (node: HierarchyNode) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
|
maxLevel?: number;
|
||||||
|
className?: string;
|
||||||
|
}>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
데이터가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
level={0}
|
||||||
|
maxLevel={maxLevel}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onSelect={onNodeSelect}
|
||||||
|
editable={editable}
|
||||||
|
draggable={draggable}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TreeView.displayName = "TreeView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조직도 뷰 컴포넌트
|
||||||
|
*/
|
||||||
|
const OrgView = forwardRef<HTMLDivElement, {
|
||||||
|
data: HierarchyNode[];
|
||||||
|
selectedNode?: HierarchyNode;
|
||||||
|
onNodeSelect?: (node: HierarchyNode) => void;
|
||||||
|
className?: string;
|
||||||
|
}>(({ data, selectedNode, onNodeSelect, className }, ref) => {
|
||||||
|
const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
|
||||||
|
const isSelected = selectedNode?.id === node.id;
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.id} className="flex flex-col items-center">
|
||||||
|
{/* 노드 카드 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center p-3 border rounded-lg cursor-pointer hover:border-primary transition-colors",
|
||||||
|
isSelected && "border-primary bg-primary/5",
|
||||||
|
isRoot && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => onNodeSelect?.(node)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center mb-2",
|
||||||
|
isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||||
|
)}>
|
||||||
|
{isRoot ? (
|
||||||
|
<Building className="h-5 w-5" />
|
||||||
|
) : hasChildren ? (
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-medium text-sm">{node.label}</div>
|
||||||
|
{node.data?.title && (
|
||||||
|
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자식 노드 */}
|
||||||
|
{hasChildren && (
|
||||||
|
<>
|
||||||
|
{/* 연결선 */}
|
||||||
|
<div className="w-px h-4 bg-border" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{node.children!.map((child, index) => (
|
||||||
|
<React.Fragment key={child.id}>
|
||||||
|
{index > 0 && <div className="w-4" />}
|
||||||
|
{renderOrgNode(child)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("overflow-auto p-4", className)}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
조직 데이터가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{data.map((node) => renderOrgNode(node, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
OrgView.displayName = "OrgView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOM 뷰 컴포넌트 (수량 포함 트리)
|
||||||
|
*/
|
||||||
|
const BomView = forwardRef<HTMLDivElement, {
|
||||||
|
data: HierarchyNode[];
|
||||||
|
selectedNode?: HierarchyNode;
|
||||||
|
onNodeSelect?: (node: HierarchyNode) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
BOM 데이터가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
level={0}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onSelect={onNodeSelect}
|
||||||
|
editable={editable}
|
||||||
|
showQty={true}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
BomView.displayName = "BomView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 드롭다운 컴포넌트
|
||||||
|
*/
|
||||||
|
const CascadingView = forwardRef<HTMLDivElement, {
|
||||||
|
data: HierarchyNode[];
|
||||||
|
selectedNode?: HierarchyNode;
|
||||||
|
onNodeSelect?: (node: HierarchyNode) => void;
|
||||||
|
maxLevel?: number;
|
||||||
|
className?: string;
|
||||||
|
}>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
|
||||||
|
const [selections, setSelections] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 레벨별 옵션 가져오기
|
||||||
|
const getOptionsForLevel = (level: number): HierarchyNode[] => {
|
||||||
|
if (level === 0) return data;
|
||||||
|
|
||||||
|
let currentNodes = data;
|
||||||
|
for (let i = 0; i < level; i++) {
|
||||||
|
const selectedId = selections[i];
|
||||||
|
if (!selectedId) return [];
|
||||||
|
|
||||||
|
const selectedNode = currentNodes.find((n) => n.id === selectedId);
|
||||||
|
if (!selectedNode?.children) return [];
|
||||||
|
|
||||||
|
currentNodes = selectedNode.children;
|
||||||
|
}
|
||||||
|
return currentNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택 핸들러
|
||||||
|
const handleSelect = (level: number, nodeId: string) => {
|
||||||
|
const newSelections = [...selections.slice(0, level), nodeId];
|
||||||
|
setSelections(newSelections);
|
||||||
|
|
||||||
|
// 마지막 선택된 노드 찾기
|
||||||
|
let node = data.find((n) => n.id === newSelections[0]);
|
||||||
|
for (let i = 1; i < newSelections.length; i++) {
|
||||||
|
node = node?.children?.find((n) => n.id === newSelections[i]);
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
onNodeSelect?.(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex gap-2", className)}>
|
||||||
|
{Array.from({ length: maxLevel }, (_, level) => {
|
||||||
|
const options = getOptionsForLevel(level);
|
||||||
|
const isDisabled = level > 0 && !selections[level - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
key={level}
|
||||||
|
value={selections[level] || ""}
|
||||||
|
onValueChange={(value) => handleSelect(level, value)}
|
||||||
|
disabled={isDisabled || options.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder={`${level + 1}단계 선택`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CascadingView.displayName = "CascadingView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedHierarchy 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedHierarchy = forwardRef<HTMLDivElement, UnifiedHierarchyProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
data = [],
|
||||||
|
selectedNode,
|
||||||
|
onNodeSelect,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
|
||||||
|
|
||||||
|
// 뷰모드별 렌더링
|
||||||
|
const renderHierarchy = () => {
|
||||||
|
const viewMode = config.viewMode || config.type || "tree";
|
||||||
|
switch (viewMode) {
|
||||||
|
case "tree":
|
||||||
|
return (
|
||||||
|
<TreeView
|
||||||
|
data={data}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
editable={config.editable}
|
||||||
|
draggable={config.draggable}
|
||||||
|
maxLevel={config.maxLevel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "org":
|
||||||
|
return (
|
||||||
|
<OrgView
|
||||||
|
data={data}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "bom":
|
||||||
|
return (
|
||||||
|
<BomView
|
||||||
|
data={data}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
editable={config.editable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "dropdown":
|
||||||
|
case "cascading":
|
||||||
|
return (
|
||||||
|
<CascadingView
|
||||||
|
data={data}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
maxLevel={config.maxLevel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<TreeView
|
||||||
|
data={data}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
fontSize: style?.labelFontSize,
|
||||||
|
color: style?.labelColor,
|
||||||
|
fontWeight: style?.labelFontWeight,
|
||||||
|
marginBottom: style?.labelMarginBottom,
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{renderHierarchy()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedHierarchy.displayName = "UnifiedHierarchy";
|
||||||
|
|
||||||
|
export default UnifiedHierarchy;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,452 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedInput
|
||||||
|
*
|
||||||
|
* 통합 입력 컴포넌트
|
||||||
|
* - text: 텍스트 입력
|
||||||
|
* - number: 숫자 입력
|
||||||
|
* - password: 비밀번호 입력
|
||||||
|
* - slider: 슬라이더 입력
|
||||||
|
* - color: 색상 선택
|
||||||
|
* - button: 버튼 (입력이 아닌 액션)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedInputProps, UnifiedInputType, UnifiedInputFormat } from "@/types/unified-components";
|
||||||
|
|
||||||
|
// 형식별 입력 마스크 및 검증 패턴
|
||||||
|
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
|
||||||
|
none: { pattern: /.*/, placeholder: "" },
|
||||||
|
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
|
||||||
|
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
|
||||||
|
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
|
||||||
|
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
|
||||||
|
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 통화 형식 변환
|
||||||
|
function formatCurrency(value: string | number): string {
|
||||||
|
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
||||||
|
if (isNaN(num)) return "";
|
||||||
|
return num.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사업자번호 형식 변환
|
||||||
|
function formatBizNo(value: string): string {
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전화번호 형식 변환
|
||||||
|
function formatTel(value: string): string {
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||||
|
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 입력 컴포넌트
|
||||||
|
*/
|
||||||
|
const TextInput = forwardRef<HTMLInputElement, {
|
||||||
|
value?: string | number;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
format?: UnifiedInputFormat;
|
||||||
|
mask?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
|
||||||
|
// 형식에 따른 값 포맷팅
|
||||||
|
const formatValue = useCallback((val: string): string => {
|
||||||
|
switch (format) {
|
||||||
|
case "currency":
|
||||||
|
return formatCurrency(val);
|
||||||
|
case "biz_no":
|
||||||
|
return formatBizNo(val);
|
||||||
|
case "tel":
|
||||||
|
return formatTel(val);
|
||||||
|
default:
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}, [format]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let newValue = e.target.value;
|
||||||
|
|
||||||
|
// 형식에 따른 자동 포맷팅
|
||||||
|
if (format === "currency") {
|
||||||
|
// 숫자와 쉼표만 허용
|
||||||
|
newValue = newValue.replace(/[^\d,]/g, "");
|
||||||
|
newValue = formatCurrency(newValue);
|
||||||
|
} else if (format === "biz_no") {
|
||||||
|
newValue = formatBizNo(newValue);
|
||||||
|
} else if (format === "tel") {
|
||||||
|
newValue = formatTel(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(newValue);
|
||||||
|
}, [format, onChange]);
|
||||||
|
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return formatValue(String(value));
|
||||||
|
}, [value, formatValue]);
|
||||||
|
|
||||||
|
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("h-full w-full", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TextInput.displayName = "TextInput";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 입력 컴포넌트
|
||||||
|
*/
|
||||||
|
const NumberInput = forwardRef<HTMLInputElement, {
|
||||||
|
value?: number;
|
||||||
|
onChange?: (value: number | undefined) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === "") {
|
||||||
|
onChange?.(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let num = parseFloat(val);
|
||||||
|
|
||||||
|
// 범위 제한
|
||||||
|
if (min !== undefined && num < min) num = min;
|
||||||
|
if (max !== undefined && num > max) num = max;
|
||||||
|
|
||||||
|
onChange?.(num);
|
||||||
|
}, [min, max, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="number"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
placeholder={placeholder || "숫자 입력"}
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("h-full w-full", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
NumberInput.displayName = "NumberInput";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 입력 컴포넌트
|
||||||
|
*/
|
||||||
|
const PasswordInput = forwardRef<HTMLInputElement, {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
placeholder={placeholder || "비밀번호 입력"}
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("h-full w-full pr-10", className)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{showPassword ? "숨김" : "보기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
PasswordInput.displayName = "PasswordInput";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬라이더 입력 컴포넌트
|
||||||
|
*/
|
||||||
|
const SliderInput = forwardRef<HTMLDivElement, {
|
||||||
|
value?: number;
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex items-center gap-4", className)}>
|
||||||
|
<Slider
|
||||||
|
value={[value ?? min]}
|
||||||
|
onValueChange={(values) => onChange?.(values[0])}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium w-12 text-right">{value ?? min}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SliderInput.displayName = "SliderInput";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 색상 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const ColorInput = forwardRef<HTMLInputElement, {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, onChange, disabled, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="color"
|
||||||
|
value={value || "#000000"}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-12 h-full p-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value || "#000000"}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-full flex-1 uppercase"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ColorInput.displayName = "ColorInput";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 줄 텍스트 입력 컴포넌트
|
||||||
|
*/
|
||||||
|
const TextareaInput = forwardRef<HTMLTextAreaElement, {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TextareaInput.displayName = "TextareaInput";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedInput 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config: UnifiedInputConfig = configProp || { type: "text" };
|
||||||
|
|
||||||
|
// 조건부 렌더링 체크
|
||||||
|
// TODO: conditional 처리 로직 추가
|
||||||
|
|
||||||
|
// 타입별 입력 컴포넌트 렌더링
|
||||||
|
const renderInput = () => {
|
||||||
|
const inputType = config.type || "text";
|
||||||
|
switch (inputType) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
format={config.format}
|
||||||
|
mask={config.mask}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
value={typeof value === "number" ? value : undefined}
|
||||||
|
onChange={(v) => onChange?.(v ?? 0)}
|
||||||
|
min={config.min}
|
||||||
|
max={config.max}
|
||||||
|
step={config.step}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
return (
|
||||||
|
<PasswordInput
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "slider":
|
||||||
|
return (
|
||||||
|
<SliderInput
|
||||||
|
value={typeof value === "number" ? value : config.min ?? 0}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
min={config.min}
|
||||||
|
max={config.max}
|
||||||
|
step={config.step}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "color":
|
||||||
|
return (
|
||||||
|
<ColorInput
|
||||||
|
value={typeof value === "string" ? value : "#000000"}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<TextareaInput
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
rows={config.rows}
|
||||||
|
readonly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
fontSize: style?.labelFontSize,
|
||||||
|
color: style?.labelColor,
|
||||||
|
fontWeight: style?.labelFontWeight,
|
||||||
|
marginBottom: style?.labelMarginBottom,
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedInput.displayName = "UnifiedInput";
|
||||||
|
|
||||||
|
export default UnifiedInput;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedLayout
|
||||||
|
*
|
||||||
|
* 통합 레이아웃 컴포넌트
|
||||||
|
* - grid: 그리드 레이아웃
|
||||||
|
* - split: 분할 레이아웃
|
||||||
|
* - flex: 플렉스 레이아웃
|
||||||
|
* - divider: 구분선
|
||||||
|
* - screen-embed: 화면 임베딩
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedLayoutProps } from "@/types/unified-components";
|
||||||
|
import { GripVertical, GripHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 레이아웃 컴포넌트 (12컬럼 시스템)
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* - columns: 컬럼 수 (기본 12, 전통적 그리드)
|
||||||
|
* - colSpan: 자식 요소별 span 지정 시 사용
|
||||||
|
* - Tailwind의 grid-cols-12 기반
|
||||||
|
*/
|
||||||
|
const GridLayout = forwardRef<HTMLDivElement, {
|
||||||
|
columns?: number; // 12컬럼 시스템에서 몇 컬럼으로 나눌지 (1-12)
|
||||||
|
gap?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
use12Column?: boolean; // 12컬럼 시스템 사용 여부
|
||||||
|
}>(({ columns = 12, gap = "16px", children, className, use12Column = true }, ref) => {
|
||||||
|
// 12컬럼 그리드 클래스 매핑
|
||||||
|
const gridColsClass: Record<number, string> = {
|
||||||
|
1: "grid-cols-1",
|
||||||
|
2: "grid-cols-2",
|
||||||
|
3: "grid-cols-3",
|
||||||
|
4: "grid-cols-4",
|
||||||
|
5: "grid-cols-5",
|
||||||
|
6: "grid-cols-6",
|
||||||
|
7: "grid-cols-7",
|
||||||
|
8: "grid-cols-8",
|
||||||
|
9: "grid-cols-9",
|
||||||
|
10: "grid-cols-10",
|
||||||
|
11: "grid-cols-11",
|
||||||
|
12: "grid-cols-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 12컬럼 시스템 사용 시
|
||||||
|
if (use12Column) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid",
|
||||||
|
gridColsClass[columns] || "grid-cols-12",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ gap }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 방식 (동적 컬럼 수)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("grid", className)}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||||
|
gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
GridLayout.displayName = "GridLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 레이아웃 컴포넌트 (리사이즈 가능)
|
||||||
|
*/
|
||||||
|
const SplitLayout = forwardRef<HTMLDivElement, {
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
splitRatio?: number[];
|
||||||
|
gap?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({ direction = "horizontal", splitRatio = [50, 50], gap = "8px", children, className }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [ratio, setRatio] = useState(splitRatio);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const childArray = React.Children.toArray(children);
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
// 리사이저 드래그 시작
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
|
const startPos = isHorizontal ? e.clientX : e.clientY;
|
||||||
|
const startRatio = [...ratio];
|
||||||
|
const container = containerRef.current;
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const containerSize = isHorizontal ? container.offsetWidth : container.offsetHeight;
|
||||||
|
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||||
|
const delta = currentPos - startPos;
|
||||||
|
const deltaPercent = (delta / containerSize) * 100;
|
||||||
|
|
||||||
|
const newFirst = Math.max(10, Math.min(90, startRatio[0] + deltaPercent));
|
||||||
|
const newSecond = 100 - newFirst;
|
||||||
|
|
||||||
|
setRatio([newFirst, newSecond]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}, [isHorizontal, ratio]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
isHorizontal ? "flex-row" : "flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ gap }}
|
||||||
|
>
|
||||||
|
{/* 첫 번째 패널 */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{
|
||||||
|
[isHorizontal ? "width" : "height"]: `${ratio[0]}%`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{childArray[0]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리사이저 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center bg-border hover:bg-primary/20 transition-colors",
|
||||||
|
isHorizontal ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
|
||||||
|
isDragging && "bg-primary/30"
|
||||||
|
)}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
{isHorizontal ? (
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 두 번째 패널 */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto flex-1"
|
||||||
|
style={{
|
||||||
|
[isHorizontal ? "width" : "height"]: `${ratio[1]}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{childArray[1]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SplitLayout.displayName = "SplitLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플렉스 레이아웃 컴포넌트
|
||||||
|
*/
|
||||||
|
const FlexLayout = forwardRef<HTMLDivElement, {
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
gap?: string;
|
||||||
|
wrap?: boolean;
|
||||||
|
justify?: "start" | "center" | "end" | "between" | "around";
|
||||||
|
align?: "start" | "center" | "end" | "stretch";
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
direction = "horizontal",
|
||||||
|
gap = "16px",
|
||||||
|
wrap = false,
|
||||||
|
justify = "start",
|
||||||
|
align = "stretch",
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const justifyMap = {
|
||||||
|
start: "flex-start",
|
||||||
|
center: "center",
|
||||||
|
end: "flex-end",
|
||||||
|
between: "space-between",
|
||||||
|
around: "space-around",
|
||||||
|
};
|
||||||
|
|
||||||
|
const alignMap = {
|
||||||
|
start: "flex-start",
|
||||||
|
center: "center",
|
||||||
|
end: "flex-end",
|
||||||
|
stretch: "stretch",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex", className)}
|
||||||
|
style={{
|
||||||
|
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||||
|
flexWrap: wrap ? "wrap" : "nowrap",
|
||||||
|
justifyContent: justifyMap[justify],
|
||||||
|
alignItems: alignMap[align],
|
||||||
|
gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FlexLayout.displayName = "FlexLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구분선 컴포넌트
|
||||||
|
*/
|
||||||
|
const DividerLayout = forwardRef<HTMLDivElement, {
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
className?: string;
|
||||||
|
}>(({ direction = "horizontal", className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-border",
|
||||||
|
direction === "horizontal" ? "h-px w-full my-4" : "w-px h-full mx-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DividerLayout.displayName = "DividerLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 컴포넌트
|
||||||
|
*/
|
||||||
|
const ScreenEmbedLayout = forwardRef<HTMLDivElement, {
|
||||||
|
screenId?: number;
|
||||||
|
className?: string;
|
||||||
|
}>(({ screenId, className }, ref) => {
|
||||||
|
if (!screenId) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center h-32 border-2 border-dashed rounded-lg text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
화면을 선택하세요
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 실제 화면 임베딩 로직 구현
|
||||||
|
// InteractiveScreenViewer와 연동 필요
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border rounded-lg p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground mb-2">
|
||||||
|
임베딩된 화면 (ID: {screenId})
|
||||||
|
</div>
|
||||||
|
<div className="h-48 bg-muted/30 rounded flex items-center justify-center">
|
||||||
|
{/* 여기에 InteractiveScreenViewer 렌더링 */}
|
||||||
|
화면 내용이 여기에 표시됩니다
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ScreenEmbedLayout.displayName = "ScreenEmbedLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedLayout 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedLayout = forwardRef<HTMLDivElement, UnifiedLayoutProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
children,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { type: "grid" as const, columns: 2 };
|
||||||
|
|
||||||
|
// 타입별 레이아웃 렌더링
|
||||||
|
const renderLayout = () => {
|
||||||
|
const layoutType = config.type || "grid";
|
||||||
|
switch (layoutType) {
|
||||||
|
case "grid":
|
||||||
|
return (
|
||||||
|
<GridLayout
|
||||||
|
columns={config.columns}
|
||||||
|
gap={config.gap}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</GridLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "split":
|
||||||
|
return (
|
||||||
|
<SplitLayout
|
||||||
|
direction={config.direction}
|
||||||
|
splitRatio={config.splitRatio}
|
||||||
|
gap={config.gap}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SplitLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "flex":
|
||||||
|
return (
|
||||||
|
<FlexLayout
|
||||||
|
direction={config.direction}
|
||||||
|
gap={config.gap}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FlexLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "divider":
|
||||||
|
return (
|
||||||
|
<DividerLayout
|
||||||
|
direction={config.direction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "screen-embed":
|
||||||
|
return (
|
||||||
|
<ScreenEmbedLayout
|
||||||
|
screenId={config.screenId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<GridLayout columns={config.columns} gap={config.gap}>
|
||||||
|
{children}
|
||||||
|
</GridLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderLayout()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedLayout.displayName = "UnifiedLayout";
|
||||||
|
|
||||||
|
export default UnifiedLayout;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,555 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedList
|
||||||
|
*
|
||||||
|
* 통합 리스트 컴포넌트
|
||||||
|
* - table: 테이블 뷰
|
||||||
|
* - card: 카드 뷰
|
||||||
|
* - kanban: 칸반 뷰
|
||||||
|
* - list: 단순 리스트 뷰
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||||
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedListProps, ListColumn } from "@/types/unified-components";
|
||||||
|
import { Search, ChevronUp, ChevronDown, MoreHorizontal, GripVertical } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 뷰 컴포넌트
|
||||||
|
*/
|
||||||
|
const TableView = forwardRef<HTMLDivElement, {
|
||||||
|
columns: ListColumn[];
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
selectedRows: Record<string, unknown>[];
|
||||||
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||||
|
onRowClick?: (row: Record<string, unknown>) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: "asc" | "desc";
|
||||||
|
onSort?: (column: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
selectedRows = [],
|
||||||
|
onRowSelect,
|
||||||
|
onRowClick,
|
||||||
|
editable,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
// 행 선택 처리
|
||||||
|
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
||||||
|
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
||||||
|
}, [selectedRows]);
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback((checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
onRowSelect?.(data);
|
||||||
|
} else {
|
||||||
|
onRowSelect?.([]);
|
||||||
|
}
|
||||||
|
}, [data, onRowSelect]);
|
||||||
|
|
||||||
|
const handleSelectRow = useCallback((row: Record<string, unknown>, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
onRowSelect?.([...selectedRows, row]);
|
||||||
|
} else {
|
||||||
|
onRowSelect?.(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row)));
|
||||||
|
}
|
||||||
|
}, [selectedRows, onRowSelect]);
|
||||||
|
|
||||||
|
const allSelected = data.length > 0 && selectedRows.length === data.length;
|
||||||
|
const someSelected = selectedRows.length > 0 && selectedRows.length < data.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("border rounded-md overflow-hidden", className)}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
{onRowSelect && (
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
// indeterminate 상태는 data-state로 처리
|
||||||
|
data-state={someSelected ? "indeterminate" : allSelected ? "checked" : "unchecked"}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.field}
|
||||||
|
className={cn(
|
||||||
|
column.sortable && "cursor-pointer select-none hover:bg-muted",
|
||||||
|
)}
|
||||||
|
style={{ width: column.width ? `${column.width}px` : "auto" }}
|
||||||
|
onClick={() => column.sortable && onSort?.(column.field)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{column.header}
|
||||||
|
{column.sortable && sortColumn === column.field && (
|
||||||
|
sortDirection === "asc"
|
||||||
|
? <ChevronUp className="h-4 w-4" />
|
||||||
|
: <ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
{editable && <TableHead className="w-10" />}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length + (onRowSelect ? 1 : 0) + (editable ? 1 : 0)}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
데이터가 없습니다
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.map((row, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer hover:bg-muted/50",
|
||||||
|
isRowSelected(row) && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
{onRowSelect && (
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isRowSelected(row)}
|
||||||
|
onCheckedChange={(checked) => handleSelectRow(row, checked as boolean)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.field}>
|
||||||
|
{formatCellValue(row[column.field], column.format)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TableView.displayName = "TableView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 뷰 컴포넌트
|
||||||
|
*/
|
||||||
|
const CardView = forwardRef<HTMLDivElement, {
|
||||||
|
columns: ListColumn[];
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
selectedRows: Record<string, unknown>[];
|
||||||
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||||
|
onRowClick?: (row: Record<string, unknown>) => void;
|
||||||
|
className?: string;
|
||||||
|
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
|
||||||
|
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
||||||
|
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
||||||
|
}, [selectedRows]);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback((row: Record<string, unknown>) => {
|
||||||
|
if (onRowSelect) {
|
||||||
|
const isSelected = isRowSelected(row);
|
||||||
|
if (isSelected) {
|
||||||
|
onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row)));
|
||||||
|
} else {
|
||||||
|
onRowSelect([...selectedRows, row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onRowClick?.(row);
|
||||||
|
}, [selectedRows, isRowSelected, onRowSelect, onRowClick]);
|
||||||
|
|
||||||
|
// 주요 컬럼 (첫 번째)과 나머지 구분
|
||||||
|
const [primaryColumn, ...otherColumns] = columns;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", className)}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||||
|
데이터가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((row, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-colors hover:border-primary",
|
||||||
|
isRowSelected(row) && "border-primary bg-primary/5"
|
||||||
|
)}
|
||||||
|
onClick={() => handleCardClick(row)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<dl className="space-y-1 text-sm">
|
||||||
|
{otherColumns.slice(0, 4).map((column) => (
|
||||||
|
<div key={column.field} className="flex justify-between">
|
||||||
|
<dt className="text-muted-foreground">{column.header}</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{formatCellValue(row[column.field], column.format)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CardView.displayName = "CardView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스트 뷰 컴포넌트
|
||||||
|
*/
|
||||||
|
const ListView = forwardRef<HTMLDivElement, {
|
||||||
|
columns: ListColumn[];
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
selectedRows: Record<string, unknown>[];
|
||||||
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||||
|
onRowClick?: (row: Record<string, unknown>) => void;
|
||||||
|
className?: string;
|
||||||
|
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
|
||||||
|
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
||||||
|
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
||||||
|
}, [selectedRows]);
|
||||||
|
|
||||||
|
const handleItemClick = useCallback((row: Record<string, unknown>) => {
|
||||||
|
if (onRowSelect) {
|
||||||
|
const isSelected = isRowSelected(row);
|
||||||
|
if (isSelected) {
|
||||||
|
onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row)));
|
||||||
|
} else {
|
||||||
|
onRowSelect([...selectedRows, row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onRowClick?.(row);
|
||||||
|
}, [selectedRows, isRowSelected, onRowSelect, onRowClick]);
|
||||||
|
|
||||||
|
const [primaryColumn, secondaryColumn] = columns;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("divide-y border rounded-md", className)}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
데이터가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50",
|
||||||
|
isRowSelected(row) && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => handleItemClick(row)}
|
||||||
|
>
|
||||||
|
{onRowSelect && (
|
||||||
|
<Checkbox
|
||||||
|
checked={isRowSelected(row)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
onRowSelect([...selectedRows, row]);
|
||||||
|
} else {
|
||||||
|
onRowSelect(selectedRows.filter((r) => r !== row));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
|
||||||
|
</div>
|
||||||
|
{secondaryColumn && (
|
||||||
|
<div className="text-sm text-muted-foreground truncate">
|
||||||
|
{formatCellValue(row[secondaryColumn.field], secondaryColumn.format)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ListView.displayName = "ListView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 셀 값 포맷팅
|
||||||
|
*/
|
||||||
|
function formatCellValue(value: unknown, format?: string): React.ReactNode {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
if (format) {
|
||||||
|
switch (format) {
|
||||||
|
case "date":
|
||||||
|
return new Date(String(value)).toLocaleDateString("ko-KR");
|
||||||
|
case "datetime":
|
||||||
|
return new Date(String(value)).toLocaleString("ko-KR");
|
||||||
|
case "currency":
|
||||||
|
return Number(value).toLocaleString("ko-KR") + "원";
|
||||||
|
case "number":
|
||||||
|
return Number(value).toLocaleString("ko-KR");
|
||||||
|
case "percent":
|
||||||
|
return Number(value).toFixed(1) + "%";
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedList 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
data = [],
|
||||||
|
selectedRows = [],
|
||||||
|
onRowSelect,
|
||||||
|
onRowClick,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { viewMode: "table" as const, source: "static" as const, columns: [] };
|
||||||
|
|
||||||
|
// 내부 상태
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
|
||||||
|
const pageSize = config.pageSize || 10;
|
||||||
|
const columns = config.columns || [];
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!searchTerm || !config.searchable) return data;
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return data.filter((row) =>
|
||||||
|
columns.some((col) => {
|
||||||
|
const value = row[col.field];
|
||||||
|
return value && String(value).toLowerCase().includes(term);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [data, searchTerm, config.searchable, columns]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sortColumn) return filteredData;
|
||||||
|
|
||||||
|
return [...filteredData].sort((a, b) => {
|
||||||
|
const aVal = a[sortColumn];
|
||||||
|
const bVal = b[sortColumn];
|
||||||
|
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal === null || aVal === undefined) return 1;
|
||||||
|
if (bVal === null || bVal === undefined) return -1;
|
||||||
|
|
||||||
|
const comparison = String(aVal).localeCompare(String(bVal), "ko-KR", { numeric: true });
|
||||||
|
return sortDirection === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
}, [filteredData, sortColumn, sortDirection]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
if (!config.pageable) return sortedData;
|
||||||
|
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return sortedData.slice(start, start + pageSize);
|
||||||
|
}, [sortedData, currentPage, pageSize, config.pageable]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||||
|
|
||||||
|
// 정렬 핸들러
|
||||||
|
const handleSort = useCallback((column: string) => {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
}, [sortColumn]);
|
||||||
|
|
||||||
|
// 뷰모드별 렌더링
|
||||||
|
const renderView = () => {
|
||||||
|
const viewProps = {
|
||||||
|
columns,
|
||||||
|
data: paginatedData,
|
||||||
|
selectedRows,
|
||||||
|
onRowSelect,
|
||||||
|
onRowClick,
|
||||||
|
editable: config.editable,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
onSort: handleSort,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (config.viewMode) {
|
||||||
|
case "table":
|
||||||
|
return <TableView {...viewProps} />;
|
||||||
|
case "card":
|
||||||
|
return <CardView {...viewProps} />;
|
||||||
|
case "list":
|
||||||
|
return <ListView {...viewProps} />;
|
||||||
|
case "kanban":
|
||||||
|
// TODO: 칸반 뷰 구현
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded text-center text-muted-foreground">
|
||||||
|
칸반 뷰 (미구현)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <TableView {...viewProps} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
const showSearch = config.searchable;
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
{(showLabel || showSearch) && (
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-shrink-0">
|
||||||
|
{showLabel && (
|
||||||
|
<Label className="text-lg font-semibold">{label}</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
{showSearch && (
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="pl-10 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 뷰 */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{renderView()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{config.pageable && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
총 {sortedData.length}건 중 {(currentPage - 1) * pageSize + 1}-
|
||||||
|
{Math.min(currentPage * pageSize, sortedData.length)}건
|
||||||
|
</div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
const page = i + 1;
|
||||||
|
return (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
isActive={currentPage === page}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedList.displayName = "UnifiedList";
|
||||||
|
|
||||||
|
export default UnifiedList;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,575 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedMedia
|
||||||
|
*
|
||||||
|
* 통합 미디어 컴포넌트
|
||||||
|
* - file: 파일 업로드
|
||||||
|
* - image: 이미지 업로드/표시
|
||||||
|
* - video: 비디오
|
||||||
|
* - audio: 오디오
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useRef, useState } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedMediaProps } from "@/types/unified-components";
|
||||||
|
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 크기 포맷팅
|
||||||
|
*/
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 타입 아이콘 가져오기
|
||||||
|
*/
|
||||||
|
function getFileIcon(type: string) {
|
||||||
|
if (type.startsWith("image/")) return ImageIcon;
|
||||||
|
if (type.startsWith("video/")) return Video;
|
||||||
|
if (type.startsWith("audio/")) return Music;
|
||||||
|
return File;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 업로드 컴포넌트
|
||||||
|
*/
|
||||||
|
const FileUploader = forwardRef<HTMLDivElement, {
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
multiple?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
uploadEndpoint?: string;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
multiple = false,
|
||||||
|
accept = "*",
|
||||||
|
maxSize = 10485760, // 10MB
|
||||||
|
disabled,
|
||||||
|
uploadEndpoint = "/api/upload",
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const files = Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
|
||||||
|
// 파일 선택 핸들러
|
||||||
|
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
const fileArray = Array.from(selectedFiles);
|
||||||
|
|
||||||
|
// 크기 검증
|
||||||
|
for (const file of fileArray) {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadedUrls: string[] = [];
|
||||||
|
|
||||||
|
for (const file of fileArray) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch(uploadEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`업로드 실패: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.url) {
|
||||||
|
uploadedUrls.push(data.url);
|
||||||
|
} else if (data.filePath) {
|
||||||
|
uploadedUrls.push(data.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiple) {
|
||||||
|
onChange?.([...files, ...uploadedUrls]);
|
||||||
|
} else {
|
||||||
|
onChange?.(uploadedUrls[0] || "");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}, [files, multiple, maxSize, uploadEndpoint, onChange]);
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 핸들러
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFileSelect(e.dataTransfer.files);
|
||||||
|
}, [handleFileSelect]);
|
||||||
|
|
||||||
|
// 파일 삭제 핸들러
|
||||||
|
const handleRemove = useCallback((index: number) => {
|
||||||
|
const newFiles = files.filter((_, i) => i !== index);
|
||||||
|
onChange?.(multiple ? newFiles : "");
|
||||||
|
}, [files, multiple, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("space-y-3", className)}>
|
||||||
|
{/* 업로드 영역 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-lg p-6 text-center transition-colors",
|
||||||
|
isDragging && "border-primary bg-primary/5",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
!disabled && "cursor-pointer hover:border-primary/50"
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
<span className="text-sm text-muted-foreground">업로드 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-primary">클릭</span>
|
||||||
|
<span className="text-muted-foreground"> 또는 파일을 드래그하세요</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
최대 {formatFileSize(maxSize)}
|
||||||
|
{accept !== "*" && ` (${accept})`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드된 파일 목록 */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 p-2 bg-muted/50 rounded-md"
|
||||||
|
>
|
||||||
|
<File className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="flex-1 text-sm truncate">{file.split("/").pop()}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FileUploader.displayName = "FileUploader";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 업로드/표시 컴포넌트
|
||||||
|
*/
|
||||||
|
const ImageUploader = forwardRef<HTMLDivElement, {
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
multiple?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
preview?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
uploadEndpoint?: string;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
multiple = false,
|
||||||
|
accept = "image/*",
|
||||||
|
maxSize = 10485760,
|
||||||
|
preview = true,
|
||||||
|
disabled,
|
||||||
|
uploadEndpoint = "/api/upload",
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const images = Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
|
||||||
|
// 파일 선택 핸들러
|
||||||
|
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileArray = Array.from(selectedFiles);
|
||||||
|
const uploadedUrls: string[] = [];
|
||||||
|
|
||||||
|
for (const file of fileArray) {
|
||||||
|
// 미리보기 생성
|
||||||
|
if (preview) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setPreviewUrl(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch(uploadEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.url) {
|
||||||
|
uploadedUrls.push(data.url);
|
||||||
|
} else if (data.filePath) {
|
||||||
|
uploadedUrls.push(data.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiple) {
|
||||||
|
onChange?.([...images, ...uploadedUrls]);
|
||||||
|
} else {
|
||||||
|
onChange?.(uploadedUrls[0] || "");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
}, [images, multiple, preview, uploadEndpoint, onChange]);
|
||||||
|
|
||||||
|
// 이미지 삭제 핸들러
|
||||||
|
const handleRemove = useCallback((index: number) => {
|
||||||
|
const newImages = images.filter((_, i) => i !== index);
|
||||||
|
onChange?.(multiple ? newImages : "");
|
||||||
|
}, [images, multiple, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("space-y-3", className)}>
|
||||||
|
{/* 이미지 미리보기 */}
|
||||||
|
{preview && images.length > 0 && (
|
||||||
|
<div className={cn(
|
||||||
|
"grid gap-2",
|
||||||
|
multiple ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" : "grid-cols-1"
|
||||||
|
)}>
|
||||||
|
{images.map((src, index) => (
|
||||||
|
<div key={index} className="relative group aspect-square rounded-lg overflow-hidden border">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={`이미지 ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => window.open(src, "_blank")}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
{(!images.length || multiple) && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
|
||||||
|
isDragging && "border-primary bg-primary/5",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
!disabled && "cursor-pointer hover:border-primary/50"
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
|
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
<span className="text-sm text-muted-foreground">업로드 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
이미지 {multiple ? "추가" : "선택"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ImageUploader.displayName = "ImageUploader";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 컴포넌트
|
||||||
|
*/
|
||||||
|
const VideoPlayer = forwardRef<HTMLDivElement, {
|
||||||
|
value?: string;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, className }, ref) => {
|
||||||
|
if (!value) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-video flex items-center justify-center border rounded-lg bg-muted/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Video className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("aspect-video rounded-lg overflow-hidden", className)}>
|
||||||
|
<video
|
||||||
|
src={value}
|
||||||
|
controls
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 컴포넌트
|
||||||
|
*/
|
||||||
|
const AudioPlayer = forwardRef<HTMLDivElement, {
|
||||||
|
value?: string;
|
||||||
|
className?: string;
|
||||||
|
}>(({ value, className }, ref) => {
|
||||||
|
if (!value) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 flex items-center justify-center border rounded-lg bg-muted/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Music className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("", className)}>
|
||||||
|
<audio
|
||||||
|
src={value}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
AudioPlayer.displayName = "AudioPlayer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedMedia 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedMedia = forwardRef<HTMLDivElement, UnifiedMediaProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { type: "image" as const };
|
||||||
|
|
||||||
|
// 타입별 미디어 컴포넌트 렌더링
|
||||||
|
const renderMedia = () => {
|
||||||
|
const isDisabled = disabled || readonly;
|
||||||
|
const mediaType = config.type || "image";
|
||||||
|
|
||||||
|
switch (mediaType) {
|
||||||
|
case "file":
|
||||||
|
return (
|
||||||
|
<FileUploader
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
multiple={config.multiple}
|
||||||
|
accept={config.accept}
|
||||||
|
maxSize={config.maxSize}
|
||||||
|
disabled={isDisabled}
|
||||||
|
uploadEndpoint={config.uploadEndpoint}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<ImageUploader
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
multiple={config.multiple}
|
||||||
|
accept={config.accept || "image/*"}
|
||||||
|
maxSize={config.maxSize}
|
||||||
|
preview={config.preview}
|
||||||
|
disabled={isDisabled}
|
||||||
|
uploadEndpoint={config.uploadEndpoint}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
return (
|
||||||
|
<VideoPlayer
|
||||||
|
value={typeof value === "string" ? value : value?.[0]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
return (
|
||||||
|
<AudioPlayer
|
||||||
|
value={typeof value === "string" ? value : value?.[0]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<FileUploader
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
fontSize: style?.labelFontSize,
|
||||||
|
color: style?.labelColor,
|
||||||
|
fontWeight: style?.labelFontWeight,
|
||||||
|
marginBottom: style?.labelMarginBottom,
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{renderMedia()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedMedia.displayName = "UnifiedMedia";
|
||||||
|
|
||||||
|
export default UnifiedMedia;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,676 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedSelect
|
||||||
|
*
|
||||||
|
* 통합 선택 컴포넌트
|
||||||
|
* - dropdown: 드롭다운 선택
|
||||||
|
* - radio: 라디오 버튼 그룹
|
||||||
|
* - check: 체크박스 그룹
|
||||||
|
* - tag: 태그 선택
|
||||||
|
* - toggle: 토글 스위치
|
||||||
|
* - swap: 스왑 선택 (좌우 이동)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UnifiedSelectProps, SelectOption } from "@/types/unified-components";
|
||||||
|
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드롭다운 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchable?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
maxSelect?: number;
|
||||||
|
allowClear?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "선택",
|
||||||
|
searchable,
|
||||||
|
multiple,
|
||||||
|
maxSelect,
|
||||||
|
allowClear = true,
|
||||||
|
disabled,
|
||||||
|
className
|
||||||
|
}, ref) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
||||||
|
if (!searchable && !multiple) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={typeof value === "string" ? value : value?.[0] ?? ""}
|
||||||
|
onValueChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger ref={ref} className={cn("h-10", className)}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 가능 또는 다중 선택 → Combobox 사용
|
||||||
|
const selectedValues = useMemo(() => {
|
||||||
|
if (!value) return [];
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const selectedLabels = useMemo(() => {
|
||||||
|
return selectedValues
|
||||||
|
.map((v) => options.find((o) => o.value === v)?.label)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
}, [selectedValues, options]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((selectedValue: string) => {
|
||||||
|
if (multiple) {
|
||||||
|
const newValues = selectedValues.includes(selectedValue)
|
||||||
|
? selectedValues.filter((v) => v !== selectedValue)
|
||||||
|
: maxSelect && selectedValues.length >= maxSelect
|
||||||
|
? selectedValues
|
||||||
|
: [...selectedValues, selectedValue];
|
||||||
|
onChange?.(newValues);
|
||||||
|
} else {
|
||||||
|
onChange?.(selectedValue);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [multiple, selectedValues, maxSelect, onChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange?.(multiple ? [] : "");
|
||||||
|
}, [multiple, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("h-10 w-full justify-between font-normal", className)}
|
||||||
|
>
|
||||||
|
<span className="truncate flex-1 text-left">
|
||||||
|
{selectedLabels.length > 0
|
||||||
|
? multiple
|
||||||
|
? `${selectedLabels.length}개 선택됨`
|
||||||
|
: selectedLabels[0]
|
||||||
|
: placeholder}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
{allowClear && selectedValues.length > 0 && (
|
||||||
|
<X
|
||||||
|
className="h-4 w-4 opacity-50 hover:opacity-100"
|
||||||
|
onClick={handleClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
// value는 CommandItem의 value (라벨)
|
||||||
|
// search는 검색어
|
||||||
|
if (!search) return 1;
|
||||||
|
const normalizedValue = value.toLowerCase();
|
||||||
|
const normalizedSearch = search.toLowerCase();
|
||||||
|
if (normalizedValue.includes(normalizedSearch)) return 1;
|
||||||
|
return 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const displayLabel = option.label || option.value || "(빈 값)";
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={displayLabel}
|
||||||
|
onSelect={() => handleSelect(option.value)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{displayLabel}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DropdownSelect.displayName = "DropdownSelect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라디오 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const RadioSelect = forwardRef<HTMLDivElement, {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ options, value, onChange, disabled, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
ref={ref}
|
||||||
|
value={value ?? ""}
|
||||||
|
onValueChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("flex flex-wrap gap-4", className)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={option.value} id={`radio-${option.value}`} />
|
||||||
|
<Label htmlFor={`radio-${option.value}`} className="text-sm cursor-pointer">
|
||||||
|
{option.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioSelect.displayName = "RadioSelect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 체크박스 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const CheckSelect = forwardRef<HTMLDivElement, {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (value: string[]) => void;
|
||||||
|
maxSelect?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
|
||||||
|
const handleChange = useCallback((optionValue: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
if (maxSelect && value.length >= maxSelect) return;
|
||||||
|
onChange?.([...value, optionValue]);
|
||||||
|
} else {
|
||||||
|
onChange?.(value.filter((v) => v !== optionValue));
|
||||||
|
}
|
||||||
|
}, [value, maxSelect, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex flex-wrap gap-4", className)}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`check-${option.value}`}
|
||||||
|
checked={value.includes(option.value)}
|
||||||
|
onCheckedChange={(checked) => handleChange(option.value, checked as boolean)}
|
||||||
|
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`check-${option.value}`} className="text-sm cursor-pointer">
|
||||||
|
{option.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CheckSelect.displayName = "CheckSelect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const TagSelect = forwardRef<HTMLDivElement, {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (value: string[]) => void;
|
||||||
|
maxSelect?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
|
||||||
|
const handleToggle = useCallback((optionValue: string) => {
|
||||||
|
const isSelected = value.includes(optionValue);
|
||||||
|
if (isSelected) {
|
||||||
|
onChange?.(value.filter((v) => v !== optionValue));
|
||||||
|
} else {
|
||||||
|
if (maxSelect && value.length >= maxSelect) return;
|
||||||
|
onChange?.([...value, optionValue]);
|
||||||
|
}
|
||||||
|
}, [value, maxSelect, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value.includes(option.value);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={option.value}
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-colors",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && handleToggle(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TagSelect.displayName = "TagSelect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토글 선택 컴포넌트 (Boolean용)
|
||||||
|
*/
|
||||||
|
const ToggleSelect = forwardRef<HTMLDivElement, {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ options, value, onChange, disabled, className }, ref) => {
|
||||||
|
// 토글은 2개 옵션만 지원
|
||||||
|
const [offOption, onOption] = options.length >= 2
|
||||||
|
? [options[0], options[1]]
|
||||||
|
: [{ value: "false", label: "아니오" }, { value: "true", label: "예" }];
|
||||||
|
|
||||||
|
const isOn = value === onOption.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex items-center gap-3", className)}>
|
||||||
|
<span className={cn("text-sm", !isOn && "font-medium")}>{offOption.label}</span>
|
||||||
|
<Switch
|
||||||
|
checked={isOn}
|
||||||
|
onCheckedChange={(checked) => onChange?.(checked ? onOption.value : offOption.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className={cn("text-sm", isOn && "font-medium")}>{onOption.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ToggleSelect.displayName = "ToggleSelect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스왑 선택 컴포넌트 (좌우 이동 방식)
|
||||||
|
*/
|
||||||
|
const SwapSelect = forwardRef<HTMLDivElement, {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (value: string[]) => void;
|
||||||
|
maxSelect?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>(({ options, value = [], onChange, disabled, className }, ref) => {
|
||||||
|
const available = useMemo(() =>
|
||||||
|
options.filter((o) => !value.includes(o.value)),
|
||||||
|
[options, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = useMemo(() =>
|
||||||
|
options.filter((o) => value.includes(o.value)),
|
||||||
|
[options, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveRight = useCallback((optionValue: string) => {
|
||||||
|
onChange?.([...value, optionValue]);
|
||||||
|
}, [value, onChange]);
|
||||||
|
|
||||||
|
const handleMoveLeft = useCallback((optionValue: string) => {
|
||||||
|
onChange?.(value.filter((v) => v !== optionValue));
|
||||||
|
}, [value, onChange]);
|
||||||
|
|
||||||
|
const handleMoveAllRight = useCallback(() => {
|
||||||
|
onChange?.(options.map((o) => o.value));
|
||||||
|
}, [options, onChange]);
|
||||||
|
|
||||||
|
const handleMoveAllLeft = useCallback(() => {
|
||||||
|
onChange?.([]);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
|
||||||
|
{/* 왼쪽: 선택 가능 */}
|
||||||
|
<div className="flex-1 border rounded-md">
|
||||||
|
<div className="p-2 bg-muted text-xs font-medium border-b">선택 가능</div>
|
||||||
|
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{available.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-sm rounded cursor-pointer hover:bg-accent",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && handleMoveRight(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{available.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">항목 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 이동 버튼 */}
|
||||||
|
<div className="flex flex-col gap-1 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleMoveAllRight}
|
||||||
|
disabled={disabled || available.length === 0}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-4 w-4 rotate-180" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleMoveAllLeft}
|
||||||
|
disabled={disabled || selected.length === 0}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 선택됨 */}
|
||||||
|
<div className="flex-1 border rounded-md">
|
||||||
|
<div className="p-2 bg-primary/10 text-xs font-medium border-b">선택됨</div>
|
||||||
|
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{selected.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-sm rounded cursor-pointer hover:bg-accent flex justify-between items-center",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && handleMoveLeft(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<X className="h-3 w-3 opacity-50" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selected.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">선택 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SwapSelect.displayName = "SwapSelect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 UnifiedSelect 컴포넌트
|
||||||
|
*/
|
||||||
|
export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
config: configProp,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// config가 없으면 기본값 사용
|
||||||
|
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 데이터 소스에 따른 옵션 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
const loadOptions = async () => {
|
||||||
|
console.log("🎯 UnifiedSelect 전체 props:", props);
|
||||||
|
console.log("🎯 UnifiedSelect config:", config);
|
||||||
|
console.log("🎯 UnifiedSelect loadOptions 호출:", {
|
||||||
|
source: config.source,
|
||||||
|
entityTable: config.entityTable,
|
||||||
|
entityValueColumn: config.entityValueColumn,
|
||||||
|
entityLabelColumn: config.entityLabelColumn,
|
||||||
|
codeGroup: config.codeGroup,
|
||||||
|
table: config.table,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.source === "static") {
|
||||||
|
setOptions(config.options || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let fetchedOptions: SelectOption[] = [];
|
||||||
|
|
||||||
|
if (config.source === "code" && config.codeGroup) {
|
||||||
|
// 공통코드에서 로드
|
||||||
|
const response = await apiClient.get(`/common-codes/${config.codeGroup}/items`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data.map((item: { code: string; codeName: string }) => ({
|
||||||
|
value: item.code,
|
||||||
|
label: item.codeName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (config.source === "db" && config.table) {
|
||||||
|
// DB 테이블에서 로드
|
||||||
|
const response = await apiClient.get(`/entity/${config.table}/options`, {
|
||||||
|
params: {
|
||||||
|
value: config.valueColumn || "id",
|
||||||
|
label: config.labelColumn || "name",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data;
|
||||||
|
}
|
||||||
|
} else if (config.source === "entity" && config.entityTable) {
|
||||||
|
// 엔티티(참조 테이블)에서 로드
|
||||||
|
const valueCol = config.entityValueColumn || config.entityValueField || "id";
|
||||||
|
const labelCol = config.entityLabelColumn || config.entityLabelField || "name";
|
||||||
|
console.log("🔍 Entity 옵션 API 호출:", `/entity/${config.entityTable}/options`, { value: valueCol, label: labelCol });
|
||||||
|
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
|
||||||
|
params: {
|
||||||
|
value: valueCol,
|
||||||
|
label: labelCol,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
console.log("🔍 Entity 옵션 API 응답:", data);
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data;
|
||||||
|
}
|
||||||
|
} else if (config.source === "api" && config.apiEndpoint) {
|
||||||
|
// 외부 API에서 로드
|
||||||
|
const response = await apiClient.get(config.apiEndpoint);
|
||||||
|
const data = response.data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
fetchedOptions = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(fetchedOptions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("옵션 로딩 실패:", error);
|
||||||
|
setOptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOptions();
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 모드별 컴포넌트 렌더링
|
||||||
|
const renderSelect = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="h-10 flex items-center text-sm text-muted-foreground">로딩 중...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = disabled || readonly;
|
||||||
|
|
||||||
|
switch (config.mode) {
|
||||||
|
case "dropdown":
|
||||||
|
return (
|
||||||
|
<DropdownSelect
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="선택"
|
||||||
|
searchable={config.searchable}
|
||||||
|
multiple={config.multiple}
|
||||||
|
maxSelect={config.maxSelect}
|
||||||
|
allowClear={config.allowClear}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "radio":
|
||||||
|
return (
|
||||||
|
<RadioSelect
|
||||||
|
options={options}
|
||||||
|
value={typeof value === "string" ? value : value?.[0]}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "check":
|
||||||
|
return (
|
||||||
|
<CheckSelect
|
||||||
|
options={options}
|
||||||
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
|
onChange={onChange}
|
||||||
|
maxSelect={config.maxSelect}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "tag":
|
||||||
|
return (
|
||||||
|
<TagSelect
|
||||||
|
options={options}
|
||||||
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
|
onChange={onChange}
|
||||||
|
maxSelect={config.maxSelect}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "toggle":
|
||||||
|
return (
|
||||||
|
<ToggleSelect
|
||||||
|
options={options}
|
||||||
|
value={typeof value === "string" ? value : value?.[0]}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "swap":
|
||||||
|
return (
|
||||||
|
<SwapSelect
|
||||||
|
options={options}
|
||||||
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
|
onChange={onChange}
|
||||||
|
maxSelect={config.maxSelect}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<DropdownSelect
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
const componentWidth = size?.width || style?.width;
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
fontSize: style?.labelFontSize,
|
||||||
|
color: style?.labelColor,
|
||||||
|
fontWeight: style?.labelFontWeight,
|
||||||
|
marginBottom: style?.labelMarginBottom,
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{renderSelect()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedSelect.displayName = "UnifiedSelect";
|
||||||
|
|
||||||
|
export default UnifiedSelect;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,267 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedBiz 설정 패널
|
||||||
|
* 통합 비즈니스 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface UnifiedBizConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedBizConfigPanel: React.FC<UnifiedBizConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 비즈니스 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">비즈니스 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.bizType || config.type || "flow"}
|
||||||
|
onValueChange={(value) => updateConfig("bizType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="flow">플로우</SelectItem>
|
||||||
|
<SelectItem value="rack">랙 구조</SelectItem>
|
||||||
|
<SelectItem value="map">지도</SelectItem>
|
||||||
|
<SelectItem value="numbering">채번 규칙</SelectItem>
|
||||||
|
<SelectItem value="category">카테고리</SelectItem>
|
||||||
|
<SelectItem value="data-mapping">데이터 매핑</SelectItem>
|
||||||
|
<SelectItem value="related-data">관련 데이터</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 플로우 설정 */}
|
||||||
|
{config.bizType === "flow" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">플로우 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">플로우 ID</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.flowId || ""}
|
||||||
|
onChange={(e) => updateConfig("flowId", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="플로우 ID"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="editable"
|
||||||
|
checked={config.editable || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("editable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="editable" className="text-xs">편집 가능</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showMinimap"
|
||||||
|
checked={config.showMinimap || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showMinimap", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showMinimap" className="text-xs">미니맵 표시</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 랙 구조 설정 */}
|
||||||
|
{config.bizType === "rack" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">랙 설정</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">행 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.rows || ""}
|
||||||
|
onChange={(e) => updateConfig("rows", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="5"
|
||||||
|
min="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">열 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.columns || ""}
|
||||||
|
onChange={(e) => updateConfig("columns", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="10"
|
||||||
|
min="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showLabels"
|
||||||
|
checked={config.showLabels !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showLabels", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showLabels" className="text-xs">라벨 표시</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 채번 규칙 설정 */}
|
||||||
|
{config.bizType === "numbering" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">채번 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">채번 규칙 ID</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.ruleId || ""}
|
||||||
|
onChange={(e) => updateConfig("ruleId", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="규칙 ID"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">접두사</Label>
|
||||||
|
<Input
|
||||||
|
value={config.prefix || ""}
|
||||||
|
onChange={(e) => updateConfig("prefix", e.target.value)}
|
||||||
|
placeholder="예: INV-"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autoGenerate"
|
||||||
|
checked={config.autoGenerate !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("autoGenerate", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="autoGenerate" className="text-xs">자동 생성</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카테고리 설정 */}
|
||||||
|
{config.bizType === "category" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">카테고리 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">카테고리 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.tableName || ""}
|
||||||
|
onChange={(e) => updateConfig("tableName", e.target.value)}
|
||||||
|
placeholder="카테고리 테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">컬럼명</Label>
|
||||||
|
<Input
|
||||||
|
value={config.columnName || ""}
|
||||||
|
onChange={(e) => updateConfig("columnName", e.target.value)}
|
||||||
|
placeholder="컬럼명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 매핑 설정 */}
|
||||||
|
{config.bizType === "data-mapping" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">매핑 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">소스 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.sourceTable || ""}
|
||||||
|
onChange={(e) => updateConfig("sourceTable", e.target.value)}
|
||||||
|
placeholder="소스 테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">대상 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.targetTable || ""}
|
||||||
|
onChange={(e) => updateConfig("targetTable", e.target.value)}
|
||||||
|
placeholder="대상 테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 관련 데이터 설정 */}
|
||||||
|
{config.bizType === "related-data" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">관련 데이터 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">관련 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.relatedTable || ""}
|
||||||
|
onChange={(e) => updateConfig("relatedTable", e.target.value)}
|
||||||
|
placeholder="관련 테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">연결 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.linkColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("linkColumn", e.target.value)}
|
||||||
|
placeholder="연결 컬럼명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.buttonText || ""}
|
||||||
|
onChange={(e) => updateConfig("buttonText", e.target.value)}
|
||||||
|
placeholder="관련 데이터 보기"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedBizConfigPanel.displayName = "UnifiedBizConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedBizConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedDate 설정 패널
|
||||||
|
* 통합 날짜 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface UnifiedDateConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedDateConfigPanel: React.FC<UnifiedDateConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 날짜 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">날짜 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.dateType || config.type || "date"}
|
||||||
|
onValueChange={(value) => updateConfig("dateType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="date">날짜</SelectItem>
|
||||||
|
<SelectItem value="time">시간</SelectItem>
|
||||||
|
<SelectItem value="datetime">날짜+시간</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 표시 형식 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 형식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.format || "YYYY-MM-DD"}
|
||||||
|
onValueChange={(value) => updateConfig("format", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="형식 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
|
||||||
|
<SelectItem value="YYYY/MM/DD">YYYY/MM/DD</SelectItem>
|
||||||
|
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
|
||||||
|
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
|
||||||
|
<SelectItem value="YYYY년 MM월 DD일">YYYY년 MM월 DD일</SelectItem>
|
||||||
|
{(config.dateType === "time" || config.dateType === "datetime") && (
|
||||||
|
<>
|
||||||
|
<SelectItem value="HH:mm">HH:mm</SelectItem>
|
||||||
|
<SelectItem value="HH:mm:ss">HH:mm:ss</SelectItem>
|
||||||
|
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
|
||||||
|
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 날짜 범위 제한 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">날짜 범위 제한</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최소 날짜</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={config.minDate || ""}
|
||||||
|
onChange={(e) => updateConfig("minDate", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최대 날짜</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={config.maxDate || ""}
|
||||||
|
onChange={(e) => updateConfig("maxDate", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 추가 옵션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">추가 옵션</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="range"
|
||||||
|
checked={config.range || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("range", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="range" className="text-xs">기간 선택 (시작~종료)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showToday"
|
||||||
|
checked={config.showToday !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showToday", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showToday" className="text-xs">오늘 버튼 표시</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(config.dateType === "datetime" || config.dateType === "time") && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showSeconds"
|
||||||
|
checked={config.showSeconds || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showSeconds", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showSeconds" className="text-xs">초 단위 표시</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedDateConfigPanel.displayName = "UnifiedDateConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedDateConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedGroup 설정 패널
|
||||||
|
* 통합 그룹 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface UnifiedGroupConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedGroupConfigPanel: React.FC<UnifiedGroupConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 탭 관리
|
||||||
|
const tabs = config.tabs || [];
|
||||||
|
|
||||||
|
const addTab = () => {
|
||||||
|
const newTabs = [...tabs, { id: `tab-${Date.now()}`, label: "새 탭", content: "" }];
|
||||||
|
updateConfig("tabs", newTabs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTab = (index: number, field: string, value: string) => {
|
||||||
|
const newTabs = [...tabs];
|
||||||
|
newTabs[index] = { ...newTabs[index], [field]: value };
|
||||||
|
updateConfig("tabs", newTabs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTab = (index: number) => {
|
||||||
|
const newTabs = tabs.filter((_: any, i: number) => i !== index);
|
||||||
|
updateConfig("tabs", newTabs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 그룹 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">그룹 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.groupType || config.type || "section"}
|
||||||
|
onValueChange={(value) => updateConfig("groupType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="section">섹션</SelectItem>
|
||||||
|
<SelectItem value="tabs">탭</SelectItem>
|
||||||
|
<SelectItem value="accordion">아코디언</SelectItem>
|
||||||
|
<SelectItem value="card">카드 섹션</SelectItem>
|
||||||
|
<SelectItem value="modal">모달</SelectItem>
|
||||||
|
<SelectItem value="form-modal">폼 모달</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.title || ""}
|
||||||
|
onChange={(e) => updateConfig("title", e.target.value)}
|
||||||
|
placeholder="그룹 제목"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 설정 */}
|
||||||
|
{config.groupType === "tabs" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">탭 목록</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addTab}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{tabs.map((tab: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={tab.id || ""}
|
||||||
|
onChange={(e) => updateTab(index, "id", e.target.value)}
|
||||||
|
placeholder="ID"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={tab.label || ""}
|
||||||
|
onChange={(e) => updateTab(index, "label", e.target.value)}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeTab(index)}
|
||||||
|
className="h-7 w-7 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tabs.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-2">
|
||||||
|
탭을 추가해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 섹션/아코디언 옵션 */}
|
||||||
|
{(config.groupType === "section" || config.groupType === "accordion" || !config.groupType) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="collapsible"
|
||||||
|
checked={config.collapsible || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("collapsible", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="collapsible" className="text-xs">접기/펴기 가능</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.collapsible && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="defaultOpen"
|
||||||
|
checked={config.defaultOpen !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("defaultOpen", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="defaultOpen" className="text-xs">기본으로 펼침</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달 옵션 */}
|
||||||
|
{(config.groupType === "modal" || config.groupType === "form-modal") && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">모달 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={config.modalSize || "md"}
|
||||||
|
onValueChange={(value) => updateConfig("modalSize", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작게 (400px)</SelectItem>
|
||||||
|
<SelectItem value="md">보통 (600px)</SelectItem>
|
||||||
|
<SelectItem value="lg">크게 (800px)</SelectItem>
|
||||||
|
<SelectItem value="xl">매우 크게 (1000px)</SelectItem>
|
||||||
|
<SelectItem value="full">전체 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="closeable"
|
||||||
|
checked={config.closeable !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("closeable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="closeable" className="text-xs">닫기 버튼 표시</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="backdrop"
|
||||||
|
checked={config.backdrop !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("backdrop", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="backdrop" className="text-xs">배경 클릭으로 닫기</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 헤더 표시 여부 */}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showHeader"
|
||||||
|
checked={config.showHeader !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showHeader" className="text-xs">헤더 표시</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedGroupConfigPanel.displayName = "UnifiedGroupConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedGroupConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedHierarchy 설정 패널
|
||||||
|
* 통합 계층 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface UnifiedHierarchyConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedHierarchyConfigPanel: React.FC<UnifiedHierarchyConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 계층 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">계층 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.hierarchyType || config.type || "tree"}
|
||||||
|
onValueChange={(value) => updateConfig("hierarchyType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tree">트리</SelectItem>
|
||||||
|
<SelectItem value="org-chart">조직도</SelectItem>
|
||||||
|
<SelectItem value="bom">BOM (Bill of Materials)</SelectItem>
|
||||||
|
<SelectItem value="cascading">연쇄 선택박스</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 뷰 모드 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.viewMode || "tree"}
|
||||||
|
onValueChange={(value) => updateConfig("viewMode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="방식 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tree">트리뷰</SelectItem>
|
||||||
|
<SelectItem value="table">테이블</SelectItem>
|
||||||
|
<SelectItem value="chart">차트</SelectItem>
|
||||||
|
<SelectItem value="cascading">연쇄 드롭다운</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 데이터 소스 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={config.dataSource || "static"}
|
||||||
|
onValueChange={(value) => updateConfig("dataSource", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">정적 데이터</SelectItem>
|
||||||
|
<SelectItem value="db">데이터베이스</SelectItem>
|
||||||
|
<SelectItem value="api">API</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB 설정 */}
|
||||||
|
{config.dataSource === "db" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={config.tableName || ""}
|
||||||
|
onChange={(e) => updateConfig("tableName", e.target.value)}
|
||||||
|
placeholder="테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">ID 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.idColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("idColumn", e.target.value)}
|
||||||
|
placeholder="id"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">부모 ID 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.parentIdColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("parentIdColumn", e.target.value)}
|
||||||
|
placeholder="parent_id"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">표시 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.labelColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("labelColumn", e.target.value)}
|
||||||
|
placeholder="name"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API 설정 */}
|
||||||
|
{config.dataSource === "api" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">API 엔드포인트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.apiEndpoint || ""}
|
||||||
|
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||||
|
placeholder="/api/hierarchy"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 옵션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">옵션</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최대 레벨</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxLevel || ""}
|
||||||
|
onChange={(e) => updateConfig("maxLevel", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="제한 없음"
|
||||||
|
min="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="draggable"
|
||||||
|
checked={config.draggable || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("draggable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="draggable" className="text-xs">드래그 앤 드롭</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="selectable"
|
||||||
|
checked={config.selectable !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("selectable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="selectable" className="text-xs">선택 가능</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="multiSelect"
|
||||||
|
checked={config.multiSelect || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("multiSelect", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="multiSelect" className="text-xs">다중 선택</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showCheckbox"
|
||||||
|
checked={config.showCheckbox || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showCheckbox", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showCheckbox" className="text-xs">체크박스 표시</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="expandAll"
|
||||||
|
checked={config.expandAll || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("expandAll", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="expandAll" className="text-xs">기본 전체 펼침</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM 전용 설정 */}
|
||||||
|
{config.hierarchyType === "bom" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">BOM 설정</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showQuantity"
|
||||||
|
checked={config.showQuantity !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showQuantity", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="showQuantity" className="text-xs">수량 표시</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">수량 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.quantityColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("quantityColumn", e.target.value)}
|
||||||
|
placeholder="quantity"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 연쇄 선택박스 전용 설정 */}
|
||||||
|
{config.hierarchyType === "cascading" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">연쇄 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">부모 필드</Label>
|
||||||
|
<Input
|
||||||
|
value={config.parentField || ""}
|
||||||
|
onChange={(e) => updateConfig("parentField", e.target.value)}
|
||||||
|
placeholder="부모 필드명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="clearOnParentChange"
|
||||||
|
checked={config.clearOnParentChange !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("clearOnParentChange", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="clearOnParentChange" className="text-xs">부모 변경 시 값 초기화</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedHierarchyConfigPanel.displayName = "UnifiedHierarchyConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedHierarchyConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedInput 설정 패널
|
||||||
|
* 통합 입력 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
interface UnifiedInputConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 입력 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">입력 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.inputType || config.type || "text"}
|
||||||
|
onValueChange={(value) => updateConfig("inputType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="입력 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number">숫자</SelectItem>
|
||||||
|
<SelectItem value="password">비밀번호</SelectItem>
|
||||||
|
<SelectItem value="textarea">여러 줄 텍스트</SelectItem>
|
||||||
|
<SelectItem value="slider">슬라이더</SelectItem>
|
||||||
|
<SelectItem value="color">색상 선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 형식 (텍스트/숫자용) */}
|
||||||
|
{(config.inputType === "text" || !config.inputType) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">입력 형식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.format || "none"}
|
||||||
|
onValueChange={(value) => updateConfig("format", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="형식 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">제한 없음</SelectItem>
|
||||||
|
<SelectItem value="email">이메일</SelectItem>
|
||||||
|
<SelectItem value="tel">전화번호</SelectItem>
|
||||||
|
<SelectItem value="url">URL</SelectItem>
|
||||||
|
<SelectItem value="currency">통화</SelectItem>
|
||||||
|
<SelectItem value="biz_no">사업자번호</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 플레이스홀더 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">플레이스홀더</Label>
|
||||||
|
<Input
|
||||||
|
value={config.placeholder || ""}
|
||||||
|
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||||
|
placeholder="입력 안내 텍스트"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 숫자/슬라이더 전용 설정 */}
|
||||||
|
{(config.inputType === "number" || config.inputType === "slider") && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">최소값</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.min ?? ""}
|
||||||
|
onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">최대값</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.max ?? ""}
|
||||||
|
onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="100"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">단계</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.step ?? ""}
|
||||||
|
onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 여러 줄 텍스트 전용 설정 */}
|
||||||
|
{config.inputType === "textarea" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">줄 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.rows || 3}
|
||||||
|
onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)}
|
||||||
|
min={2}
|
||||||
|
max={20}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 마스크 입력 (선택) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">입력 마스크 (선택)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.mask || ""}
|
||||||
|
onChange={(e) => updateConfig("mask", e.target.value)}
|
||||||
|
placeholder="예: ###-####-####"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
# = 숫자, A = 문자, * = 모든 문자
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedInputConfigPanel.displayName = "UnifiedInputConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedInputConfigPanel;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedLayout 설정 패널
|
||||||
|
* 통합 레이아웃 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface UnifiedLayoutConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedLayoutConfigPanel: React.FC<UnifiedLayoutConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 레이아웃 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">레이아웃 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.layoutType || config.type || "grid"}
|
||||||
|
onValueChange={(value) => updateConfig("layoutType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="grid">그리드</SelectItem>
|
||||||
|
<SelectItem value="split">분할 패널</SelectItem>
|
||||||
|
<SelectItem value="flex">플렉스</SelectItem>
|
||||||
|
<SelectItem value="divider">구분선</SelectItem>
|
||||||
|
<SelectItem value="screen-embed">화면 임베드</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 그리드 설정 */}
|
||||||
|
{(config.layoutType === "grid" || !config.layoutType) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">그리드 설정</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="use12Column"
|
||||||
|
checked={config.use12Column !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("use12Column", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="use12Column" className="text-xs">12컬럼 그리드 시스템 사용</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">컬럼 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(config.columns || 12)}
|
||||||
|
onValueChange={(value) => updateConfig("columns", Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1</SelectItem>
|
||||||
|
<SelectItem value="2">2</SelectItem>
|
||||||
|
<SelectItem value="3">3</SelectItem>
|
||||||
|
<SelectItem value="4">4</SelectItem>
|
||||||
|
<SelectItem value="6">6</SelectItem>
|
||||||
|
<SelectItem value="12">12</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">간격 (px)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.gap || "16"}
|
||||||
|
onChange={(e) => updateConfig("gap", e.target.value)}
|
||||||
|
placeholder="16"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 분할 패널 설정 */}
|
||||||
|
{config.layoutType === "split" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">분할 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">분할 방향</Label>
|
||||||
|
<Select
|
||||||
|
value={config.direction || "horizontal"}
|
||||||
|
onValueChange={(value) => updateConfig("direction", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="horizontal">가로</SelectItem>
|
||||||
|
<SelectItem value="vertical">세로</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">비율 (%)</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.splitRatio?.[0] || 50}
|
||||||
|
onChange={(e) => updateConfig("splitRatio", [Number(e.target.value), 100 - Number(e.target.value)])}
|
||||||
|
placeholder="50"
|
||||||
|
min="10"
|
||||||
|
max="90"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.splitRatio?.[1] || 50}
|
||||||
|
disabled
|
||||||
|
className="h-8 text-xs bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="resizable"
|
||||||
|
checked={config.resizable !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("resizable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="resizable" className="text-xs">크기 조절 가능</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 플렉스 설정 */}
|
||||||
|
{config.layoutType === "flex" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">플렉스 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">방향</Label>
|
||||||
|
<Select
|
||||||
|
value={config.direction || "row"}
|
||||||
|
onValueChange={(value) => updateConfig("direction", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="row">가로</SelectItem>
|
||||||
|
<SelectItem value="column">세로</SelectItem>
|
||||||
|
<SelectItem value="row-reverse">가로 (역순)</SelectItem>
|
||||||
|
<SelectItem value="column-reverse">세로 (역순)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={config.justifyContent || "flex-start"}
|
||||||
|
onValueChange={(value) => updateConfig("justifyContent", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="flex-start">시작</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="flex-end">끝</SelectItem>
|
||||||
|
<SelectItem value="space-between">양끝 정렬</SelectItem>
|
||||||
|
<SelectItem value="space-around">균등 배치</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">교차축 정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={config.alignItems || "stretch"}
|
||||||
|
onValueChange={(value) => updateConfig("alignItems", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="flex-start">시작</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="flex-end">끝</SelectItem>
|
||||||
|
<SelectItem value="stretch">늘리기</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">간격 (px)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.gap || "16"}
|
||||||
|
onChange={(e) => updateConfig("gap", e.target.value)}
|
||||||
|
placeholder="16"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wrap"
|
||||||
|
checked={config.wrap || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("wrap", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="wrap" className="text-xs">줄바꿈 허용</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면 임베드 설정 */}
|
||||||
|
{config.layoutType === "screen-embed" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">임베드할 화면 ID</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.screenId || ""}
|
||||||
|
onChange={(e) => updateConfig("screenId", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="화면 ID"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedLayoutConfigPanel.displayName = "UnifiedLayoutConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedLayoutConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedList 설정 패널
|
||||||
|
* 통합 목록 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface UnifiedListConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 관리
|
||||||
|
const columns = config.columns || [];
|
||||||
|
|
||||||
|
const addColumn = () => {
|
||||||
|
const newColumns = [...columns, { key: "", title: "", width: "" }];
|
||||||
|
updateConfig("columns", newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateColumn = (index: number, field: string, value: string) => {
|
||||||
|
const newColumns = [...columns];
|
||||||
|
newColumns[index] = { ...newColumns[index], [field]: value };
|
||||||
|
updateConfig("columns", newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeColumn = (index: number) => {
|
||||||
|
const newColumns = columns.filter((_: any, i: number) => i !== index);
|
||||||
|
updateConfig("columns", newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 뷰 모드 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.viewMode || "table"}
|
||||||
|
onValueChange={(value) => updateConfig("viewMode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="방식 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="table">테이블</SelectItem>
|
||||||
|
<SelectItem value="card">카드</SelectItem>
|
||||||
|
<SelectItem value="kanban">칸반</SelectItem>
|
||||||
|
<SelectItem value="list">리스트</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 데이터 소스 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={config.source || "static"}
|
||||||
|
onValueChange={(value) => updateConfig("source", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">정적 데이터</SelectItem>
|
||||||
|
<SelectItem value="db">데이터베이스</SelectItem>
|
||||||
|
<SelectItem value="api">API</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB 설정 */}
|
||||||
|
{config.source === "db" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={config.tableName || ""}
|
||||||
|
onChange={(e) => updateConfig("tableName", e.target.value)}
|
||||||
|
placeholder="테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API 설정 */}
|
||||||
|
{config.source === "api" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">API 엔드포인트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.apiEndpoint || ""}
|
||||||
|
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||||
|
placeholder="/api/list"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 컬럼 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">컬럼 설정</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addColumn}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{columns.map((column: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={column.key || ""}
|
||||||
|
onChange={(e) => updateColumn(index, "key", e.target.value)}
|
||||||
|
placeholder="키"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={column.title || ""}
|
||||||
|
onChange={(e) => updateColumn(index, "title", e.target.value)}
|
||||||
|
placeholder="제목"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={column.width || ""}
|
||||||
|
onChange={(e) => updateColumn(index, "width", e.target.value)}
|
||||||
|
placeholder="너비"
|
||||||
|
className="h-7 text-xs w-16"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeColumn(index)}
|
||||||
|
className="h-7 w-7 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{columns.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-2">
|
||||||
|
컬럼을 추가해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 기능 옵션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">기능 옵션</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="sortable"
|
||||||
|
checked={config.sortable !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("sortable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="sortable" className="text-xs">정렬 기능</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="pagination"
|
||||||
|
checked={config.pagination !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("pagination", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="pagination" className="text-xs">페이지네이션</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="searchable"
|
||||||
|
checked={config.searchable || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("searchable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="searchable" className="text-xs">검색 기능</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="editable"
|
||||||
|
checked={config.editable || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("editable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="editable" className="text-xs">인라인 편집</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지 크기 */}
|
||||||
|
{config.pagination !== false && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">페이지당 행 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(config.pageSize || 10)}
|
||||||
|
onValueChange={(value) => updateConfig("pageSize", Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5개</SelectItem>
|
||||||
|
<SelectItem value="10">10개</SelectItem>
|
||||||
|
<SelectItem value="20">20개</SelectItem>
|
||||||
|
<SelectItem value="50">50개</SelectItem>
|
||||||
|
<SelectItem value="100">100개</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedListConfigPanel.displayName = "UnifiedListConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedListConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedMedia 설정 패널
|
||||||
|
* 통합 미디어 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React 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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface UnifiedMediaConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedMediaConfigPanel: React.FC<UnifiedMediaConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 미디어 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">미디어 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.mediaType || config.type || "image"}
|
||||||
|
onValueChange={(value) => updateConfig("mediaType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="file">파일</SelectItem>
|
||||||
|
<SelectItem value="image">이미지</SelectItem>
|
||||||
|
<SelectItem value="video">비디오</SelectItem>
|
||||||
|
<SelectItem value="audio">오디오</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 허용 파일 형식 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">허용 파일 형식</Label>
|
||||||
|
<Input
|
||||||
|
value={config.accept || ""}
|
||||||
|
onChange={(e) => updateConfig("accept", e.target.value)}
|
||||||
|
placeholder="예: .jpg,.png,.pdf"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
쉼표로 구분. 예: .jpg,.png,.gif 또는 image/*
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최대 파일 크기 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">최대 파일 크기 (MB)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxSize || ""}
|
||||||
|
onChange={(e) => updateConfig("maxSize", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="10"
|
||||||
|
min="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최대 파일 수 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">최대 파일 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxFiles || ""}
|
||||||
|
onChange={(e) => updateConfig("maxFiles", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="제한 없음"
|
||||||
|
min="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 옵션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">옵션</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="multiple"
|
||||||
|
checked={config.multiple || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="multiple" className="text-xs">다중 파일 업로드</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="preview"
|
||||||
|
checked={config.preview !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("preview", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="preview" className="text-xs">미리보기 표시</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="dragDrop"
|
||||||
|
checked={config.dragDrop !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("dragDrop", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="dragDrop" className="text-xs">드래그 앤 드롭</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 전용 설정 */}
|
||||||
|
{config.mediaType === "image" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">이미지 설정</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최대 너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxWidth || ""}
|
||||||
|
onChange={(e) => updateConfig("maxWidth", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="자동"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최대 높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxHeight || ""}
|
||||||
|
onChange={(e) => updateConfig("maxHeight", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="자동"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="crop"
|
||||||
|
checked={config.crop || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("crop", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="crop" className="text-xs">자르기 기능</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 비디오/오디오 전용 설정 */}
|
||||||
|
{(config.mediaType === "video" || config.mediaType === "audio") && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">플레이어 설정</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autoplay"
|
||||||
|
checked={config.autoplay || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("autoplay", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="autoplay" className="text-xs">자동 재생</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="controls"
|
||||||
|
checked={config.controls !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("controls", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="controls" className="text-xs">컨트롤 표시</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="loop"
|
||||||
|
checked={config.loop || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("loop", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="loop" className="text-xs">반복 재생</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedMediaConfigPanel.displayName = "UnifiedMediaConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedMediaConfigPanel;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedSelect 설정 패널
|
||||||
|
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface ColumnOption {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedSelectConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedSelectConfigPanel: React.FC<UnifiedSelectConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 엔티티 테이블의 컬럼 목록
|
||||||
|
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
|
||||||
|
// 설정 업데이트 핸들러
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엔티티 테이블 변경 시 컬럼 목록 조회
|
||||||
|
const loadEntityColumns = useCallback(async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setEntityColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
|
||||||
|
const data = response.data.data || response.data;
|
||||||
|
const columns = data.columns || data || [];
|
||||||
|
|
||||||
|
const columnOptions: ColumnOption[] = columns.map((col: any) => {
|
||||||
|
const name = col.columnName || col.column_name || col.name;
|
||||||
|
// displayName 우선 사용
|
||||||
|
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnName: name,
|
||||||
|
columnLabel: label,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setEntityColumns(columnOptions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
setEntityColumns([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 엔티티 테이블이 변경되면 컬럼 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.source === "entity" && config.entityTable) {
|
||||||
|
loadEntityColumns(config.entityTable);
|
||||||
|
}
|
||||||
|
}, [config.source, config.entityTable, loadEntityColumns]);
|
||||||
|
|
||||||
|
// 정적 옵션 관리
|
||||||
|
const options = config.options || [];
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
const newOptions = [...options, { value: "", label: "" }];
|
||||||
|
updateConfig("options", newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOption = (index: number, field: "value" | "label", value: string) => {
|
||||||
|
const newOptions = [...options];
|
||||||
|
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||||
|
updateConfig("options", newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
const newOptions = options.filter((_: any, i: number) => i !== index);
|
||||||
|
updateConfig("options", newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 선택 모드 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">선택 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.mode || "dropdown"}
|
||||||
|
onValueChange={(value) => updateConfig("mode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="dropdown">드롭다운</SelectItem>
|
||||||
|
<SelectItem value="radio">라디오 버튼</SelectItem>
|
||||||
|
<SelectItem value="check">체크박스</SelectItem>
|
||||||
|
<SelectItem value="tag">태그 선택</SelectItem>
|
||||||
|
<SelectItem value="toggle">토글 스위치</SelectItem>
|
||||||
|
<SelectItem value="swap">스왑 선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 데이터 소스 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={config.source || "static"}
|
||||||
|
onValueChange={(value) => updateConfig("source", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">정적 옵션</SelectItem>
|
||||||
|
<SelectItem value="code">공통 코드</SelectItem>
|
||||||
|
<SelectItem value="db">데이터베이스</SelectItem>
|
||||||
|
<SelectItem value="api">API</SelectItem>
|
||||||
|
<SelectItem value="entity">엔티티</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정적 옵션 관리 */}
|
||||||
|
{config.source === "static" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">옵션 목록</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addOption}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{options.map((option: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={option.value || ""}
|
||||||
|
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={option.label || ""}
|
||||||
|
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||||
|
placeholder="표시 텍스트"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
className="h-7 w-7 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{options.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-2">
|
||||||
|
옵션을 추가해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 공통 코드 설정 */}
|
||||||
|
{config.source === "code" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">코드 그룹</Label>
|
||||||
|
<Input
|
||||||
|
value={config.codeGroup || ""}
|
||||||
|
onChange={(e) => updateConfig("codeGroup", e.target.value)}
|
||||||
|
placeholder="공통 코드 그룹명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DB 설정 */}
|
||||||
|
{config.source === "db" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={config.table || ""}
|
||||||
|
onChange={(e) => updateConfig("table", e.target.value)}
|
||||||
|
placeholder="테이블명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">값 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.valueColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("valueColumn", e.target.value)}
|
||||||
|
placeholder="id"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.labelColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("labelColumn", e.target.value)}
|
||||||
|
placeholder="name"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API 설정 */}
|
||||||
|
{config.source === "api" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">API 엔드포인트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.apiEndpoint || ""}
|
||||||
|
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||||
|
placeholder="/api/options"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 엔티티(참조 테이블) 설정 */}
|
||||||
|
{config.source === "entity" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">참조 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.entityTable || ""}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
placeholder="테이블 타입 관리에서 설정"
|
||||||
|
className="h-8 text-xs bg-muted"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
조인할 테이블명 (테이블 타입 관리에서 설정된 경우 자동 입력됨)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 로딩 중 표시 */}
|
||||||
|
{loadingColumns && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
컬럼 목록 로딩 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">값 컬럼 (코드)</Label>
|
||||||
|
{entityColumns.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={config.entityValueColumn || ""}
|
||||||
|
onValueChange={(value) => updateConfig("entityValueColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entityColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={config.entityValueColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("entityValueColumn", e.target.value)}
|
||||||
|
placeholder="id"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground">저장될 값</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 컬럼</Label>
|
||||||
|
{entityColumns.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={config.entityLabelColumn || ""}
|
||||||
|
onValueChange={(value) => updateConfig("entityLabelColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entityColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={config.entityLabelColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("entityLabelColumn", e.target.value)}
|
||||||
|
placeholder="name"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground">화면에 표시될 값</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼이 없는 경우 안내 */}
|
||||||
|
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
||||||
|
<p className="text-[10px] text-amber-600">
|
||||||
|
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 추가 옵션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">추가 옵션</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="multiple"
|
||||||
|
checked={config.multiple || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="multiple" className="text-xs">다중 선택 허용</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="searchable"
|
||||||
|
checked={config.searchable || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("searchable", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="searchable" className="text-xs">검색 기능</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowClear"
|
||||||
|
checked={config.allowClear !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="allowClear" className="text-xs">값 초기화 허용</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다중 선택 시 최대 개수 */}
|
||||||
|
{config.multiple && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">최대 선택 개수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxSelect ?? ""}
|
||||||
|
onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="제한 없음"
|
||||||
|
min="1"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedSelectConfigPanel.displayName = "UnifiedSelectConfigPanel";
|
||||||
|
|
||||||
|
export default UnifiedSelectConfigPanel;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Unified 컴포넌트 설정 패널 인덱스
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { UnifiedInputConfigPanel } from "./UnifiedInputConfigPanel";
|
||||||
|
export { UnifiedSelectConfigPanel } from "./UnifiedSelectConfigPanel";
|
||||||
|
export { UnifiedDateConfigPanel } from "./UnifiedDateConfigPanel";
|
||||||
|
export { UnifiedListConfigPanel } from "./UnifiedListConfigPanel";
|
||||||
|
export { UnifiedLayoutConfigPanel } from "./UnifiedLayoutConfigPanel";
|
||||||
|
export { UnifiedGroupConfigPanel } from "./UnifiedGroupConfigPanel";
|
||||||
|
export { UnifiedMediaConfigPanel } from "./UnifiedMediaConfigPanel";
|
||||||
|
export { UnifiedBizConfigPanel } from "./UnifiedBizConfigPanel";
|
||||||
|
export { UnifiedHierarchyConfigPanel } from "./UnifiedHierarchyConfigPanel";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* Unified Components 모듈 인덱스
|
||||||
|
*
|
||||||
|
* 10개의 통합 컴포넌트 시스템
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Phase 1 컴포넌트
|
||||||
|
export { UnifiedInput } from "./UnifiedInput";
|
||||||
|
export { UnifiedSelect } from "./UnifiedSelect";
|
||||||
|
export { UnifiedDate } from "./UnifiedDate";
|
||||||
|
|
||||||
|
// Phase 2 컴포넌트
|
||||||
|
export { UnifiedList } from "./UnifiedList";
|
||||||
|
export { UnifiedLayout } from "./UnifiedLayout";
|
||||||
|
export { UnifiedGroup } from "./UnifiedGroup";
|
||||||
|
|
||||||
|
// Phase 3 컴포넌트
|
||||||
|
export { UnifiedMedia } from "./UnifiedMedia";
|
||||||
|
export { UnifiedBiz } from "./UnifiedBiz";
|
||||||
|
export { UnifiedHierarchy } from "./UnifiedHierarchy";
|
||||||
|
|
||||||
|
// UnifiedText는 UnifiedInput의 textarea 모드로 대체 가능
|
||||||
|
|
||||||
|
// 렌더러
|
||||||
|
export { UnifiedComponentRenderer } from "./UnifiedComponentRenderer";
|
||||||
|
|
||||||
|
// 설정 패널
|
||||||
|
export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel";
|
||||||
|
|
||||||
|
// 데모 컴포넌트
|
||||||
|
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
|
||||||
|
|
||||||
|
// 폼 컨텍스트
|
||||||
|
export {
|
||||||
|
UnifiedFormProvider,
|
||||||
|
useUnifiedForm,
|
||||||
|
useUnifiedField,
|
||||||
|
useCascadingOptions,
|
||||||
|
} from "./UnifiedFormContext";
|
||||||
|
|
||||||
|
// 설정 UI 패널
|
||||||
|
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
|
||||||
|
|
||||||
|
// 타입 re-export
|
||||||
|
export type {
|
||||||
|
// 공통 타입
|
||||||
|
UnifiedComponentType,
|
||||||
|
UnifiedBaseProps,
|
||||||
|
ConditionalConfig,
|
||||||
|
AutoFillConfig,
|
||||||
|
CascadingConfig,
|
||||||
|
MutualExclusionConfig,
|
||||||
|
|
||||||
|
// UnifiedInput 타입
|
||||||
|
UnifiedInputType,
|
||||||
|
UnifiedInputFormat,
|
||||||
|
UnifiedInputConfig,
|
||||||
|
UnifiedInputProps,
|
||||||
|
|
||||||
|
// UnifiedSelect 타입
|
||||||
|
UnifiedSelectMode,
|
||||||
|
UnifiedSelectSource,
|
||||||
|
SelectOption,
|
||||||
|
UnifiedSelectConfig,
|
||||||
|
UnifiedSelectProps,
|
||||||
|
|
||||||
|
// UnifiedDate 타입
|
||||||
|
UnifiedDateType,
|
||||||
|
UnifiedDateConfig,
|
||||||
|
UnifiedDateProps,
|
||||||
|
|
||||||
|
// UnifiedList 타입
|
||||||
|
UnifiedListViewMode,
|
||||||
|
ListColumn,
|
||||||
|
UnifiedListConfig,
|
||||||
|
UnifiedListProps,
|
||||||
|
|
||||||
|
// UnifiedLayout 타입
|
||||||
|
UnifiedLayoutType,
|
||||||
|
UnifiedLayoutConfig,
|
||||||
|
UnifiedLayoutProps,
|
||||||
|
|
||||||
|
// UnifiedGroup 타입
|
||||||
|
UnifiedGroupType,
|
||||||
|
TabItem,
|
||||||
|
UnifiedGroupConfig,
|
||||||
|
UnifiedGroupProps,
|
||||||
|
|
||||||
|
// UnifiedMedia 타입
|
||||||
|
UnifiedMediaType,
|
||||||
|
UnifiedMediaConfig,
|
||||||
|
UnifiedMediaProps,
|
||||||
|
|
||||||
|
// UnifiedBiz 타입
|
||||||
|
UnifiedBizType,
|
||||||
|
UnifiedBizConfig,
|
||||||
|
UnifiedBizProps,
|
||||||
|
|
||||||
|
// UnifiedHierarchy 타입
|
||||||
|
UnifiedHierarchyType,
|
||||||
|
UnifiedHierarchyViewMode,
|
||||||
|
HierarchyNode,
|
||||||
|
UnifiedHierarchyConfig,
|
||||||
|
UnifiedHierarchyProps,
|
||||||
|
|
||||||
|
// 통합 Props
|
||||||
|
UnifiedComponentProps,
|
||||||
|
} from "@/types/unified-components";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified 컴포넌트 레지스트리 등록
|
||||||
|
*
|
||||||
|
* 9개의 Unified 컴포넌트를 ComponentRegistry에 등록합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||||
|
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||||
|
import { WebType } from "@/types/screen";
|
||||||
|
|
||||||
|
// 실제 컴포넌트 import
|
||||||
|
import { UnifiedInput } from "./UnifiedInput";
|
||||||
|
import { UnifiedSelect } from "./UnifiedSelect";
|
||||||
|
import { UnifiedDate } from "./UnifiedDate";
|
||||||
|
import { UnifiedList } from "./UnifiedList";
|
||||||
|
import { UnifiedLayout } from "./UnifiedLayout";
|
||||||
|
import { UnifiedGroup } from "./UnifiedGroup";
|
||||||
|
import { UnifiedMedia } from "./UnifiedMedia";
|
||||||
|
import { UnifiedBiz } from "./UnifiedBiz";
|
||||||
|
import { UnifiedHierarchy } from "./UnifiedHierarchy";
|
||||||
|
|
||||||
|
// 설정 패널 import
|
||||||
|
import { UnifiedInputConfigPanel } from "./config-panels/UnifiedInputConfigPanel";
|
||||||
|
import { UnifiedSelectConfigPanel } from "./config-panels/UnifiedSelectConfigPanel";
|
||||||
|
import { UnifiedDateConfigPanel } from "./config-panels/UnifiedDateConfigPanel";
|
||||||
|
import { UnifiedListConfigPanel } from "./config-panels/UnifiedListConfigPanel";
|
||||||
|
import { UnifiedLayoutConfigPanel } from "./config-panels/UnifiedLayoutConfigPanel";
|
||||||
|
import { UnifiedGroupConfigPanel } from "./config-panels/UnifiedGroupConfigPanel";
|
||||||
|
import { UnifiedMediaConfigPanel } from "./config-panels/UnifiedMediaConfigPanel";
|
||||||
|
import { UnifiedBizConfigPanel } from "./config-panels/UnifiedBizConfigPanel";
|
||||||
|
import { UnifiedHierarchyConfigPanel } from "./config-panels/UnifiedHierarchyConfigPanel";
|
||||||
|
|
||||||
|
// Unified 컴포넌트 정의
|
||||||
|
const unifiedComponentDefinitions: ComponentDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "unified-input",
|
||||||
|
name: "통합 입력",
|
||||||
|
description: "텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 다양한 입력 타입을 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "text" as WebType,
|
||||||
|
component: UnifiedInput as any,
|
||||||
|
tags: ["input", "text", "number", "password", "slider", "color", "unified"],
|
||||||
|
defaultSize: { width: 200, height: 40 },
|
||||||
|
configPanel: UnifiedInputConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
inputType: "text",
|
||||||
|
format: "none",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-select",
|
||||||
|
name: "통합 선택",
|
||||||
|
description: "드롭다운, 라디오, 체크박스, 태그, 토글 등 다양한 선택 방식을 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "select" as WebType,
|
||||||
|
component: UnifiedSelect as any,
|
||||||
|
tags: ["select", "dropdown", "radio", "checkbox", "toggle", "unified"],
|
||||||
|
defaultSize: { width: 200, height: 40 },
|
||||||
|
configPanel: UnifiedSelectConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
mode: "dropdown",
|
||||||
|
source: "static",
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-date",
|
||||||
|
name: "통합 날짜",
|
||||||
|
description: "날짜, 시간, 날짜시간, 날짜 범위 등을 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "date" as WebType,
|
||||||
|
component: UnifiedDate as any,
|
||||||
|
tags: ["date", "time", "datetime", "datepicker", "unified"],
|
||||||
|
defaultSize: { width: 200, height: 40 },
|
||||||
|
configPanel: UnifiedDateConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
dateType: "date",
|
||||||
|
format: "YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-list",
|
||||||
|
name: "통합 목록",
|
||||||
|
description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식을 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "list" as WebType,
|
||||||
|
component: UnifiedList as any,
|
||||||
|
tags: ["list", "table", "card", "kanban", "data", "unified"],
|
||||||
|
defaultSize: { width: 600, height: 400 },
|
||||||
|
configPanel: UnifiedListConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
viewMode: "table",
|
||||||
|
source: "static",
|
||||||
|
columns: [],
|
||||||
|
pagination: true,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-layout",
|
||||||
|
name: "통합 레이아웃",
|
||||||
|
description: "그리드, 분할 패널, 플렉스 등 다양한 레이아웃 구조를 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "container" as WebType,
|
||||||
|
component: UnifiedLayout as any,
|
||||||
|
tags: ["layout", "grid", "split", "flex", "container", "unified"],
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
configPanel: UnifiedLayoutConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
layoutType: "grid",
|
||||||
|
columns: 2,
|
||||||
|
gap: "16",
|
||||||
|
use12Column: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-group",
|
||||||
|
name: "통합 그룹",
|
||||||
|
description: "탭, 아코디언, 섹션, 모달 등 그룹 요소를 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "group" as WebType,
|
||||||
|
component: UnifiedGroup as any,
|
||||||
|
tags: ["group", "tabs", "accordion", "section", "modal", "unified"],
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
configPanel: UnifiedGroupConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
groupType: "section",
|
||||||
|
title: "",
|
||||||
|
collapsible: false,
|
||||||
|
defaultOpen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-media",
|
||||||
|
name: "통합 미디어",
|
||||||
|
description: "이미지, 비디오, 오디오, 파일 업로드 등을 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "file" as WebType,
|
||||||
|
component: UnifiedMedia as any,
|
||||||
|
tags: ["media", "image", "video", "audio", "file", "upload", "unified"],
|
||||||
|
defaultSize: { width: 300, height: 200 },
|
||||||
|
configPanel: UnifiedMediaConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
mediaType: "image",
|
||||||
|
multiple: false,
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-biz",
|
||||||
|
name: "통합 비즈니스",
|
||||||
|
description: "플로우, 랙, 채번규칙 등 비즈니스 기능을 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "custom" as WebType,
|
||||||
|
component: UnifiedBiz as any,
|
||||||
|
tags: ["business", "flow", "rack", "numbering", "category", "unified"],
|
||||||
|
defaultSize: { width: 500, height: 400 },
|
||||||
|
configPanel: UnifiedBizConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
bizType: "flow",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unified-hierarchy",
|
||||||
|
name: "통합 계층",
|
||||||
|
description: "트리, 조직도, BOM, 연쇄 선택박스 등 계층 구조를 지원하는 통합 컴포넌트",
|
||||||
|
category: ComponentCategory.UNIFIED,
|
||||||
|
webType: "tree" as WebType,
|
||||||
|
component: UnifiedHierarchy as any,
|
||||||
|
tags: ["hierarchy", "tree", "org-chart", "bom", "cascading", "unified"],
|
||||||
|
defaultSize: { width: 400, height: 400 },
|
||||||
|
configPanel: UnifiedHierarchyConfigPanel as any,
|
||||||
|
defaultConfig: {
|
||||||
|
hierarchyType: "tree",
|
||||||
|
viewMode: "tree",
|
||||||
|
dataSource: "static",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified 컴포넌트들을 ComponentRegistry에 등록
|
||||||
|
*/
|
||||||
|
export function registerUnifiedComponents(): void {
|
||||||
|
for (const definition of unifiedComponentDefinitions) {
|
||||||
|
try {
|
||||||
|
// 이미 등록되어 있으면 스킵
|
||||||
|
if (ComponentRegistry.getComponent(definition.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ComponentRegistry.registerComponent(definition);
|
||||||
|
console.log(`✅ Unified 컴포넌트 등록: ${definition.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Unified 컴포넌트 등록 실패: ${definition.id}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default registerUnifiedComponents;
|
||||||
|
|
@ -139,3 +139,4 @@ export const useActiveTabOptional = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,3 +196,4 @@ export function applyAutoFillToFormData(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -307,8 +307,10 @@ export const tableTypeApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// 테이블 컬럼 정보 조회 (모든 컬럼)
|
// 테이블 컬럼 정보 조회 (모든 컬럼)
|
||||||
getColumns: async (tableName: string): Promise<any[]> => {
|
getColumns: async (tableName: string, bustCache: boolean = false): Promise<any[]> => {
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
|
// bustCache: 캐시 우회 (테이블 타입 변경 후 즉시 반영 필요 시)
|
||||||
|
const cacheParam = bustCache ? `&_t=${Date.now()}` : "";
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000${cacheParam}`);
|
||||||
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
|
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
|
||||||
const data = response.data.data || response.data;
|
const data = response.data.data || response.data;
|
||||||
return data.columns || data || [];
|
return data.columns || data || [];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,19 @@ import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
||||||
import { ComponentRegistry } from "./ComponentRegistry";
|
import { ComponentRegistry } from "./ComponentRegistry";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
|
// Unified 컴포넌트 import
|
||||||
|
import {
|
||||||
|
UnifiedInput,
|
||||||
|
UnifiedSelect,
|
||||||
|
UnifiedDate,
|
||||||
|
UnifiedList,
|
||||||
|
UnifiedLayout,
|
||||||
|
UnifiedGroup,
|
||||||
|
UnifiedMedia,
|
||||||
|
UnifiedBiz,
|
||||||
|
UnifiedHierarchy,
|
||||||
|
} from "@/components/unified";
|
||||||
|
|
||||||
// 컴포넌트 렌더러 인터페이스
|
// 컴포넌트 렌더러 인터페이스
|
||||||
export interface ComponentRenderer {
|
export interface ComponentRenderer {
|
||||||
(props: {
|
(props: {
|
||||||
|
|
@ -151,6 +164,206 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||||
const componentType = (component as any).componentType || component.type;
|
const componentType = (component as any).componentType || component.type;
|
||||||
|
|
||||||
|
// 🆕 Unified 컴포넌트 처리
|
||||||
|
if (componentType?.startsWith("unified-")) {
|
||||||
|
const unifiedType = componentType as string;
|
||||||
|
const config = (component as any).componentConfig || {};
|
||||||
|
const fieldName = (component as any).columnName || component.id;
|
||||||
|
const currentValue = props.formData?.[fieldName];
|
||||||
|
|
||||||
|
const handleChange = (value: any) => {
|
||||||
|
if (props.onFormDataChange) {
|
||||||
|
props.onFormDataChange(fieldName, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공통 props
|
||||||
|
const commonProps = {
|
||||||
|
id: component.id,
|
||||||
|
label: (component as any).label,
|
||||||
|
required: (component as any).required,
|
||||||
|
readonly: (component as any).readonly,
|
||||||
|
disabled: (component as any).disabled || props.disabledFields?.includes(fieldName),
|
||||||
|
value: currentValue,
|
||||||
|
onChange: handleChange,
|
||||||
|
tableName: (component as any).tableName || props.tableName,
|
||||||
|
columnName: fieldName,
|
||||||
|
style: component.style,
|
||||||
|
size: component.size,
|
||||||
|
position: component.position,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (unifiedType) {
|
||||||
|
case "unified-input":
|
||||||
|
return (
|
||||||
|
<UnifiedInput
|
||||||
|
unifiedType="UnifiedInput"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.inputType || config.type || "text",
|
||||||
|
format: config.format,
|
||||||
|
placeholder: config.placeholder,
|
||||||
|
mask: config.mask,
|
||||||
|
min: config.min,
|
||||||
|
max: config.max,
|
||||||
|
step: config.step,
|
||||||
|
buttonText: config.buttonText,
|
||||||
|
buttonVariant: config.buttonVariant,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-select":
|
||||||
|
return (
|
||||||
|
<UnifiedSelect
|
||||||
|
unifiedType="UnifiedSelect"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
mode: config.mode || "dropdown",
|
||||||
|
source: config.source || "static",
|
||||||
|
options: config.options || [],
|
||||||
|
multiple: config.multiple,
|
||||||
|
searchable: config.searchable,
|
||||||
|
codeGroup: config.codeGroup,
|
||||||
|
codeCategory: config.codeCategory,
|
||||||
|
table: config.table,
|
||||||
|
valueColumn: config.valueColumn,
|
||||||
|
labelColumn: config.labelColumn,
|
||||||
|
// 엔티티(참조 테이블) 관련 속성
|
||||||
|
entityTable: config.entityTable,
|
||||||
|
entityValueColumn: config.entityValueColumn,
|
||||||
|
entityLabelColumn: config.entityLabelColumn,
|
||||||
|
entityValueField: config.entityValueField,
|
||||||
|
entityLabelField: config.entityLabelField,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-date":
|
||||||
|
return (
|
||||||
|
<UnifiedDate
|
||||||
|
unifiedType="UnifiedDate"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.dateType || config.type || "date",
|
||||||
|
format: config.format,
|
||||||
|
range: config.range,
|
||||||
|
minDate: config.minDate,
|
||||||
|
maxDate: config.maxDate,
|
||||||
|
showToday: config.showToday,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-list":
|
||||||
|
return (
|
||||||
|
<UnifiedList
|
||||||
|
unifiedType="UnifiedList"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
viewMode: config.viewMode || "table",
|
||||||
|
columns: config.columns || [],
|
||||||
|
source: config.source || "static",
|
||||||
|
sortable: config.sortable,
|
||||||
|
pagination: config.pagination,
|
||||||
|
searchable: config.searchable,
|
||||||
|
editable: config.editable,
|
||||||
|
}}
|
||||||
|
data={config.data || []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-layout":
|
||||||
|
return (
|
||||||
|
<UnifiedLayout
|
||||||
|
unifiedType="UnifiedLayout"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.layoutType || config.type || "grid",
|
||||||
|
columns: config.columns,
|
||||||
|
gap: config.gap,
|
||||||
|
direction: config.direction,
|
||||||
|
use12Column: config.use12Column,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</UnifiedLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-group":
|
||||||
|
return (
|
||||||
|
<UnifiedGroup
|
||||||
|
unifiedType="UnifiedGroup"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.groupType || config.type || "section",
|
||||||
|
collapsible: config.collapsible,
|
||||||
|
defaultOpen: config.defaultOpen,
|
||||||
|
tabs: config.tabs || [],
|
||||||
|
showHeader: config.showHeader,
|
||||||
|
}}
|
||||||
|
title={config.title}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</UnifiedGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-media":
|
||||||
|
return (
|
||||||
|
<UnifiedMedia
|
||||||
|
unifiedType="UnifiedMedia"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.mediaType || config.type || "image",
|
||||||
|
accept: config.accept,
|
||||||
|
maxSize: config.maxSize,
|
||||||
|
multiple: config.multiple,
|
||||||
|
preview: config.preview,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-biz":
|
||||||
|
return (
|
||||||
|
<UnifiedBiz
|
||||||
|
unifiedType="UnifiedBiz"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.bizType || config.type || "flow",
|
||||||
|
flowConfig: config.flowConfig,
|
||||||
|
rackConfig: config.rackConfig,
|
||||||
|
numberingConfig: config.numberingConfig,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "unified-hierarchy":
|
||||||
|
return (
|
||||||
|
<UnifiedHierarchy
|
||||||
|
unifiedType="UnifiedHierarchy"
|
||||||
|
{...commonProps}
|
||||||
|
config={{
|
||||||
|
type: config.hierarchyType || config.type || "tree",
|
||||||
|
viewMode: config.viewMode || "tree",
|
||||||
|
dataSource: config.dataSource || "static",
|
||||||
|
maxLevel: config.maxLevel,
|
||||||
|
draggable: config.draggable,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-amber-300 bg-amber-50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2 text-sm font-medium text-amber-600">Unified 컴포넌트</div>
|
||||||
|
<div className="text-xs text-amber-500">알 수 없는 타입: {unifiedType}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
|
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
|
||||||
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
|
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
|
||||||
const webType = (component as any).componentConfig?.webType;
|
const webType = (component as any).componentConfig?.webType;
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,14 @@ export function initializeWebTypeRegistry() {
|
||||||
export function initializeRegistries() {
|
export function initializeRegistries() {
|
||||||
initializeWebTypeRegistry();
|
initializeWebTypeRegistry();
|
||||||
|
|
||||||
|
// Unified 컴포넌트 등록
|
||||||
|
try {
|
||||||
|
const { registerUnifiedComponents } = require("@/components/unified/registerUnifiedComponents");
|
||||||
|
registerUnifiedComponents();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("⚠️ Unified 컴포넌트 등록 실패:", error);
|
||||||
|
}
|
||||||
|
|
||||||
// 필요한 경우 버튼 액션 레지스트리도 여기서 초기화
|
// 필요한 경우 버튼 액션 레지스트리도 여기서 초기화
|
||||||
// initializeButtonActionRegistry();
|
// initializeButtonActionRegistry();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* 웹타입을 새로운 컴포넌트 시스템의 컴포넌트 ID로 매핑하는 유틸리티
|
* 웹타입을 Unified 컴포넌트 시스템으로 매핑하는 유틸리티
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface WebTypeMapping {
|
export interface WebTypeMapping {
|
||||||
|
|
@ -8,69 +8,309 @@ export interface WebTypeMapping {
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UnifiedComponentMapping {
|
||||||
|
componentType: string; // unified-input, unified-select, unified-date 등
|
||||||
|
config: Record<string, any>; // 컴포넌트별 기본 설정
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 웹타입 → 컴포넌트 ID 매핑 테이블
|
* 웹타입 → Unified 컴포넌트 매핑 테이블
|
||||||
*/
|
*/
|
||||||
export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
export const WEB_TYPE_UNIFIED_MAPPING: Record<string, UnifiedComponentMapping> = {
|
||||||
// 텍스트 입력
|
// 텍스트 입력 계열 → UnifiedInput
|
||||||
text: "text-input",
|
text: {
|
||||||
email: "text-input",
|
componentType: "unified-input",
|
||||||
password: "text-input",
|
config: { inputType: "text", format: "none" },
|
||||||
tel: "text-input",
|
},
|
||||||
|
email: {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "text", format: "email" },
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "password" },
|
||||||
|
},
|
||||||
|
tel: {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "text", format: "tel" },
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "text", format: "url" },
|
||||||
|
},
|
||||||
|
textarea: {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "textarea", rows: 3 },
|
||||||
|
},
|
||||||
|
|
||||||
// 숫자 입력
|
// 숫자 입력 → UnifiedInput
|
||||||
number: "number-input",
|
number: {
|
||||||
decimal: "number-input",
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "number" },
|
||||||
|
},
|
||||||
|
decimal: {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "number", step: 0.01 },
|
||||||
|
},
|
||||||
|
|
||||||
// 날짜/시간
|
// 날짜/시간 → UnifiedDate
|
||||||
date: "date-input",
|
date: {
|
||||||
datetime: "date-input",
|
componentType: "unified-date",
|
||||||
time: "date-input",
|
config: { type: "date", format: "YYYY-MM-DD" },
|
||||||
|
},
|
||||||
|
datetime: {
|
||||||
|
componentType: "unified-date",
|
||||||
|
config: { type: "datetime", format: "YYYY-MM-DD HH:mm" },
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
componentType: "unified-date",
|
||||||
|
config: { type: "time", format: "HH:mm" },
|
||||||
|
},
|
||||||
|
|
||||||
// 텍스트 영역
|
// 선택 입력 → UnifiedSelect
|
||||||
textarea: "textarea-basic",
|
select: {
|
||||||
|
componentType: "unified-select",
|
||||||
|
config: { mode: "dropdown", source: "static", options: [] },
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
componentType: "unified-select",
|
||||||
|
config: { mode: "dropdown", source: "static", options: [] },
|
||||||
|
},
|
||||||
|
radio: {
|
||||||
|
componentType: "unified-select",
|
||||||
|
config: { mode: "radio", source: "static", options: [] },
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
componentType: "unified-select",
|
||||||
|
config: { mode: "checkbox", source: "static", options: [] },
|
||||||
|
},
|
||||||
|
boolean: {
|
||||||
|
componentType: "unified-select",
|
||||||
|
config: { mode: "toggle", source: "static" },
|
||||||
|
},
|
||||||
|
|
||||||
// 선택
|
// 코드/참조 → UnifiedSelect (소스: code)
|
||||||
select: "select-basic",
|
code: {
|
||||||
dropdown: "select-basic",
|
componentType: "unified-select",
|
||||||
|
config: { mode: "dropdown", source: "code", codeGroup: "" },
|
||||||
|
},
|
||||||
|
|
||||||
// 체크박스/라디오
|
// 엔티티/참조 테이블 → UnifiedSelect (소스: entity)
|
||||||
checkbox: "checkbox-basic",
|
entity: {
|
||||||
radio: "radio-basic",
|
componentType: "unified-select",
|
||||||
boolean: "toggle-switch",
|
config: { mode: "dropdown", source: "entity", searchable: true },
|
||||||
|
},
|
||||||
|
|
||||||
// 파일
|
// 카테고리 → UnifiedSelect (소스: category)
|
||||||
file: "file-upload",
|
category: {
|
||||||
|
componentType: "unified-select",
|
||||||
|
config: { mode: "dropdown", source: "category" },
|
||||||
|
},
|
||||||
|
|
||||||
// 이미지
|
// 파일/이미지 → UnifiedMedia
|
||||||
image: "image-widget",
|
file: {
|
||||||
img: "image-widget",
|
componentType: "unified-media",
|
||||||
picture: "image-widget",
|
config: { type: "file", multiple: false },
|
||||||
photo: "image-widget",
|
},
|
||||||
|
image: {
|
||||||
|
componentType: "unified-media",
|
||||||
|
config: { type: "image", showPreview: true },
|
||||||
|
},
|
||||||
|
img: {
|
||||||
|
componentType: "unified-media",
|
||||||
|
config: { type: "image", showPreview: true },
|
||||||
|
},
|
||||||
|
|
||||||
// 버튼
|
// 버튼은 Unified 컴포넌트에서 제외 (기존 버튼 시스템 사용)
|
||||||
button: "button-primary",
|
button: {
|
||||||
|
componentType: "button-primary", // 레거시 유지
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
|
||||||
// 기타
|
// 라벨/텍스트 표시 → UnifiedInput (readonly)
|
||||||
label: "text-display",
|
label: {
|
||||||
code: "select-basic", // 코드 타입은 선택상자 사용
|
componentType: "unified-input",
|
||||||
entity: "entity-search-input", // 엔티티 타입은 전용 검색 입력 사용
|
config: { inputType: "text", readonly: true },
|
||||||
category: "select-basic", // 카테고리 타입은 선택상자 사용
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 웹타입을 컴포넌트 ID로 변환
|
* 레거시 매핑 테이블 (하위 호환성)
|
||||||
|
*/
|
||||||
|
export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||||
|
text: "unified-input",
|
||||||
|
email: "unified-input",
|
||||||
|
password: "unified-input",
|
||||||
|
tel: "unified-input",
|
||||||
|
url: "unified-input",
|
||||||
|
number: "unified-input",
|
||||||
|
decimal: "unified-input",
|
||||||
|
textarea: "unified-input",
|
||||||
|
date: "unified-date",
|
||||||
|
datetime: "unified-date",
|
||||||
|
time: "unified-date",
|
||||||
|
select: "unified-select",
|
||||||
|
dropdown: "unified-select",
|
||||||
|
checkbox: "unified-select",
|
||||||
|
radio: "unified-select",
|
||||||
|
boolean: "unified-select",
|
||||||
|
code: "unified-select",
|
||||||
|
entity: "unified-select",
|
||||||
|
category: "unified-select",
|
||||||
|
file: "unified-media",
|
||||||
|
image: "unified-media",
|
||||||
|
img: "unified-media",
|
||||||
|
button: "button-primary",
|
||||||
|
label: "unified-input",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹타입을 Unified 컴포넌트 ID로 변환
|
||||||
*/
|
*/
|
||||||
export function getComponentIdFromWebType(webType: string): string {
|
export function getComponentIdFromWebType(webType: string): string {
|
||||||
const componentId = WEB_TYPE_COMPONENT_MAPPING[webType];
|
const mapping = WEB_TYPE_UNIFIED_MAPPING[webType];
|
||||||
|
|
||||||
if (!componentId) {
|
if (!mapping) {
|
||||||
console.warn(`웹타입 "${webType}"에 대한 컴포넌트 매핑을 찾을 수 없습니다. 기본값 'text-input' 사용`);
|
console.warn(`웹타입 "${webType}"에 대한 Unified 매핑을 찾을 수 없습니다. 기본값 'unified-input' 사용`);
|
||||||
return "text-input"; // 기본값
|
return "unified-input";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`웹타입 "${webType}" → 컴포넌트 "${componentId}" 매핑`);
|
console.log(`웹타입 "${webType}" → Unified 컴포넌트 "${mapping.componentType}" 매핑`);
|
||||||
return componentId;
|
return mapping.componentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹타입에 대한 Unified 컴포넌트 기본 설정 가져오기
|
||||||
|
*/
|
||||||
|
export function getUnifiedConfigFromWebType(webType: string): Record<string, any> {
|
||||||
|
const mapping = WEB_TYPE_UNIFIED_MAPPING[webType];
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
console.warn(`웹타입 "${webType}"에 대한 Unified 설정을 찾을 수 없습니다. 기본 설정 사용`);
|
||||||
|
return { inputType: "text" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...mapping.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹타입에 대한 전체 Unified 매핑 정보 가져오기
|
||||||
|
*/
|
||||||
|
export function getUnifiedMappingFromWebType(webType: string): UnifiedComponentMapping {
|
||||||
|
const mapping = WEB_TYPE_UNIFIED_MAPPING[webType];
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
return {
|
||||||
|
componentType: "unified-input",
|
||||||
|
config: { inputType: "text" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
componentType: mapping.componentType,
|
||||||
|
config: { ...mapping.config },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 정보를 기반으로 Unified 컴포넌트 설정 생성
|
||||||
|
*/
|
||||||
|
export function createUnifiedConfigFromColumn(column: {
|
||||||
|
widgetType: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
codeCategory?: string;
|
||||||
|
inputType?: string;
|
||||||
|
required?: boolean;
|
||||||
|
detailSettings?: string | Record<string, any>; // 테이블 타입 관리의 detail_settings
|
||||||
|
// 직접 저장된 참조 정보 (column_labels 테이블)
|
||||||
|
referenceTable?: string;
|
||||||
|
referenceColumn?: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}): { componentType: string; componentConfig: Record<string, any> } {
|
||||||
|
const mapping = getUnifiedMappingFromWebType(column.widgetType);
|
||||||
|
|
||||||
|
// detailSettings 파싱 (문자열이면 JSON 파싱)
|
||||||
|
let parsedDetailSettings: Record<string, any> = {};
|
||||||
|
if (column.detailSettings) {
|
||||||
|
if (typeof column.detailSettings === "string") {
|
||||||
|
try {
|
||||||
|
parsedDetailSettings = JSON.parse(column.detailSettings);
|
||||||
|
} catch (e) {
|
||||||
|
// JSON이 아닌 문자열일 수 있음 (예: "참조테이블: 회사관리")
|
||||||
|
console.warn("detail_settings 파싱 실패 (문자열일 수 있음):", column.detailSettings);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedDetailSettings = column.detailSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentConfig: Record<string, any> = {
|
||||||
|
...mapping.config,
|
||||||
|
webType: column.widgetType, // 원본 웹타입 보존
|
||||||
|
// DB의 input_type이 있으면 사용, 없으면 매핑에서 가져온 값 유지
|
||||||
|
inputType: column.inputType || mapping.config.inputType || "text",
|
||||||
|
placeholder: parsedDetailSettings.placeholder || column.columnLabel || column.columnName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 코드 타입인 경우 코드 카테고리 추가
|
||||||
|
if (column.widgetType === "code" && column.codeCategory) {
|
||||||
|
componentConfig.codeGroup = column.codeCategory;
|
||||||
|
componentConfig.codeCategory = column.codeCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티 타입인 경우 참조 테이블/컬럼 정보 추가
|
||||||
|
if (column.widgetType === "entity") {
|
||||||
|
componentConfig.searchable = parsedDetailSettings.searchable ?? true;
|
||||||
|
|
||||||
|
// 다양한 필드명 지원 (레거시 호환)
|
||||||
|
// referenceTable / entityTable 둘 다 지원
|
||||||
|
const refTable =
|
||||||
|
parsedDetailSettings.referenceTable ||
|
||||||
|
parsedDetailSettings.entityTable ||
|
||||||
|
column.referenceTable;
|
||||||
|
|
||||||
|
// referenceColumn / entityCodeColumn / entityValueColumn 지원
|
||||||
|
const refColumn =
|
||||||
|
parsedDetailSettings.referenceColumn ||
|
||||||
|
parsedDetailSettings.entityCodeColumn ||
|
||||||
|
parsedDetailSettings.entityValueColumn ||
|
||||||
|
column.referenceColumn ||
|
||||||
|
"id";
|
||||||
|
|
||||||
|
// displayColumn / entityLabelColumn 지원
|
||||||
|
const dispColumn =
|
||||||
|
parsedDetailSettings.displayColumn ||
|
||||||
|
parsedDetailSettings.entityLabelColumn ||
|
||||||
|
column.displayColumn ||
|
||||||
|
"name";
|
||||||
|
|
||||||
|
console.log("🎯 엔티티 매핑:", {
|
||||||
|
columnName: column.columnName,
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
parsedDetailSettings,
|
||||||
|
refTable,
|
||||||
|
refColumn,
|
||||||
|
dispColumn,
|
||||||
|
"column.referenceTable": column.referenceTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refTable) {
|
||||||
|
componentConfig.entityTable = refTable;
|
||||||
|
componentConfig.entityValueColumn = refColumn;
|
||||||
|
componentConfig.entityLabelColumn = dispColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 타입인 경우 추가 설정
|
||||||
|
if (column.widgetType === "category") {
|
||||||
|
componentConfig.searchable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
componentType: mapping.componentType,
|
||||||
|
componentConfig,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -85,23 +325,23 @@ export function getWebTypeFromComponentId(componentId: string): string {
|
||||||
* 지원되는 모든 웹타입 목록 조회
|
* 지원되는 모든 웹타입 목록 조회
|
||||||
*/
|
*/
|
||||||
export function getSupportedWebTypes(): string[] {
|
export function getSupportedWebTypes(): string[] {
|
||||||
return Object.keys(WEB_TYPE_COMPONENT_MAPPING);
|
return Object.keys(WEB_TYPE_UNIFIED_MAPPING);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지원되는 모든 컴포넌트 ID 목록 조회
|
* 지원되는 모든 Unified 컴포넌트 ID 목록 조회
|
||||||
*/
|
*/
|
||||||
export function getSupportedComponentIds(): string[] {
|
export function getSupportedComponentIds(): string[] {
|
||||||
return [...new Set(Object.values(WEB_TYPE_COMPONENT_MAPPING))];
|
return [...new Set(Object.values(WEB_TYPE_UNIFIED_MAPPING).map((m) => m.componentType))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매핑 정보 조회
|
* 매핑 정보 조회
|
||||||
*/
|
*/
|
||||||
export function getWebTypeMappings(): WebTypeMapping[] {
|
export function getWebTypeMappings(): WebTypeMapping[] {
|
||||||
return Object.entries(WEB_TYPE_COMPONENT_MAPPING).map(([webType, componentId]) => ({
|
return Object.entries(WEB_TYPE_UNIFIED_MAPPING).map(([webType, mapping]) => ({
|
||||||
webType,
|
webType,
|
||||||
componentId,
|
componentId: mapping.componentType,
|
||||||
description: `${webType} → ${componentId}`,
|
description: `${webType} → ${mapping.componentType}`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export enum ComponentCategory {
|
||||||
SYSTEM = "system", // 시스템 컴포넌트 (에러 바운더리 등)
|
SYSTEM = "system", // 시스템 컴포넌트 (에러 바운더리 등)
|
||||||
ADMIN = "admin", // 관리자 전용 컴포넌트
|
ADMIN = "admin", // 관리자 전용 컴포넌트
|
||||||
CUSTOM = "custom", // 커스텀 컴포넌트
|
CUSTOM = "custom", // 커스텀 컴포넌트
|
||||||
|
UNIFIED = "unified", // 통합 컴포넌트 (새로운 Unified 컴포넌트 시스템)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -368,4 +369,9 @@ export const COMPONENT_CATEGORIES_INFO = {
|
||||||
description: "사용자 정의 컴포넌트",
|
description: "사용자 정의 컴포넌트",
|
||||||
color: "#059669",
|
color: "#059669",
|
||||||
},
|
},
|
||||||
|
[ComponentCategory.UNIFIED]: {
|
||||||
|
name: "통합",
|
||||||
|
description: "새로운 통합 컴포넌트 시스템",
|
||||||
|
color: "#2563eb",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,24 @@ export interface BaseComponent {
|
||||||
// 반응형 설정
|
// 반응형 설정
|
||||||
responsiveConfig?: ResponsiveComponentConfig;
|
responsiveConfig?: ResponsiveComponentConfig;
|
||||||
responsiveDisplay?: any; // 런타임에 추가되는 임시 필드
|
responsiveDisplay?: any; // 런타임에 추가되는 임시 필드
|
||||||
|
|
||||||
|
// 조건부 표시 설정
|
||||||
|
conditional?: {
|
||||||
|
enabled: boolean;
|
||||||
|
field: string;
|
||||||
|
operator: "=" | "!=" | ">" | "<" | "in" | "notIn" | "isEmpty" | "isNotEmpty";
|
||||||
|
value: unknown;
|
||||||
|
action: "show" | "hide" | "enable" | "disable";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동 입력 설정
|
||||||
|
autoFill?: {
|
||||||
|
enabled: boolean;
|
||||||
|
sourceTable: string;
|
||||||
|
filterColumn: string;
|
||||||
|
userField: "companyCode" | "userId" | "deptCode";
|
||||||
|
displayColumn: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,494 @@
|
||||||
|
/**
|
||||||
|
* Unified 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 10개의 통합 컴포넌트 시스템을 위한 타입 정의
|
||||||
|
* - UnifiedInput
|
||||||
|
* - UnifiedSelect
|
||||||
|
* - UnifiedDate
|
||||||
|
* - UnifiedText
|
||||||
|
* - UnifiedMedia
|
||||||
|
* - UnifiedList
|
||||||
|
* - UnifiedLayout
|
||||||
|
* - UnifiedGroup
|
||||||
|
* - UnifiedBiz
|
||||||
|
* - UnifiedHierarchy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Position, Size, CommonStyle, ValidationRule } from "./unified-core";
|
||||||
|
|
||||||
|
// ===== 공통 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified 컴포넌트 타입
|
||||||
|
*/
|
||||||
|
export type UnifiedComponentType =
|
||||||
|
| "UnifiedInput"
|
||||||
|
| "UnifiedSelect"
|
||||||
|
| "UnifiedDate"
|
||||||
|
| "UnifiedText"
|
||||||
|
| "UnifiedMedia"
|
||||||
|
| "UnifiedList"
|
||||||
|
| "UnifiedLayout"
|
||||||
|
| "UnifiedGroup"
|
||||||
|
| "UnifiedBiz"
|
||||||
|
| "UnifiedHierarchy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 렌더링 설정
|
||||||
|
*/
|
||||||
|
export interface ConditionalConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
field: string; // 참조 필드
|
||||||
|
operator: "=" | "!=" | ">" | "<" | "in" | "notIn" | "isEmpty" | "isNotEmpty";
|
||||||
|
value: unknown;
|
||||||
|
action: "show" | "hide" | "disable" | "enable";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 설정
|
||||||
|
*/
|
||||||
|
export interface AutoFillConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
sourceTable: string;
|
||||||
|
filterColumn: string;
|
||||||
|
userField: "companyCode" | "userId" | "deptCode";
|
||||||
|
displayColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 설정
|
||||||
|
*/
|
||||||
|
export interface CascadingConfig {
|
||||||
|
parentField: string;
|
||||||
|
filterColumn: string;
|
||||||
|
clearOnChange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 설정
|
||||||
|
*/
|
||||||
|
export interface MutualExclusionConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
targetField: string;
|
||||||
|
type: "exclusive" | "inclusive";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 Unified 컴포넌트 속성
|
||||||
|
*/
|
||||||
|
export interface UnifiedBaseProps {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
// 데이터 바인딩
|
||||||
|
tableName?: string;
|
||||||
|
columnName?: string;
|
||||||
|
// 위치 및 크기
|
||||||
|
position?: Position;
|
||||||
|
size?: Size;
|
||||||
|
// 스타일
|
||||||
|
style?: CommonStyle;
|
||||||
|
// 조건부 및 자동화
|
||||||
|
conditional?: ConditionalConfig;
|
||||||
|
autoFill?: AutoFillConfig;
|
||||||
|
// 유효성 검사
|
||||||
|
validation?: ValidationRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedInput =====
|
||||||
|
|
||||||
|
export type UnifiedInputType = "text" | "number" | "password" | "slider" | "color" | "button";
|
||||||
|
export type UnifiedInputFormat = "none" | "email" | "tel" | "url" | "currency" | "biz_no";
|
||||||
|
|
||||||
|
export interface UnifiedInputConfig {
|
||||||
|
type: UnifiedInputType;
|
||||||
|
format?: UnifiedInputFormat;
|
||||||
|
mask?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
// 숫자 전용
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
// 버튼 전용
|
||||||
|
buttonText?: string;
|
||||||
|
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost";
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedInputProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedInput";
|
||||||
|
config: UnifiedInputConfig;
|
||||||
|
value?: string | number;
|
||||||
|
onChange?: (value: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedSelect =====
|
||||||
|
|
||||||
|
export type UnifiedSelectMode = "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||||
|
export type UnifiedSelectSource = "static" | "code" | "db" | "api" | "entity";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedSelectConfig {
|
||||||
|
mode: UnifiedSelectMode;
|
||||||
|
source: UnifiedSelectSource;
|
||||||
|
// 정적 옵션 (source: static)
|
||||||
|
options?: SelectOption[];
|
||||||
|
// 코드 그룹 (source: code)
|
||||||
|
codeGroup?: string;
|
||||||
|
// DB 연결 (source: db)
|
||||||
|
table?: string;
|
||||||
|
valueColumn?: string;
|
||||||
|
labelColumn?: string;
|
||||||
|
filters?: Array<{ column: string; operator: string; value: unknown }>;
|
||||||
|
// 엔티티 연결 (source: entity)
|
||||||
|
entityTable?: string;
|
||||||
|
entityValueField?: string;
|
||||||
|
entityLabelField?: string;
|
||||||
|
// API 연결 (source: api)
|
||||||
|
apiEndpoint?: string;
|
||||||
|
// 공통 옵션
|
||||||
|
searchable?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
maxSelect?: number;
|
||||||
|
allowClear?: boolean;
|
||||||
|
// 연쇄 관계
|
||||||
|
cascading?: CascadingConfig;
|
||||||
|
// 상호 배제
|
||||||
|
mutualExclusion?: MutualExclusionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedSelectProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedSelect";
|
||||||
|
config: UnifiedSelectConfig;
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedDate =====
|
||||||
|
|
||||||
|
export type UnifiedDateType = "date" | "time" | "datetime";
|
||||||
|
|
||||||
|
export interface UnifiedDateConfig {
|
||||||
|
type: UnifiedDateType;
|
||||||
|
format?: string;
|
||||||
|
range?: boolean;
|
||||||
|
minDate?: string;
|
||||||
|
maxDate?: string;
|
||||||
|
showToday?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedDateProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedDate";
|
||||||
|
config: UnifiedDateConfig;
|
||||||
|
value?: string | [string, string]; // 범위 선택 시 튜플
|
||||||
|
onChange?: (value: string | [string, string]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedText =====
|
||||||
|
|
||||||
|
export type UnifiedTextMode = "simple" | "rich" | "code" | "markdown";
|
||||||
|
|
||||||
|
export interface UnifiedTextConfig {
|
||||||
|
mode: UnifiedTextMode;
|
||||||
|
rows?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
resize?: "none" | "vertical" | "horizontal" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedTextProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedText";
|
||||||
|
config: UnifiedTextConfig;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedMedia =====
|
||||||
|
|
||||||
|
export type UnifiedMediaType = "file" | "image" | "video" | "audio";
|
||||||
|
|
||||||
|
export interface UnifiedMediaConfig {
|
||||||
|
type: UnifiedMediaType;
|
||||||
|
multiple?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
preview?: boolean;
|
||||||
|
uploadEndpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedMediaProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedMedia";
|
||||||
|
config: UnifiedMediaConfig;
|
||||||
|
value?: string | string[]; // 파일 URL 또는 배열
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedList =====
|
||||||
|
|
||||||
|
export type UnifiedListViewMode = "table" | "card" | "kanban" | "list";
|
||||||
|
|
||||||
|
export interface ListColumn {
|
||||||
|
field: string;
|
||||||
|
header: string;
|
||||||
|
width?: number;
|
||||||
|
sortable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
format?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedListConfig {
|
||||||
|
viewMode: UnifiedListViewMode;
|
||||||
|
editable?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
pageable?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
columns?: ListColumn[];
|
||||||
|
modal?: boolean;
|
||||||
|
// 데이터 소스
|
||||||
|
dataSource?: {
|
||||||
|
table?: string;
|
||||||
|
api?: string;
|
||||||
|
filters?: Array<{ column: string; operator: string; value: unknown }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedListProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedList";
|
||||||
|
config: UnifiedListConfig;
|
||||||
|
data?: Record<string, unknown>[];
|
||||||
|
selectedRows?: Record<string, unknown>[];
|
||||||
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||||
|
onRowClick?: (row: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedLayout =====
|
||||||
|
|
||||||
|
export type UnifiedLayoutType = "grid" | "split" | "flex" | "divider" | "screen-embed";
|
||||||
|
|
||||||
|
export interface UnifiedLayoutConfig {
|
||||||
|
type: UnifiedLayoutType;
|
||||||
|
columns?: number; // 12컬럼 시스템에서 실제 표시할 컬럼 수 (1-12)
|
||||||
|
gap?: string;
|
||||||
|
splitRatio?: number[];
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
use12Column?: boolean; // 12컬럼 시스템 사용 여부 (기본 true)
|
||||||
|
// screen-embed 전용
|
||||||
|
screenId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedLayoutProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedLayout";
|
||||||
|
config: UnifiedLayoutConfig;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedGroup =====
|
||||||
|
|
||||||
|
export type UnifiedGroupType = "tabs" | "accordion" | "section" | "card-section" | "modal" | "form-modal";
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedGroupConfig {
|
||||||
|
type: UnifiedGroupType;
|
||||||
|
title?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
// 탭 전용
|
||||||
|
tabs?: TabItem[];
|
||||||
|
activeTab?: string;
|
||||||
|
// 모달 전용
|
||||||
|
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedGroupProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedGroup";
|
||||||
|
config: UnifiedGroupConfig;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedBiz =====
|
||||||
|
|
||||||
|
export type UnifiedBizType = "flow" | "rack" | "map" | "numbering" | "category" | "mapping" | "related-buttons";
|
||||||
|
|
||||||
|
export interface UnifiedBizConfig {
|
||||||
|
type: UnifiedBizType;
|
||||||
|
// 각 타입별 설정은 제네릭하게 처리
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedBizProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedBiz";
|
||||||
|
config: UnifiedBizConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UnifiedHierarchy =====
|
||||||
|
|
||||||
|
export type UnifiedHierarchyType = "tree" | "org" | "bom" | "cascading";
|
||||||
|
export type UnifiedHierarchyViewMode = "tree" | "table" | "indent" | "dropdown";
|
||||||
|
|
||||||
|
export interface HierarchyNode {
|
||||||
|
id: string;
|
||||||
|
parentId?: string;
|
||||||
|
label: string;
|
||||||
|
children?: HierarchyNode[];
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedHierarchyConfig {
|
||||||
|
type: UnifiedHierarchyType;
|
||||||
|
viewMode: UnifiedHierarchyViewMode;
|
||||||
|
source?: string; // 계층 그룹 코드
|
||||||
|
editable?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
|
showQty?: boolean; // BOM 전용
|
||||||
|
maxLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedHierarchyProps extends UnifiedBaseProps {
|
||||||
|
unifiedType: "UnifiedHierarchy";
|
||||||
|
config: UnifiedHierarchyConfig;
|
||||||
|
data?: HierarchyNode[];
|
||||||
|
selectedNode?: HierarchyNode;
|
||||||
|
onNodeSelect?: (node: HierarchyNode) => void;
|
||||||
|
onNodeMove?: (nodeId: string, newParentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 통합 Props 유니온 타입 =====
|
||||||
|
|
||||||
|
export type UnifiedComponentProps =
|
||||||
|
| UnifiedInputProps
|
||||||
|
| UnifiedSelectProps
|
||||||
|
| UnifiedDateProps
|
||||||
|
| UnifiedTextProps
|
||||||
|
| UnifiedMediaProps
|
||||||
|
| UnifiedListProps
|
||||||
|
| UnifiedLayoutProps
|
||||||
|
| UnifiedGroupProps
|
||||||
|
| UnifiedBizProps
|
||||||
|
| UnifiedHierarchyProps;
|
||||||
|
|
||||||
|
// ===== 타입 가드 =====
|
||||||
|
|
||||||
|
export function isUnifiedInput(props: UnifiedComponentProps): props is UnifiedInputProps {
|
||||||
|
return props.unifiedType === "UnifiedInput";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedSelect(props: UnifiedComponentProps): props is UnifiedSelectProps {
|
||||||
|
return props.unifiedType === "UnifiedSelect";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedDate(props: UnifiedComponentProps): props is UnifiedDateProps {
|
||||||
|
return props.unifiedType === "UnifiedDate";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedText(props: UnifiedComponentProps): props is UnifiedTextProps {
|
||||||
|
return props.unifiedType === "UnifiedText";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedMedia(props: UnifiedComponentProps): props is UnifiedMediaProps {
|
||||||
|
return props.unifiedType === "UnifiedMedia";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedList(props: UnifiedComponentProps): props is UnifiedListProps {
|
||||||
|
return props.unifiedType === "UnifiedList";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedLayout(props: UnifiedComponentProps): props is UnifiedLayoutProps {
|
||||||
|
return props.unifiedType === "UnifiedLayout";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedGroup(props: UnifiedComponentProps): props is UnifiedGroupProps {
|
||||||
|
return props.unifiedType === "UnifiedGroup";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedBiz(props: UnifiedComponentProps): props is UnifiedBizProps {
|
||||||
|
return props.unifiedType === "UnifiedBiz";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnifiedHierarchy(props: UnifiedComponentProps): props is UnifiedHierarchyProps {
|
||||||
|
return props.unifiedType === "UnifiedHierarchy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== JSON Schema 타입 =====
|
||||||
|
|
||||||
|
export interface JSONSchemaProperty {
|
||||||
|
type: "string" | "number" | "boolean" | "array" | "object";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
enum?: string[];
|
||||||
|
default?: unknown;
|
||||||
|
items?: JSONSchemaProperty;
|
||||||
|
properties?: Record<string, JSONSchemaProperty>;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedConfigSchema {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, JSONSchemaProperty>;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 레거시 컴포넌트 → Unified 컴포넌트 매핑 =====
|
||||||
|
|
||||||
|
export const LEGACY_TO_UNIFIED_MAP: Record<string, UnifiedComponentType> = {
|
||||||
|
// Input 계열
|
||||||
|
"text-input": "UnifiedInput",
|
||||||
|
"number-input": "UnifiedInput",
|
||||||
|
"password-input": "UnifiedInput",
|
||||||
|
|
||||||
|
// Select 계열
|
||||||
|
"select-basic": "UnifiedSelect",
|
||||||
|
"radio-basic": "UnifiedSelect",
|
||||||
|
"checkbox-basic": "UnifiedSelect",
|
||||||
|
"entity-search-input": "UnifiedSelect",
|
||||||
|
"autocomplete-search-input": "UnifiedSelect",
|
||||||
|
|
||||||
|
// Date 계열
|
||||||
|
"date-input": "UnifiedDate",
|
||||||
|
|
||||||
|
// Text 계열
|
||||||
|
"textarea-basic": "UnifiedText",
|
||||||
|
|
||||||
|
// Media 계열
|
||||||
|
"file-upload": "UnifiedMedia",
|
||||||
|
"image-widget": "UnifiedMedia",
|
||||||
|
|
||||||
|
// List 계열
|
||||||
|
"table-list": "UnifiedList",
|
||||||
|
"table-search-widget": "UnifiedList",
|
||||||
|
"modal-repeater-table": "UnifiedList",
|
||||||
|
"repeater-field-group": "UnifiedList",
|
||||||
|
"card-display": "UnifiedList",
|
||||||
|
|
||||||
|
// Layout 계열
|
||||||
|
"split-panel-layout": "UnifiedLayout",
|
||||||
|
"screen-split-panel": "UnifiedLayout",
|
||||||
|
|
||||||
|
// Group 계열
|
||||||
|
"tabs-widget": "UnifiedGroup",
|
||||||
|
"section-paper": "UnifiedGroup",
|
||||||
|
"section-card": "UnifiedGroup",
|
||||||
|
"universal-form-modal": "UnifiedGroup",
|
||||||
|
|
||||||
|
// Biz 계열
|
||||||
|
"category-manager": "UnifiedBiz",
|
||||||
|
"numbering-rule": "UnifiedBiz",
|
||||||
|
"flow-widget": "UnifiedBiz",
|
||||||
|
|
||||||
|
// Button (Input의 버튼 모드)
|
||||||
|
"button-primary": "UnifiedInput",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -37,7 +37,13 @@ export type WebType =
|
||||||
| "entity" // 엔티티 참조
|
| "entity" // 엔티티 참조
|
||||||
| "file" // 파일 업로드
|
| "file" // 파일 업로드
|
||||||
| "image" // 이미지 표시
|
| "image" // 이미지 표시
|
||||||
| "button"; // 버튼 컴포넌트
|
| "button" // 버튼 컴포넌트
|
||||||
|
// 레이아웃/컨테이너 타입
|
||||||
|
| "container" // 컨테이너
|
||||||
|
| "group" // 그룹
|
||||||
|
| "list" // 목록
|
||||||
|
| "tree" // 트리
|
||||||
|
| "custom"; // 커스텀 컴포넌트
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 동적 WebType 지원
|
* 동적 WebType 지원
|
||||||
|
|
|
||||||
|
|
@ -1688,3 +1688,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -535,3 +535,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -522,3 +522,4 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue