웹타입 컴포넌트 분리작업
This commit is contained in:
parent
540d82e7e4
commit
a17602c643
|
|
@ -234,7 +234,7 @@ model assembly_wbs_task {
|
|||
}
|
||||
|
||||
model attach_file_info {
|
||||
objid Decimal @default(0) @db.Decimal
|
||||
objid Decimal @id @default(0) @db.Decimal
|
||||
target_objid String? @db.VarChar
|
||||
saved_file_name String? @default("NULL::character varying") @db.VarChar(128)
|
||||
real_file_name String? @default("NULL::character varying") @db.VarChar(128)
|
||||
|
|
@ -243,18 +243,17 @@ model attach_file_info {
|
|||
file_size Decimal? @db.Decimal
|
||||
file_ext String? @default("NULL::character varying") @db.VarChar(32)
|
||||
file_path String? @default("NULL::character varying") @db.VarChar(512)
|
||||
company_code String? @default("default") @db.VarChar(32)
|
||||
writer String? @default("NULL::character varying") @db.VarChar(32)
|
||||
regdate DateTime? @db.Timestamp(6)
|
||||
status String? @default("NULL::character varying") @db.VarChar(32)
|
||||
parent_target_objid String? @db.VarChar
|
||||
company_code String? @default("default") @db.VarChar(32)
|
||||
|
||||
@@index([doc_type, objid], map: "attach_file_info_doc_type_idx")
|
||||
@@index([target_objid])
|
||||
@@index([company_code], map: "attach_file_info_company_code_idx")
|
||||
@@index([company_code])
|
||||
@@index([company_code, doc_type], map: "attach_file_info_company_doc_type_idx")
|
||||
@@index([company_code, target_objid], map: "attach_file_info_company_target_idx")
|
||||
@@id([objid])
|
||||
}
|
||||
|
||||
model authority_master {
|
||||
|
|
@ -1494,7 +1493,6 @@ model menu_info {
|
|||
lang_key String? @db.VarChar(100)
|
||||
lang_key_desc String? @db.VarChar(100)
|
||||
company company_mng? @relation(fields: [company_code], references: [company_code])
|
||||
screen_assignments screen_menu_assignments[]
|
||||
|
||||
@@index([parent_obj_id])
|
||||
@@index([company_code])
|
||||
|
|
@ -4994,21 +4992,20 @@ model screen_definitions {
|
|||
table_name String @db.VarChar(100)
|
||||
company_code String @db.VarChar(50)
|
||||
description String?
|
||||
is_active String @default("Y") @db.Char(1) // Y=활성, N=비활성, D=삭제됨(휴지통)
|
||||
is_active String @default("Y") @db.Char(1)
|
||||
layout_metadata Json?
|
||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
deleted_date DateTime? @db.Timestamp(6) // 삭제 일시 (휴지통 이동 시점)
|
||||
deleted_by String? @db.VarChar(50) // 삭제한 사용자
|
||||
delete_reason String? // 삭제 사유 (선택사항)
|
||||
deleted_date DateTime? @db.Timestamp(6)
|
||||
deleted_by String? @db.VarChar(50)
|
||||
delete_reason String?
|
||||
layouts screen_layouts[]
|
||||
menu_assignments screen_menu_assignments[]
|
||||
|
||||
@@index([company_code])
|
||||
@@index([is_active, company_code])
|
||||
@@index([deleted_date], map: "idx_screen_definitions_deleted")
|
||||
@@index([is_active, company_code], map: "idx_screen_definitions_status")
|
||||
}
|
||||
|
||||
model screen_layouts {
|
||||
|
|
@ -5072,7 +5069,6 @@ model screen_menu_assignments {
|
|||
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
||||
menu_info menu_info @relation(fields: [menu_objid], references: [objid])
|
||||
|
||||
@@unique([screen_id, menu_objid, company_code])
|
||||
@@index([company_code])
|
||||
|
|
@ -5111,3 +5107,149 @@ model code_info {
|
|||
@@id([code_category, code_value], map: "pk_code_info")
|
||||
@@index([code_category, sort_order], map: "idx_code_info_sort")
|
||||
}
|
||||
|
||||
model web_type_standards {
|
||||
web_type String @id @db.VarChar(50)
|
||||
type_name String @db.VarChar(100)
|
||||
type_name_eng String? @db.VarChar(100)
|
||||
description String?
|
||||
category String? @default("input") @db.VarChar(50)
|
||||
component_name String? @default("TextWidget") @db.VarChar(100)
|
||||
default_config Json?
|
||||
validation_rules Json?
|
||||
default_style Json?
|
||||
input_properties Json?
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([is_active], map: "idx_web_type_standards_active")
|
||||
@@index([category], map: "idx_web_type_standards_category")
|
||||
@@index([sort_order], map: "idx_web_type_standards_sort")
|
||||
}
|
||||
|
||||
model style_templates {
|
||||
template_id Int @id @default(autoincrement())
|
||||
template_name String @db.VarChar(100)
|
||||
template_name_eng String? @db.VarChar(100)
|
||||
template_type String @db.VarChar(50)
|
||||
category String? @db.VarChar(50)
|
||||
style_config Json
|
||||
preview_config Json?
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
is_default Boolean? @default(false)
|
||||
is_public Boolean? @default(true)
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([is_active], map: "idx_style_templates_active")
|
||||
@@index([category], map: "idx_style_templates_category")
|
||||
@@index([company_code], map: "idx_style_templates_company")
|
||||
@@index([template_type], map: "idx_style_templates_type")
|
||||
}
|
||||
|
||||
model button_action_standards {
|
||||
action_type String @id @db.VarChar(50)
|
||||
action_name String @db.VarChar(100)
|
||||
action_name_eng String? @db.VarChar(100)
|
||||
description String?
|
||||
category String? @default("general") @db.VarChar(50)
|
||||
default_text String? @db.VarChar(100)
|
||||
default_text_eng String? @db.VarChar(100)
|
||||
default_icon String? @db.VarChar(50)
|
||||
default_color String? @db.VarChar(50)
|
||||
default_variant String? @db.VarChar(50)
|
||||
confirmation_required Boolean? @default(false)
|
||||
confirmation_message String?
|
||||
validation_rules Json?
|
||||
action_config Json?
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([is_active], map: "idx_button_action_standards_active")
|
||||
@@index([category], map: "idx_button_action_standards_category")
|
||||
@@index([sort_order], map: "idx_button_action_standards_sort")
|
||||
}
|
||||
|
||||
model grid_standards {
|
||||
grid_id Int @id @default(autoincrement())
|
||||
grid_name String @db.VarChar(100)
|
||||
grid_name_eng String? @db.VarChar(100)
|
||||
description String?
|
||||
grid_size Int
|
||||
grid_color String? @default("#e5e7eb") @db.VarChar(50)
|
||||
grid_opacity Decimal? @default(0.5) @db.Decimal(3, 2)
|
||||
snap_enabled Boolean? @default(true)
|
||||
snap_threshold Int? @default(5)
|
||||
grid_config Json?
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
is_default Boolean? @default(false)
|
||||
sort_order Int? @default(0)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([is_active], map: "idx_grid_standards_active")
|
||||
@@index([company_code], map: "idx_grid_standards_company")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model data_relationship_bridge {
|
||||
bridge_id Int @id @default(autoincrement())
|
||||
relationship_id Int?
|
||||
from_table_name String @db.VarChar(100)
|
||||
from_column_name String @db.VarChar(100)
|
||||
from_key_value String? @db.VarChar(500)
|
||||
from_record_id String? @db.VarChar(100)
|
||||
to_table_name String @db.VarChar(100)
|
||||
to_column_name String @db.VarChar(100)
|
||||
to_key_value String? @db.VarChar(500)
|
||||
to_record_id String? @db.VarChar(100)
|
||||
connection_type String @db.VarChar(20)
|
||||
company_code String @db.VarChar(50)
|
||||
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
bridge_data Json?
|
||||
table_relationships table_relationships? @relation(fields: [relationship_id], references: [relationship_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@index([company_code, is_active], map: "idx_data_bridge_company_active")
|
||||
@@index([connection_type], map: "idx_data_bridge_connection_type")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model table_relationships {
|
||||
relationship_id Int @id @default(autoincrement())
|
||||
relationship_name String @db.VarChar(200)
|
||||
from_table_name String @db.VarChar(100)
|
||||
from_column_name String @db.VarChar(100)
|
||||
to_table_name String @db.VarChar(100)
|
||||
to_column_name String @db.VarChar(100)
|
||||
relationship_type String @db.VarChar(20)
|
||||
connection_type String @db.VarChar(20)
|
||||
company_code String @db.VarChar(50)
|
||||
settings Json?
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
data_relationship_bridge data_relationship_bridge[]
|
||||
|
||||
@@index([to_table_name], map: "idx_table_relationships_to_table")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import commonCodeRoutes from "./routes/commonCodeRoutes";
|
|||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||
import fileRoutes from "./routes/fileRoutes";
|
||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||
import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
|
|
@ -101,6 +104,9 @@ app.use("/api/common-codes", commonCodeRoutes);
|
|||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
app.use("/api/company-management", companyManagementRoutes);
|
||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
import { Request, Response } from "express";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class ButtonActionStandardController {
|
||||
// 버튼 액션 목록 조회
|
||||
static async getButtonActions(req: Request, res: Response) {
|
||||
try {
|
||||
const { active, category, search } = req.query;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (active) {
|
||||
where.is_active = active as string;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = category as string;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ action_name: { contains: search as string, mode: "insensitive" } },
|
||||
{
|
||||
action_name_eng: {
|
||||
contains: search as string,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{ description: { contains: search as string, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const buttonActions = await prisma.button_action_standards.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: "asc" }, { action_type: "asc" }],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: buttonActions,
|
||||
message: "버튼 액션 목록을 성공적으로 조회했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "버튼 액션 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 버튼 액션 상세 조회
|
||||
static async getButtonAction(req: Request, res: Response) {
|
||||
try {
|
||||
const { actionType } = req.params;
|
||||
|
||||
const buttonAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
if (!buttonAction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 버튼 액션을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: buttonAction,
|
||||
message: "버튼 액션 정보를 성공적으로 조회했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "버튼 액션 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 버튼 액션 생성
|
||||
static async createButtonAction(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
action_type,
|
||||
action_name,
|
||||
action_name_eng,
|
||||
description,
|
||||
category = "general",
|
||||
default_text,
|
||||
default_text_eng,
|
||||
default_icon,
|
||||
default_color,
|
||||
default_variant = "default",
|
||||
confirmation_required = false,
|
||||
confirmation_message,
|
||||
validation_rules,
|
||||
action_config,
|
||||
sort_order = 0,
|
||||
is_active = "Y",
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!action_type || !action_name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "액션 타입과 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const existingAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type },
|
||||
});
|
||||
|
||||
if (existingAction) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 액션 타입입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const newButtonAction = await prisma.button_action_standards.create({
|
||||
data: {
|
||||
action_type,
|
||||
action_name,
|
||||
action_name_eng,
|
||||
description,
|
||||
category,
|
||||
default_text,
|
||||
default_text_eng,
|
||||
default_icon,
|
||||
default_color,
|
||||
default_variant,
|
||||
confirmation_required,
|
||||
confirmation_message,
|
||||
validation_rules,
|
||||
action_config,
|
||||
sort_order,
|
||||
is_active,
|
||||
created_by: req.user?.userId || "system",
|
||||
updated_by: req.user?.userId || "system",
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: newButtonAction,
|
||||
message: "버튼 액션이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "버튼 액션 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 버튼 액션 수정
|
||||
static async updateButtonAction(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { actionType } = req.params;
|
||||
const {
|
||||
action_name,
|
||||
action_name_eng,
|
||||
description,
|
||||
category,
|
||||
default_text,
|
||||
default_text_eng,
|
||||
default_icon,
|
||||
default_color,
|
||||
default_variant,
|
||||
confirmation_required,
|
||||
confirmation_message,
|
||||
validation_rules,
|
||||
action_config,
|
||||
sort_order,
|
||||
is_active,
|
||||
} = req.body;
|
||||
|
||||
// 존재 여부 확인
|
||||
const existingAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
if (!existingAction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 버튼 액션을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedButtonAction = await prisma.button_action_standards.update({
|
||||
where: { action_type: actionType },
|
||||
data: {
|
||||
action_name,
|
||||
action_name_eng,
|
||||
description,
|
||||
category,
|
||||
default_text,
|
||||
default_text_eng,
|
||||
default_icon,
|
||||
default_color,
|
||||
default_variant,
|
||||
confirmation_required,
|
||||
confirmation_message,
|
||||
validation_rules,
|
||||
action_config,
|
||||
sort_order,
|
||||
is_active,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: updatedButtonAction,
|
||||
message: "버튼 액션이 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "버튼 액션 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 버튼 액션 삭제
|
||||
static async deleteButtonAction(req: Request, res: Response) {
|
||||
try {
|
||||
const { actionType } = req.params;
|
||||
|
||||
// 존재 여부 확인
|
||||
const existingAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
if (!existingAction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 버튼 액션을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.button_action_standards.delete({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "버튼 액션이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "버튼 액션 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 버튼 액션 정렬 순서 업데이트
|
||||
static async updateButtonActionSortOrder(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const { buttonActions } = req.body; // [{ action_type: 'save', sort_order: 1 }, ...]
|
||||
|
||||
if (!Array.isArray(buttonActions)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 데이터 형식입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 트랜잭션으로 일괄 업데이트
|
||||
await prisma.$transaction(
|
||||
buttonActions.map((item) =>
|
||||
prisma.button_action_standards.update({
|
||||
where: { action_type: item.action_type },
|
||||
data: {
|
||||
sort_order: item.sort_order,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "버튼 액션 정렬 순서가 성공적으로 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 정렬 순서 업데이트 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "정렬 순서 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 버튼 액션 카테고리 목록 조회
|
||||
static async getButtonActionCategories(req: Request, res: Response) {
|
||||
try {
|
||||
const categories = await prisma.button_action_standards.groupBy({
|
||||
by: ["category"],
|
||||
where: {
|
||||
is_active: "Y",
|
||||
},
|
||||
_count: {
|
||||
category: true,
|
||||
},
|
||||
});
|
||||
|
||||
const categoryList = categories.map((item) => ({
|
||||
category: item.category,
|
||||
count: item._count.category,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: categoryList,
|
||||
message: "버튼 액션 카테고리 목록을 성공적으로 조회했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 카테고리 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import { Request, Response } from "express";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class WebTypeStandardController {
|
||||
// 웹타입 목록 조회
|
||||
static async getWebTypes(req: Request, res: Response) {
|
||||
try {
|
||||
const { active, category, search } = req.query;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (active) {
|
||||
where.is_active = active as string;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = category as string;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ type_name: { contains: search as string, mode: "insensitive" } },
|
||||
{
|
||||
type_name_eng: { contains: search as string, mode: "insensitive" },
|
||||
},
|
||||
{ description: { contains: search as string, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const webTypes = await prisma.web_type_standards.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: "asc" }, { web_type: "asc" }],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: webTypes,
|
||||
message: "웹타입 목록을 성공적으로 조회했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "웹타입 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입 상세 조회
|
||||
static async getWebType(req: Request, res: Response) {
|
||||
try {
|
||||
const { webType } = req.params;
|
||||
|
||||
const webTypeData = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: webType },
|
||||
});
|
||||
|
||||
if (!webTypeData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 웹타입을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: webTypeData,
|
||||
message: "웹타입 정보를 성공적으로 조회했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "웹타입 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입 생성
|
||||
static async createWebType(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
web_type,
|
||||
type_name,
|
||||
type_name_eng,
|
||||
description,
|
||||
category = "input",
|
||||
component_name = "TextWidget",
|
||||
default_config,
|
||||
validation_rules,
|
||||
default_style,
|
||||
input_properties,
|
||||
sort_order = 0,
|
||||
is_active = "Y",
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!web_type || !type_name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "웹타입 코드와 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type },
|
||||
});
|
||||
|
||||
if (existingWebType) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 웹타입 코드입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const newWebType = await prisma.web_type_standards.create({
|
||||
data: {
|
||||
web_type,
|
||||
type_name,
|
||||
type_name_eng,
|
||||
description,
|
||||
category,
|
||||
component_name,
|
||||
default_config,
|
||||
validation_rules,
|
||||
default_style,
|
||||
input_properties,
|
||||
sort_order,
|
||||
is_active,
|
||||
created_by: req.user?.userId || "system",
|
||||
updated_by: req.user?.userId || "system",
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: newWebType,
|
||||
message: "웹타입이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "웹타입 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입 수정
|
||||
static async updateWebType(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { webType } = req.params;
|
||||
const {
|
||||
type_name,
|
||||
type_name_eng,
|
||||
description,
|
||||
category,
|
||||
component_name,
|
||||
default_config,
|
||||
validation_rules,
|
||||
default_style,
|
||||
input_properties,
|
||||
sort_order,
|
||||
is_active,
|
||||
} = req.body;
|
||||
|
||||
// 존재 여부 확인
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: webType },
|
||||
});
|
||||
|
||||
if (!existingWebType) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 웹타입을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedWebType = await prisma.web_type_standards.update({
|
||||
where: { web_type: webType },
|
||||
data: {
|
||||
type_name,
|
||||
type_name_eng,
|
||||
description,
|
||||
category,
|
||||
component_name,
|
||||
default_config,
|
||||
validation_rules,
|
||||
default_style,
|
||||
input_properties,
|
||||
sort_order,
|
||||
is_active,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: updatedWebType,
|
||||
message: "웹타입이 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "웹타입 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입 삭제
|
||||
static async deleteWebType(req: Request, res: Response) {
|
||||
try {
|
||||
const { webType } = req.params;
|
||||
|
||||
// 존재 여부 확인
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: webType },
|
||||
});
|
||||
|
||||
if (!existingWebType) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 웹타입을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.web_type_standards.delete({
|
||||
where: { web_type: webType },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "웹타입이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "웹타입 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입 정렬 순서 업데이트
|
||||
static async updateWebTypeSortOrder(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const { webTypes } = req.body; // [{ web_type: 'text', sort_order: 1 }, ...]
|
||||
|
||||
if (!Array.isArray(webTypes)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 데이터 형식입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 트랜잭션으로 일괄 업데이트
|
||||
await prisma.$transaction(
|
||||
webTypes.map((item) =>
|
||||
prisma.web_type_standards.update({
|
||||
where: { web_type: item.web_type },
|
||||
data: {
|
||||
sort_order: item.sort_order,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "웹타입 정렬 순서가 성공적으로 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 정렬 순서 업데이트 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "정렬 순서 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입 카테고리 목록 조회
|
||||
static async getWebTypeCategories(req: Request, res: Response) {
|
||||
try {
|
||||
const categories = await prisma.web_type_standards.groupBy({
|
||||
by: ["category"],
|
||||
where: {
|
||||
is_active: "Y",
|
||||
},
|
||||
_count: {
|
||||
category: true,
|
||||
},
|
||||
});
|
||||
|
||||
const categoryList = categories.map((item) => ({
|
||||
category: item.category,
|
||||
count: item._count.category,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: categoryList,
|
||||
message: "웹타입 카테고리 목록을 성공적으로 조회했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("웹타입 카테고리 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import express from "express";
|
||||
import { ButtonActionStandardController } from "../controllers/buttonActionStandardController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 버튼 액션 표준 관리 라우트
|
||||
router.get("/", ButtonActionStandardController.getButtonActions);
|
||||
router.get(
|
||||
"/categories",
|
||||
ButtonActionStandardController.getButtonActionCategories
|
||||
);
|
||||
router.get("/:actionType", ButtonActionStandardController.getButtonAction);
|
||||
router.post("/", ButtonActionStandardController.createButtonAction);
|
||||
router.put("/:actionType", ButtonActionStandardController.updateButtonAction);
|
||||
router.delete(
|
||||
"/:actionType",
|
||||
ButtonActionStandardController.deleteButtonAction
|
||||
);
|
||||
router.put(
|
||||
"/sort-order/bulk",
|
||||
ButtonActionStandardController.updateButtonActionSortOrder
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import express from "express";
|
||||
import { WebTypeStandardController } from "../controllers/webTypeStandardController";
|
||||
import { ButtonActionStandardController } from "../controllers/buttonActionStandardController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 화면관리에서 사용할 조회 전용 API
|
||||
router.get("/web-types", WebTypeStandardController.getWebTypes);
|
||||
router.get(
|
||||
"/web-types/categories",
|
||||
WebTypeStandardController.getWebTypeCategories
|
||||
);
|
||||
router.get("/button-actions", ButtonActionStandardController.getButtonActions);
|
||||
router.get(
|
||||
"/button-actions/categories",
|
||||
ButtonActionStandardController.getButtonActionCategories
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import express from "express";
|
||||
import { WebTypeStandardController } from "../controllers/webTypeStandardController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 웹타입 표준 관리 라우트
|
||||
router.get("/", WebTypeStandardController.getWebTypes);
|
||||
router.get("/categories", WebTypeStandardController.getWebTypeCategories);
|
||||
router.get("/:webType", WebTypeStandardController.getWebType);
|
||||
router.post("/", WebTypeStandardController.createWebType);
|
||||
router.put("/:webType", WebTypeStandardController.updateWebType);
|
||||
router.delete("/:webType", WebTypeStandardController.deleteWebType);
|
||||
router.put(
|
||||
"/sort-order/bulk",
|
||||
WebTypeStandardController.updateWebTypeSortOrder
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
|||
#!/bin/bash
|
||||
|
||||
# DataTableConfigPanel의 모든 Select를 HTML select로 교체하는 스크립트
|
||||
|
||||
FILE="frontend/components/screen/panels/DataTableConfigPanel.tsx"
|
||||
|
||||
echo "🔄 DataTableConfigPanel의 Select 컴포넌트들을 교체 중..."
|
||||
|
||||
# 1. Select 컴포넌트를 select로 교체 (기본 패턴)
|
||||
sed -i '' 's/<Select\([^>]*\)>/<select className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"\1>/g' "$FILE"
|
||||
|
||||
# 2. SelectTrigger 제거
|
||||
sed -i '' '/<SelectTrigger[^>]*>/,/<\/SelectTrigger>/d' "$FILE"
|
||||
|
||||
# 3. SelectContent를 빈 태그로 교체
|
||||
sed -i '' 's/<SelectContent[^>]*>//g' "$FILE"
|
||||
sed -i '' 's/<\/SelectContent>//g' "$FILE"
|
||||
|
||||
# 4. SelectItem을 option으로 교체
|
||||
sed -i '' 's/<SelectItem\([^>]*\)value="\([^"]*\)"\([^>]*\)>/<option value="\2">/g' "$FILE"
|
||||
sed -i '' 's/<\/SelectItem>/<\/option>/g' "$FILE"
|
||||
|
||||
# 5. SelectValue 제거
|
||||
sed -i '' '/<SelectValue[^>]*\/>/d' "$FILE"
|
||||
|
||||
# 6. onValueChange를 onChange로 교체
|
||||
sed -i '' 's/onValueChange={(value) =>/onChange={(e) => {const value = e.target.value;/g' "$FILE"
|
||||
sed -i '' 's/onValueChange={([^}]*) =>/onChange={(e) => {const value = e.target.value; \1(value) =>/g' "$FILE"
|
||||
|
||||
# 7. </Select>를 </select>로 교체
|
||||
sed -i '' 's/<\/Select>/<\/select>/g' "$FILE"
|
||||
|
||||
echo "✅ 완료!"
|
||||
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
|
||||
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
|
||||
|
||||
// 기본 변형 목록
|
||||
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
|
||||
|
||||
export default function EditButtonActionPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const actionType = params.actionType as string;
|
||||
|
||||
const { buttonActions, updateButtonAction, isUpdating, updateError, isLoading } = useButtonActions();
|
||||
|
||||
const [formData, setFormData] = useState<Partial<ButtonActionFormData>>({});
|
||||
const [originalData, setOriginalData] = useState<any>(null);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
validation_rules?: string;
|
||||
action_config?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
validation_rules: "{}",
|
||||
action_config: "{}",
|
||||
});
|
||||
|
||||
// 버튼 액션 데이터 로드
|
||||
useEffect(() => {
|
||||
if (buttonActions && actionType && !isDataLoaded) {
|
||||
const found = buttonActions.find((ba) => ba.action_type === actionType);
|
||||
if (found) {
|
||||
setOriginalData(found);
|
||||
setFormData({
|
||||
action_name: found.action_name,
|
||||
action_name_eng: found.action_name_eng || "",
|
||||
description: found.description || "",
|
||||
category: found.category,
|
||||
default_text: found.default_text || "",
|
||||
default_text_eng: found.default_text_eng || "",
|
||||
default_icon: found.default_icon || "",
|
||||
default_color: found.default_color || "",
|
||||
default_variant: found.default_variant || "default",
|
||||
confirmation_required: found.confirmation_required || false,
|
||||
confirmation_message: found.confirmation_message || "",
|
||||
validation_rules: found.validation_rules || {},
|
||||
action_config: found.action_config || {},
|
||||
sort_order: found.sort_order || 0,
|
||||
is_active: found.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
|
||||
action_config: JSON.stringify(found.action_config || {}, null, 2),
|
||||
});
|
||||
setIsDataLoaded(true);
|
||||
} else {
|
||||
toast.error("버튼 액션을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/button-actions");
|
||||
}
|
||||
}
|
||||
}, [buttonActions, actionType, isDataLoaded, router]);
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.action_name?.trim()) {
|
||||
toast.error("액션명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await updateButtonAction(actionType, formData);
|
||||
toast.success("버튼 액션이 성공적으로 수정되었습니다.");
|
||||
router.push(`/admin/system-settings/button-actions/${actionType}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화 (원본 데이터로 되돌리기)
|
||||
const handleReset = () => {
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
action_name: originalData.action_name,
|
||||
action_name_eng: originalData.action_name_eng || "",
|
||||
description: originalData.description || "",
|
||||
category: originalData.category,
|
||||
default_text: originalData.default_text || "",
|
||||
default_text_eng: originalData.default_text_eng || "",
|
||||
default_icon: originalData.default_icon || "",
|
||||
default_color: originalData.default_color || "",
|
||||
default_variant: originalData.default_variant || "default",
|
||||
confirmation_required: originalData.confirmation_required || false,
|
||||
confirmation_message: originalData.confirmation_message || "",
|
||||
validation_rules: originalData.validation_rules || {},
|
||||
action_config: originalData.action_config || {},
|
||||
sort_order: originalData.sort_order || 0,
|
||||
is_active: originalData.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
|
||||
action_config: JSON.stringify(originalData.action_config || {}, null, 2),
|
||||
});
|
||||
setJsonErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading || !isDataLoaded) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">버튼 액션 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 액션을 찾지 못한 경우
|
||||
if (!originalData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">버튼 액션을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
상세보기로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">버튼 액션 편집</h1>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{actionType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{originalData.action_name} 버튼 액션의 정보를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>버튼 액션의 기본적인 정보를 수정해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 액션 타입 (읽기 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_type">액션 타입</Label>
|
||||
<Input id="action_type" value={actionType} disabled className="bg-muted font-mono" />
|
||||
<p className="text-muted-foreground text-xs">액션 타입은 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 액션명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name">
|
||||
액션명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="action_name"
|
||||
value={formData.action_name || ""}
|
||||
onChange={(e) => handleInputChange("action_name", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="action_name_eng"
|
||||
value={formData.action_name_eng || ""}
|
||||
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>버튼 액션의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>버튼의 기본 스타일과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 기본 텍스트 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text">기본 텍스트</Label>
|
||||
<Input
|
||||
id="default_text"
|
||||
value={formData.default_text || ""}
|
||||
onChange={(e) => handleInputChange("default_text", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text_eng">영문 텍스트</Label>
|
||||
<Input
|
||||
id="default_text_eng"
|
||||
value={formData.default_text_eng || ""}
|
||||
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 및 색상 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_icon">기본 아이콘</Label>
|
||||
<Input
|
||||
id="default_icon"
|
||||
value={formData.default_icon || ""}
|
||||
onChange={(e) => handleInputChange("default_icon", e.target.value)}
|
||||
placeholder="예: Save (Lucide 아이콘명)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_color">기본 색상</Label>
|
||||
<Input
|
||||
id="default_color"
|
||||
value={formData.default_color || ""}
|
||||
onChange={(e) => handleInputChange("default_color", e.target.value)}
|
||||
placeholder="예: blue, red, green..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_variant">기본 변형</Label>
|
||||
<Select
|
||||
value={formData.default_variant || "default"}
|
||||
onValueChange={(value) => handleInputChange("default_variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="변형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_VARIANTS.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{variant}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 확인 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>확인 설정</CardTitle>
|
||||
<CardDescription>버튼 실행 전 확인 메시지 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmation_required">확인 메시지 필요</Label>
|
||||
<p className="text-muted-foreground text-xs">버튼 실행 전 사용자 확인을 받습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="confirmation_required"
|
||||
checked={formData.confirmation_required || false}
|
||||
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.confirmation_required && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation_message">확인 메시지</Label>
|
||||
<Textarea
|
||||
id="confirmation_message"
|
||||
value={formData.confirmation_message || ""}
|
||||
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
|
||||
placeholder="예: 정말로 삭제하시겠습니까?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>버튼 액션의 세부 설정을 JSON 형식으로 수정할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"requiresData": true, "minItems": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_config">액션 설정</Label>
|
||||
<Textarea
|
||||
id="action_config"
|
||||
value={jsonStrings.action_config}
|
||||
onChange={(e) => handleJsonChange("action_config", e.target.value)}
|
||||
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
|
||||
<Button variant="outline">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세보기
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
되돌리기
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isUpdating ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{updateError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Edit, Settings, Code, Eye, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useButtonActions } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ButtonActionDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const actionType = params.actionType as string;
|
||||
|
||||
const { buttonActions, isLoading, error } = useButtonActions();
|
||||
const [actionData, setActionData] = useState<any>(null);
|
||||
|
||||
// 버튼 액션 데이터 로드
|
||||
useEffect(() => {
|
||||
if (buttonActions && actionType) {
|
||||
const found = buttonActions.find((ba) => ba.action_type === actionType);
|
||||
if (found) {
|
||||
setActionData(found);
|
||||
} else {
|
||||
toast.error("버튼 액션을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/button-actions");
|
||||
}
|
||||
}
|
||||
}, [buttonActions, actionType, router]);
|
||||
|
||||
// JSON 포맷팅 함수
|
||||
const formatJson = (obj: any): string => {
|
||||
if (!obj || typeof obj !== "object") return "{}";
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">버튼 액션 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">버튼 액션 정보를 불러오는데 실패했습니다.</div>
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 액션을 찾지 못한 경우
|
||||
if (!actionData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">버튼 액션을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{actionData.action_name}</h1>
|
||||
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
{actionData.confirmation_required && (
|
||||
<Badge variant="outline" className="text-orange-600">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
확인 필요
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<p className="text-muted-foreground font-mono">{actionData.action_type}</p>
|
||||
{actionData.action_name_eng && <p className="text-muted-foreground">{actionData.action_name_eng}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/system-settings/button-actions/${actionType}/edit`}>
|
||||
<Button>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
JSON 데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">액션 타입</dt>
|
||||
<dd className="font-mono text-lg">{actionData.action_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">액션명</dt>
|
||||
<dd className="text-lg">{actionData.action_name}</dd>
|
||||
</div>
|
||||
{actionData.action_name_eng && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">영문명</dt>
|
||||
<dd className="text-lg">{actionData.action_name_eng}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">카테고리</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{actionData.category}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
{actionData.description && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">설명</dt>
|
||||
<dd className="text-muted-foreground text-sm">{actionData.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{actionData.default_text && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 텍스트</dt>
|
||||
<dd className="text-lg">{actionData.default_text}</dd>
|
||||
{actionData.default_text_eng && (
|
||||
<dd className="text-muted-foreground text-sm">{actionData.default_text_eng}</dd>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{actionData.default_icon && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 아이콘</dt>
|
||||
<dd className="font-mono">{actionData.default_icon}</dd>
|
||||
</div>
|
||||
)}
|
||||
{actionData.default_color && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 색상</dt>
|
||||
<dd>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{
|
||||
borderColor: actionData.default_color,
|
||||
color: actionData.default_color,
|
||||
}}
|
||||
>
|
||||
{actionData.default_color}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{actionData.default_variant && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 변형</dt>
|
||||
<dd>
|
||||
<Badge variant="outline">{actionData.default_variant}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 확인 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>확인 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">확인 메시지 필요</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
{actionData.confirmation_required ? (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-orange-600">예</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-green-600">아니오</span>
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{actionData.confirmation_required && actionData.confirmation_message && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">확인 메시지</dt>
|
||||
<dd className="bg-muted rounded-md p-3 text-sm">{actionData.confirmation_message}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메타데이터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>메타데이터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">정렬 순서</dt>
|
||||
<dd className="text-lg">{actionData.sort_order || 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">상태</dt>
|
||||
<dd>
|
||||
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성일</dt>
|
||||
<dd className="text-sm">
|
||||
{actionData.created_date ? new Date(actionData.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성자</dt>
|
||||
<dd className="text-sm">{actionData.created_by || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">최종 수정일</dt>
|
||||
<dd className="text-sm">
|
||||
{actionData.updated_date ? new Date(actionData.updated_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">수정자</dt>
|
||||
<dd className="text-sm">{actionData.updated_by || "-"}</dd>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>유효성 검사 규칙</CardTitle>
|
||||
<CardDescription>실행 전 검증을 위한 규칙입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(actionData.validation_rules)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>액션 설정</CardTitle>
|
||||
<CardDescription>액션별 추가 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(actionData.action_config)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON 데이터 탭 */}
|
||||
<TabsContent value="json" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>전체 JSON 데이터</CardTitle>
|
||||
<CardDescription>버튼 액션의 모든 데이터를 JSON 형식으로 확인할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(actionData)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
|
||||
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
|
||||
|
||||
// 기본 변형 목록
|
||||
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
|
||||
|
||||
export default function NewButtonActionPage() {
|
||||
const router = useRouter();
|
||||
const { createButtonAction, isCreating, createError } = useButtonActions();
|
||||
|
||||
const [formData, setFormData] = useState<ButtonActionFormData>({
|
||||
action_type: "",
|
||||
action_name: "",
|
||||
action_name_eng: "",
|
||||
description: "",
|
||||
category: "general",
|
||||
default_text: "",
|
||||
default_text_eng: "",
|
||||
default_icon: "",
|
||||
default_color: "",
|
||||
default_variant: "default",
|
||||
confirmation_required: false,
|
||||
confirmation_message: "",
|
||||
validation_rules: {},
|
||||
action_config: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
validation_rules?: string;
|
||||
action_config?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
validation_rules: "{}",
|
||||
action_config: "{}",
|
||||
});
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.action_type.trim()) {
|
||||
toast.error("액션 타입을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.action_name.trim()) {
|
||||
toast.error("액션명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await createButtonAction(formData);
|
||||
toast.success("버튼 액션이 성공적으로 생성되었습니다.");
|
||||
router.push("/admin/system-settings/button-actions");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
action_type: "",
|
||||
action_name: "",
|
||||
action_name_eng: "",
|
||||
description: "",
|
||||
category: "general",
|
||||
default_text: "",
|
||||
default_text_eng: "",
|
||||
default_icon: "",
|
||||
default_color: "",
|
||||
default_variant: "default",
|
||||
confirmation_required: false,
|
||||
confirmation_message: "",
|
||||
validation_rules: {},
|
||||
action_config: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
setJsonStrings({
|
||||
validation_rules: "{}",
|
||||
action_config: "{}",
|
||||
});
|
||||
setJsonErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">새 버튼 액션 추가</h1>
|
||||
<p className="text-muted-foreground">새로운 버튼 액션을 생성하여 화면관리에서 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>버튼 액션의 기본적인 정보를 입력해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 액션 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_type">
|
||||
액션 타입 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="action_type"
|
||||
value={formData.action_type}
|
||||
onChange={(e) => handleInputChange("action_type", e.target.value)}
|
||||
placeholder="예: save, delete, edit..."
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">영문 소문자, 숫자, 언더스코어(_)만 사용 가능합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 액션명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name">
|
||||
액션명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="action_name"
|
||||
value={formData.action_name}
|
||||
onChange={(e) => handleInputChange("action_name", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="action_name_eng"
|
||||
value={formData.action_name_eng}
|
||||
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>버튼 액션의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>버튼의 기본 스타일과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 기본 텍스트 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text">기본 텍스트</Label>
|
||||
<Input
|
||||
id="default_text"
|
||||
value={formData.default_text}
|
||||
onChange={(e) => handleInputChange("default_text", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text_eng">영문 텍스트</Label>
|
||||
<Input
|
||||
id="default_text_eng"
|
||||
value={formData.default_text_eng}
|
||||
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 및 색상 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_icon">기본 아이콘</Label>
|
||||
<Input
|
||||
id="default_icon"
|
||||
value={formData.default_icon}
|
||||
onChange={(e) => handleInputChange("default_icon", e.target.value)}
|
||||
placeholder="예: Save (Lucide 아이콘명)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_color">기본 색상</Label>
|
||||
<Input
|
||||
id="default_color"
|
||||
value={formData.default_color}
|
||||
onChange={(e) => handleInputChange("default_color", e.target.value)}
|
||||
placeholder="예: blue, red, green..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_variant">기본 변형</Label>
|
||||
<Select
|
||||
value={formData.default_variant}
|
||||
onValueChange={(value) => handleInputChange("default_variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="변형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_VARIANTS.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{variant}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 확인 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>확인 설정</CardTitle>
|
||||
<CardDescription>버튼 실행 전 확인 메시지 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmation_required">확인 메시지 필요</Label>
|
||||
<p className="text-muted-foreground text-xs">버튼 실행 전 사용자 확인을 받습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="confirmation_required"
|
||||
checked={formData.confirmation_required}
|
||||
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.confirmation_required && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation_message">확인 메시지</Label>
|
||||
<Textarea
|
||||
id="confirmation_message"
|
||||
value={formData.confirmation_message}
|
||||
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
|
||||
placeholder="예: 정말로 삭제하시겠습니까?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>버튼 액션의 세부 설정을 JSON 형식으로 입력할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"requiresData": true, "minItems": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_config">액션 설정</Label>
|
||||
<Textarea
|
||||
id="action_config"
|
||||
value={jsonStrings.action_config}
|
||||
onChange={(e) => handleJsonChange("action_config", e.target.value)}
|
||||
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isCreating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{createError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Filter,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useButtonActions } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ButtonActionsManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("Y");
|
||||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 버튼 액션 데이터 조회
|
||||
const { buttonActions, isLoading, error, deleteButtonAction, isDeleting, deleteError, refetch } = useButtonActions({
|
||||
active: activeFilter || undefined,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
});
|
||||
|
||||
// 카테고리 목록 생성
|
||||
const categories = useMemo(() => {
|
||||
const uniqueCategories = Array.from(new Set(buttonActions.map((ba) => ba.category).filter(Boolean)));
|
||||
return uniqueCategories.sort();
|
||||
}, [buttonActions]);
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedButtonActions = useMemo(() => {
|
||||
let filtered = [...buttonActions];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
if (typeof bValue === "string") {
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [buttonActions, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (actionType: string, actionName: string) => {
|
||||
try {
|
||||
await deleteButtonAction(actionType);
|
||||
toast.success(`버튼 액션 '${actionName}'이 삭제되었습니다.`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("");
|
||||
setActiveFilter("Y");
|
||||
setSortField("sort_order");
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">버튼 액션 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">버튼 액션 목록을 불러오는데 실패했습니다.</div>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">버튼 액션 관리</h1>
|
||||
<p className="text-muted-foreground">화면관리에서 사용할 버튼 액션들을 관리합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/system-settings/button-actions/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />새 버튼 액션 추가
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Filter className="h-5 w-5" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="액션명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체 카테고리</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
<SelectItem value="Y">활성화</SelectItem>
|
||||
<SelectItem value="N">비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 결과 통계 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
총 {filteredAndSortedButtonActions.length}개의 버튼 액션이 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 버튼 액션 목록 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
|
||||
<div className="flex items-center gap-2">
|
||||
순서
|
||||
{sortField === "sort_order" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_type")}>
|
||||
<div className="flex items-center gap-2">
|
||||
액션 타입
|
||||
{sortField === "action_type" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
액션명
|
||||
{sortField === "action_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
|
||||
<div className="flex items-center gap-2">
|
||||
카테고리
|
||||
{sortField === "category" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>기본 텍스트</TableHead>
|
||||
<TableHead>확인 필요</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
|
||||
<div className="flex items-center gap-2">
|
||||
상태
|
||||
{sortField === "is_active" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
|
||||
<div className="flex items-center gap-2">
|
||||
최종 수정일
|
||||
{sortField === "updated_date" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedButtonActions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="py-8 text-center">
|
||||
조건에 맞는 버튼 액션이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedButtonActions.map((action) => (
|
||||
<TableRow key={action.action_type}>
|
||||
<TableCell className="font-mono">{action.sort_order || 0}</TableCell>
|
||||
<TableCell className="font-mono">{action.action_type}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{action.action_name}
|
||||
{action.action_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{action.action_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{action.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{action.default_text || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{action.confirmation_required ? (
|
||||
<div className="flex items-center gap-1 text-orange-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-xs">필요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-xs">불필요</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{action.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={action.is_active === "Y" ? "default" : "secondary"}>
|
||||
{action.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{action.updated_date ? new Date(action.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/system-settings/button-actions/${action.action_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/system-settings/button-actions/${action.action_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>버튼 액션 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{action.action_name}' 버튼 액션을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(action.action_type, action.action_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
|
||||
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
|
||||
|
||||
export default function EditWebTypePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const webType = params.webType as string;
|
||||
|
||||
const { webTypes, updateWebType, isUpdating, updateError, isLoading } = useWebTypes();
|
||||
|
||||
const [formData, setFormData] = useState<Partial<WebTypeFormData>>({});
|
||||
const [originalData, setOriginalData] = useState<any>(null);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
default_config?: string;
|
||||
validation_rules?: string;
|
||||
default_style?: string;
|
||||
input_properties?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
|
||||
// 웹타입 데이터 로드
|
||||
useEffect(() => {
|
||||
if (webTypes && webType && !isDataLoaded) {
|
||||
const found = webTypes.find((wt) => wt.web_type === webType);
|
||||
if (found) {
|
||||
setOriginalData(found);
|
||||
setFormData({
|
||||
type_name: found.type_name,
|
||||
type_name_eng: found.type_name_eng || "",
|
||||
description: found.description || "",
|
||||
category: found.category,
|
||||
default_config: found.default_config || {},
|
||||
validation_rules: found.validation_rules || {},
|
||||
default_style: found.default_style || {},
|
||||
input_properties: found.input_properties || {},
|
||||
sort_order: found.sort_order || 0,
|
||||
is_active: found.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: JSON.stringify(found.default_config || {}, null, 2),
|
||||
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
|
||||
default_style: JSON.stringify(found.default_style || {}, null, 2),
|
||||
input_properties: JSON.stringify(found.input_properties || {}, null, 2),
|
||||
});
|
||||
setIsDataLoaded(true);
|
||||
} else {
|
||||
toast.error("웹타입을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/web-types");
|
||||
}
|
||||
}
|
||||
}, [webTypes, webType, isDataLoaded, router]);
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (
|
||||
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
|
||||
value: string,
|
||||
) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.type_name?.trim()) {
|
||||
toast.error("웹타입명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await updateWebType(webType, formData);
|
||||
toast.success("웹타입이 성공적으로 수정되었습니다.");
|
||||
router.push(`/admin/system-settings/web-types/${webType}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화 (원본 데이터로 되돌리기)
|
||||
const handleReset = () => {
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
type_name: originalData.type_name,
|
||||
type_name_eng: originalData.type_name_eng || "",
|
||||
description: originalData.description || "",
|
||||
category: originalData.category,
|
||||
default_config: originalData.default_config || {},
|
||||
validation_rules: originalData.validation_rules || {},
|
||||
default_style: originalData.default_style || {},
|
||||
input_properties: originalData.input_properties || {},
|
||||
sort_order: originalData.sort_order || 0,
|
||||
is_active: originalData.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: JSON.stringify(originalData.default_config || {}, null, 2),
|
||||
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
|
||||
default_style: JSON.stringify(originalData.default_style || {}, null, 2),
|
||||
input_properties: JSON.stringify(originalData.input_properties || {}, null, 2),
|
||||
});
|
||||
setJsonErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading || !isDataLoaded) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 웹타입을 찾지 못한 경우
|
||||
if (!originalData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">웹타입을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href={`/admin/system-settings/web-types/${webType}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
상세보기로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">웹타입 편집</h1>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{webType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{originalData.type_name} 웹타입의 정보를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>웹타입의 기본적인 정보를 수정해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 웹타입 코드 (읽기 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">웹타입 코드</Label>
|
||||
<Input id="web_type" value={webType} disabled className="bg-muted font-mono" />
|
||||
<p className="text-muted-foreground text-xs">웹타입 코드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
value={formData.type_name || ""}
|
||||
onChange={(e) => handleInputChange("type_name", e.target.value)}
|
||||
placeholder="예: 텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="type_name_eng"
|
||||
value={formData.type_name_eng || ""}
|
||||
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
|
||||
placeholder="예: Text Input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="웹타입에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>웹타입의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>웹타입의 세부 설정을 JSON 형식으로 수정할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_config">기본 설정</Label>
|
||||
<Textarea
|
||||
id="default_config"
|
||||
value={jsonStrings.default_config}
|
||||
onChange={(e) => handleJsonChange("default_config", e.target.value)}
|
||||
placeholder='{"placeholder": "입력하세요..."}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"required": true, "minLength": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_style">기본 스타일</Label>
|
||||
<Textarea
|
||||
id="default_style"
|
||||
value={jsonStrings.default_style}
|
||||
onChange={(e) => handleJsonChange("default_style", e.target.value)}
|
||||
placeholder='{"width": "100%", "height": "40px"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input_properties">HTML 입력 속성</Label>
|
||||
<Textarea
|
||||
id="input_properties"
|
||||
value={jsonStrings.input_properties}
|
||||
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
|
||||
placeholder='{"type": "text", "autoComplete": "off"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Link href={`/admin/system-settings/web-types/${webType}`}>
|
||||
<Button variant="outline">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세보기
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
되돌리기
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isUpdating ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{updateError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Edit, Settings, Code, Eye } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function WebTypeDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const webType = params.webType as string;
|
||||
|
||||
const { webTypes, isLoading, error } = useWebTypes();
|
||||
const [webTypeData, setWebTypeData] = useState<any>(null);
|
||||
|
||||
// 웹타입 데이터 로드
|
||||
useEffect(() => {
|
||||
if (webTypes && webType) {
|
||||
const found = webTypes.find((wt) => wt.web_type === webType);
|
||||
if (found) {
|
||||
setWebTypeData(found);
|
||||
} else {
|
||||
toast.error("웹타입을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/web-types");
|
||||
}
|
||||
}
|
||||
}, [webTypes, webType, router]);
|
||||
|
||||
// JSON 포맷팅 함수
|
||||
const formatJson = (obj: any): string => {
|
||||
if (!obj || typeof obj !== "object") return "{}";
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 정보를 불러오는데 실패했습니다.</div>
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 웹타입을 찾지 못한 경우
|
||||
if (!webTypeData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">웹타입을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{webTypeData.type_name}</h1>
|
||||
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<p className="text-muted-foreground font-mono">{webTypeData.web_type}</p>
|
||||
{webTypeData.type_name_eng && <p className="text-muted-foreground">{webTypeData.type_name_eng}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/system-settings/web-types/${webType}/edit`}>
|
||||
<Button>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
JSON 데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">웹타입 코드</dt>
|
||||
<dd className="font-mono text-lg">{webTypeData.web_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">웹타입명</dt>
|
||||
<dd className="text-lg">{webTypeData.type_name}</dd>
|
||||
</div>
|
||||
{webTypeData.type_name_eng && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">영문명</dt>
|
||||
<dd className="text-lg">{webTypeData.type_name_eng}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">카테고리</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{webTypeData.category}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
{webTypeData.description && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">설명</dt>
|
||||
<dd className="text-muted-foreground text-sm">{webTypeData.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메타데이터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>메타데이터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">정렬 순서</dt>
|
||||
<dd className="text-lg">{webTypeData.sort_order || 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">상태</dt>
|
||||
<dd>
|
||||
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성일</dt>
|
||||
<dd className="text-sm">
|
||||
{webTypeData.created_date ? new Date(webTypeData.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성자</dt>
|
||||
<dd className="text-sm">{webTypeData.created_by || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">최종 수정일</dt>
|
||||
<dd className="text-sm">
|
||||
{webTypeData.updated_date ? new Date(webTypeData.updated_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">수정자</dt>
|
||||
<dd className="text-sm">{webTypeData.updated_by || "-"}</dd>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>웹타입의 기본 동작 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.default_config)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>유효성 검사 규칙</CardTitle>
|
||||
<CardDescription>입력값 검증을 위한 규칙입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.validation_rules)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 스타일</CardTitle>
|
||||
<CardDescription>웹타입의 기본 스타일 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.default_style)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* HTML 입력 속성 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>HTML 입력 속성</CardTitle>
|
||||
<CardDescription>HTML 요소에 적용될 기본 속성입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.input_properties)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON 데이터 탭 */}
|
||||
<TabsContent value="json" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>전체 JSON 데이터</CardTitle>
|
||||
<CardDescription>웹타입의 모든 데이터를 JSON 형식으로 확인할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(webTypeData)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
|
||||
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
|
||||
|
||||
export default function NewWebTypePage() {
|
||||
const router = useRouter();
|
||||
const { createWebType, isCreating, createError } = useWebTypes();
|
||||
|
||||
const [formData, setFormData] = useState<WebTypeFormData>({
|
||||
web_type: "",
|
||||
type_name: "",
|
||||
type_name_eng: "",
|
||||
description: "",
|
||||
category: "input",
|
||||
default_config: {},
|
||||
validation_rules: {},
|
||||
default_style: {},
|
||||
input_properties: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
default_config?: string;
|
||||
validation_rules?: string;
|
||||
default_style?: string;
|
||||
input_properties?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (
|
||||
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
|
||||
value: string,
|
||||
) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.web_type.trim()) {
|
||||
toast.error("웹타입 코드를 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.type_name.trim()) {
|
||||
toast.error("웹타입명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await createWebType(formData);
|
||||
toast.success("웹타입이 성공적으로 생성되었습니다.");
|
||||
router.push("/admin/system-settings/web-types");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
web_type: "",
|
||||
type_name: "",
|
||||
type_name_eng: "",
|
||||
description: "",
|
||||
category: "input",
|
||||
default_config: {},
|
||||
validation_rules: {},
|
||||
default_style: {},
|
||||
input_properties: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
setJsonErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">새 웹타입 추가</h1>
|
||||
<p className="text-muted-foreground">새로운 웹타입을 생성하여 화면관리에서 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>웹타입의 기본적인 정보를 입력해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 웹타입 코드 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">
|
||||
웹타입 코드 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="web_type"
|
||||
value={formData.web_type}
|
||||
onChange={(e) => handleInputChange("web_type", e.target.value)}
|
||||
placeholder="예: text, number, email..."
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">영문 소문자, 숫자, 언더스코어(_)만 사용 가능합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
value={formData.type_name}
|
||||
onChange={(e) => handleInputChange("type_name", e.target.value)}
|
||||
placeholder="예: 텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="type_name_eng"
|
||||
value={formData.type_name_eng}
|
||||
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
|
||||
placeholder="예: Text Input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="웹타입에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>웹타입의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>웹타입의 세부 설정을 JSON 형식으로 입력할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_config">기본 설정</Label>
|
||||
<Textarea
|
||||
id="default_config"
|
||||
value={jsonStrings.default_config}
|
||||
onChange={(e) => handleJsonChange("default_config", e.target.value)}
|
||||
placeholder='{"placeholder": "입력하세요..."}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"required": true, "minLength": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_style">기본 스타일</Label>
|
||||
<Textarea
|
||||
id="default_style"
|
||||
value={jsonStrings.default_style}
|
||||
onChange={(e) => handleJsonChange("default_style", e.target.value)}
|
||||
placeholder='{"width": "100%", "height": "40px"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input_properties">HTML 입력 속성</Label>
|
||||
<Textarea
|
||||
id="input_properties"
|
||||
value={jsonStrings.input_properties}
|
||||
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
|
||||
placeholder='{"type": "text", "autoComplete": "off"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isCreating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{createError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function WebTypesManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("Y");
|
||||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 웹타입 데이터 조회
|
||||
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
|
||||
active: activeFilter || undefined,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
});
|
||||
|
||||
// 카테고리 목록 생성
|
||||
const categories = useMemo(() => {
|
||||
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
|
||||
return uniqueCategories.sort();
|
||||
}, [webTypes]);
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedWebTypes = useMemo(() => {
|
||||
let filtered = [...webTypes];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
if (typeof bValue === "string") {
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [webTypes, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (webType: string, typeName: string) => {
|
||||
try {
|
||||
await deleteWebType(webType);
|
||||
toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("");
|
||||
setActiveFilter("Y");
|
||||
setSortField("sort_order");
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 목록을 불러오는데 실패했습니다.</div>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">웹타입 관리</h1>
|
||||
<p className="text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/system-settings/web-types/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />새 웹타입 추가
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Filter className="h-5 w-5" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="웹타입명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체 카테고리</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
<SelectItem value="Y">활성화</SelectItem>
|
||||
<SelectItem value="N">비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 결과 통계 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground text-sm">총 {filteredAndSortedWebTypes.length}개의 웹타입이 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입 목록 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
|
||||
<div className="flex items-center gap-2">
|
||||
순서
|
||||
{sortField === "sort_order" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입 코드
|
||||
{sortField === "web_type" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입명
|
||||
{sortField === "type_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
|
||||
<div className="flex items-center gap-2">
|
||||
카테고리
|
||||
{sortField === "category" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
|
||||
<div className="flex items-center gap-2">
|
||||
상태
|
||||
{sortField === "is_active" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
|
||||
<div className="flex items-center gap-2">
|
||||
최종 수정일
|
||||
{sortField === "updated_date" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedWebTypes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center">
|
||||
조건에 맞는 웹타입이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedWebTypes.map((webType) => (
|
||||
<TableRow key={webType.web_type}>
|
||||
<TableCell className="font-mono">{webType.sort_order || 0}</TableCell>
|
||||
<TableCell className="font-mono">{webType.web_type}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{webType.type_name}
|
||||
{webType.type_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{webType.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{webType.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={webType.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webType.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/system-settings/web-types/${webType.web_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/system-settings/web-types/${webType.web_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>웹타입 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{webType.type_name}' 웹타입을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(webType.web_type, webType.type_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
|
||||
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
|
||||
import { AVAILABLE_COMPONENTS, getComponentInfo } from "@/lib/utils/availableComponents";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
|
||||
|
||||
export default function EditWebTypePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const webType = params.webType as string;
|
||||
|
||||
const { webTypes, updateWebType, isUpdating, updateError, isLoading } = useWebTypes();
|
||||
|
||||
const [formData, setFormData] = useState<Partial<WebTypeFormData>>({});
|
||||
const [originalData, setOriginalData] = useState<any>(null);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
default_config?: string;
|
||||
validation_rules?: string;
|
||||
default_style?: string;
|
||||
input_properties?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
|
||||
// 웹타입 데이터 로드
|
||||
useEffect(() => {
|
||||
if (webTypes && webType && !isDataLoaded) {
|
||||
const found = webTypes.find((wt) => wt.web_type === webType);
|
||||
if (found) {
|
||||
setOriginalData(found);
|
||||
setFormData({
|
||||
type_name: found.type_name,
|
||||
type_name_eng: found.type_name_eng || "",
|
||||
description: found.description || "",
|
||||
category: found.category,
|
||||
component_name: found.component_name || "TextWidget",
|
||||
default_config: found.default_config || {},
|
||||
validation_rules: found.validation_rules || {},
|
||||
default_style: found.default_style || {},
|
||||
input_properties: found.input_properties || {},
|
||||
sort_order: found.sort_order || 0,
|
||||
is_active: found.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: JSON.stringify(found.default_config || {}, null, 2),
|
||||
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
|
||||
default_style: JSON.stringify(found.default_style || {}, null, 2),
|
||||
input_properties: JSON.stringify(found.input_properties || {}, null, 2),
|
||||
});
|
||||
setIsDataLoaded(true);
|
||||
} else {
|
||||
toast.error("웹타입을 찾을 수 없습니다.");
|
||||
router.push("/admin/standards");
|
||||
}
|
||||
}
|
||||
}, [webTypes, webType, isDataLoaded, router]);
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (
|
||||
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
|
||||
value: string,
|
||||
) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.type_name?.trim()) {
|
||||
toast.error("웹타입명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await updateWebType(webType, formData);
|
||||
toast.success("웹타입이 성공적으로 수정되었습니다.");
|
||||
router.push(`/admin/standards/${webType}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화 (원본 데이터로 되돌리기)
|
||||
const handleReset = () => {
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
type_name: originalData.type_name,
|
||||
type_name_eng: originalData.type_name_eng || "",
|
||||
description: originalData.description || "",
|
||||
category: originalData.category,
|
||||
component_name: originalData.component_name || "TextWidget",
|
||||
default_config: originalData.default_config || {},
|
||||
validation_rules: originalData.validation_rules || {},
|
||||
default_style: originalData.default_style || {},
|
||||
input_properties: originalData.input_properties || {},
|
||||
sort_order: originalData.sort_order || 0,
|
||||
is_active: originalData.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: JSON.stringify(originalData.default_config || {}, null, 2),
|
||||
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
|
||||
default_style: JSON.stringify(originalData.default_style || {}, null, 2),
|
||||
input_properties: JSON.stringify(originalData.input_properties || {}, null, 2),
|
||||
});
|
||||
setJsonErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading || !isDataLoaded) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 웹타입을 찾지 못한 경우
|
||||
if (!originalData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">웹타입을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/standards">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href={`/admin/standards/${webType}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
상세보기로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">웹타입 편집</h1>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{webType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{originalData.type_name} 웹타입의 정보를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>웹타입의 기본적인 정보를 수정해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 웹타입 코드 (읽기 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">웹타입 코드</Label>
|
||||
<Input id="web_type" value={webType} disabled className="bg-muted font-mono" />
|
||||
<p className="text-muted-foreground text-xs">웹타입 코드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
value={formData.type_name || ""}
|
||||
onChange={(e) => handleInputChange("type_name", e.target.value)}
|
||||
placeholder="예: 텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="type_name_eng"
|
||||
value={formData.type_name_eng || ""}
|
||||
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
|
||||
placeholder="예: Text Input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 연결된 컴포넌트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="component_name">
|
||||
연결된 컴포넌트 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.component_name || "TextWidget"}
|
||||
onValueChange={(value) => handleInputChange("component_name", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_COMPONENTS.map((component) => (
|
||||
<SelectItem key={component.value} value={component.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{component.label}</span>
|
||||
<span className="text-muted-foreground text-xs">{component.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.component_name && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
현재 선택:{" "}
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{formData.component_name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="웹타입에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>웹타입의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>웹타입의 세부 설정을 JSON 형식으로 수정할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_config">기본 설정</Label>
|
||||
<Textarea
|
||||
id="default_config"
|
||||
value={jsonStrings.default_config}
|
||||
onChange={(e) => handleJsonChange("default_config", e.target.value)}
|
||||
placeholder='{"placeholder": "입력하세요..."}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"required": true, "minLength": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_style">기본 스타일</Label>
|
||||
<Textarea
|
||||
id="default_style"
|
||||
value={jsonStrings.default_style}
|
||||
onChange={(e) => handleJsonChange("default_style", e.target.value)}
|
||||
placeholder='{"width": "100%", "height": "40px"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input_properties">HTML 입력 속성</Label>
|
||||
<Textarea
|
||||
id="input_properties"
|
||||
value={jsonStrings.input_properties}
|
||||
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
|
||||
placeholder='{"type": "text", "autoComplete": "off"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Link href={`/admin/standards/${webType}`}>
|
||||
<Button variant="outline">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세보기
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
되돌리기
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isUpdating ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{updateError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Edit, Settings, Code, Eye } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function WebTypeDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const webType = params.webType as string;
|
||||
|
||||
const { webTypes, isLoading, error } = useWebTypes();
|
||||
const [webTypeData, setWebTypeData] = useState<any>(null);
|
||||
|
||||
// 웹타입 데이터 로드
|
||||
useEffect(() => {
|
||||
if (webTypes && webType) {
|
||||
const found = webTypes.find((wt) => wt.web_type === webType);
|
||||
if (found) {
|
||||
setWebTypeData(found);
|
||||
} else {
|
||||
toast.error("웹타입을 찾을 수 없습니다.");
|
||||
router.push("/admin/standards");
|
||||
}
|
||||
}
|
||||
}, [webTypes, webType, router]);
|
||||
|
||||
// JSON 포맷팅 함수
|
||||
const formatJson = (obj: any): string => {
|
||||
if (!obj || typeof obj !== "object") return "{}";
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 정보를 불러오는데 실패했습니다.</div>
|
||||
<Link href="/admin/standards">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 웹타입을 찾지 못한 경우
|
||||
if (!webTypeData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">웹타입을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/standards">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/standards">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{webTypeData.type_name}</h1>
|
||||
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<p className="text-muted-foreground font-mono">{webTypeData.web_type}</p>
|
||||
{webTypeData.type_name_eng && <p className="text-muted-foreground">{webTypeData.type_name_eng}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/standards/${webType}/edit`}>
|
||||
<Button>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
JSON 데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">웹타입 코드</dt>
|
||||
<dd className="font-mono text-lg">{webTypeData.web_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">웹타입명</dt>
|
||||
<dd className="text-lg">{webTypeData.type_name}</dd>
|
||||
</div>
|
||||
{webTypeData.type_name_eng && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">영문명</dt>
|
||||
<dd className="text-lg">{webTypeData.type_name_eng}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">카테고리</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{webTypeData.category}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
{webTypeData.description && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">설명</dt>
|
||||
<dd className="text-muted-foreground text-sm">{webTypeData.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메타데이터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>메타데이터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">정렬 순서</dt>
|
||||
<dd className="text-lg">{webTypeData.sort_order || 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">상태</dt>
|
||||
<dd>
|
||||
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성일</dt>
|
||||
<dd className="text-sm">
|
||||
{webTypeData.created_date ? new Date(webTypeData.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성자</dt>
|
||||
<dd className="text-sm">{webTypeData.created_by || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">최종 수정일</dt>
|
||||
<dd className="text-sm">
|
||||
{webTypeData.updated_date ? new Date(webTypeData.updated_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">수정자</dt>
|
||||
<dd className="text-sm">{webTypeData.updated_by || "-"}</dd>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>웹타입의 기본 동작 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.default_config)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>유효성 검사 규칙</CardTitle>
|
||||
<CardDescription>입력값 검증을 위한 규칙입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.validation_rules)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 스타일</CardTitle>
|
||||
<CardDescription>웹타입의 기본 스타일 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.default_style)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* HTML 입력 속성 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>HTML 입력 속성</CardTitle>
|
||||
<CardDescription>HTML 요소에 적용될 기본 속성입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.input_properties)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON 데이터 탭 */}
|
||||
<TabsContent value="json" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>전체 JSON 데이터</CardTitle>
|
||||
<CardDescription>웹타입의 모든 데이터를 JSON 형식으로 확인할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(webTypeData)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
|
||||
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
|
||||
import { AVAILABLE_COMPONENTS } from "@/lib/utils/availableComponents";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
|
||||
|
||||
export default function NewWebTypePage() {
|
||||
const router = useRouter();
|
||||
const { createWebType, isCreating, createError } = useWebTypes();
|
||||
|
||||
const [formData, setFormData] = useState<WebTypeFormData>({
|
||||
web_type: "",
|
||||
type_name: "",
|
||||
type_name_eng: "",
|
||||
description: "",
|
||||
category: "input",
|
||||
component_name: "TextWidget",
|
||||
default_config: {},
|
||||
validation_rules: {},
|
||||
default_style: {},
|
||||
input_properties: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
default_config?: string;
|
||||
validation_rules?: string;
|
||||
default_style?: string;
|
||||
input_properties?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (
|
||||
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
|
||||
value: string,
|
||||
) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.web_type.trim()) {
|
||||
toast.error("웹타입 코드를 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.type_name.trim()) {
|
||||
toast.error("웹타입명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await createWebType(formData);
|
||||
toast.success("웹타입이 성공적으로 생성되었습니다.");
|
||||
router.push("/admin/standards");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
web_type: "",
|
||||
type_name: "",
|
||||
type_name_eng: "",
|
||||
description: "",
|
||||
category: "input",
|
||||
component_name: "TextWidget",
|
||||
default_config: {},
|
||||
validation_rules: {},
|
||||
default_style: {},
|
||||
input_properties: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
setJsonErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href="/admin/standards">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">새 웹타입 추가</h1>
|
||||
<p className="text-muted-foreground">새로운 웹타입을 생성하여 화면관리에서 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>웹타입의 기본적인 정보를 입력해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 웹타입 코드 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">
|
||||
웹타입 코드 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="web_type"
|
||||
value={formData.web_type}
|
||||
onChange={(e) => handleInputChange("web_type", e.target.value)}
|
||||
placeholder="예: text, number, email..."
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">영문 소문자, 숫자, 언더스코어(_)만 사용 가능합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
value={formData.type_name}
|
||||
onChange={(e) => handleInputChange("type_name", e.target.value)}
|
||||
placeholder="예: 텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="type_name_eng"
|
||||
value={formData.type_name_eng}
|
||||
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
|
||||
placeholder="예: Text Input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 연결된 컴포넌트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="component_name">
|
||||
연결된 컴포넌트 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.component_name || "TextWidget"}
|
||||
onValueChange={(value) => handleInputChange("component_name", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_COMPONENTS.map((component) => (
|
||||
<SelectItem key={component.value} value={component.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{component.label}</span>
|
||||
<span className="text-muted-foreground text-xs">{component.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.component_name && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
현재 선택:{" "}
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{formData.component_name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="웹타입에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>웹타입의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>웹타입의 세부 설정을 JSON 형식으로 입력할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_config">기본 설정</Label>
|
||||
<Textarea
|
||||
id="default_config"
|
||||
value={jsonStrings.default_config}
|
||||
onChange={(e) => handleJsonChange("default_config", e.target.value)}
|
||||
placeholder='{"placeholder": "입력하세요..."}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"required": true, "minLength": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_style">기본 스타일</Label>
|
||||
<Textarea
|
||||
id="default_style"
|
||||
value={jsonStrings.default_style}
|
||||
onChange={(e) => handleJsonChange("default_style", e.target.value)}
|
||||
placeholder='{"width": "100%", "height": "40px"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input_properties">HTML 입력 속성</Label>
|
||||
<Textarea
|
||||
id="input_properties"
|
||||
value={jsonStrings.input_properties}
|
||||
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
|
||||
placeholder='{"type": "text", "autoComplete": "off"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isCreating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{createError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function WebTypesManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("Y");
|
||||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 웹타입 데이터 조회
|
||||
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
|
||||
active: activeFilter === "all" ? undefined : activeFilter,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter === "all" ? undefined : categoryFilter,
|
||||
});
|
||||
|
||||
// 카테고리 목록 생성
|
||||
const categories = useMemo(() => {
|
||||
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
|
||||
return uniqueCategories.sort();
|
||||
}, [webTypes]);
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedWebTypes = useMemo(() => {
|
||||
let filtered = [...webTypes];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
if (typeof bValue === "string") {
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [webTypes, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (webType: string, typeName: string) => {
|
||||
try {
|
||||
await deleteWebType(webType);
|
||||
toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("all");
|
||||
setActiveFilter("Y");
|
||||
setSortField("sort_order");
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 목록을 불러오는데 실패했습니다.</div>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">웹타입 관리</h1>
|
||||
<p className="text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/standards/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />새 웹타입 추가
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Filter className="h-5 w-5" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="웹타입명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 카테고리</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성화</SelectItem>
|
||||
<SelectItem value="N">비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 결과 통계 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground text-sm">총 {filteredAndSortedWebTypes.length}개의 웹타입이 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입 목록 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
|
||||
<div className="flex items-center gap-2">
|
||||
순서
|
||||
{sortField === "sort_order" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입 코드
|
||||
{sortField === "web_type" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입명
|
||||
{sortField === "type_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
|
||||
<div className="flex items-center gap-2">
|
||||
카테고리
|
||||
{sortField === "category" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("component_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
연결된 컴포넌트
|
||||
{sortField === "component_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
|
||||
<div className="flex items-center gap-2">
|
||||
상태
|
||||
{sortField === "is_active" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
|
||||
<div className="flex items-center gap-2">
|
||||
최종 수정일
|
||||
{sortField === "updated_date" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedWebTypes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="py-8 text-center">
|
||||
조건에 맞는 웹타입이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedWebTypes.map((webType) => (
|
||||
<TableRow key={webType.web_type}>
|
||||
<TableCell className="font-mono">{webType.sort_order || 0}</TableCell>
|
||||
<TableCell className="font-mono">{webType.web_type}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{webType.type_name}
|
||||
{webType.type_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{webType.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{webType.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{webType.component_name || "TextWidget"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={webType.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webType.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/standards/${webType.web_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/standards/${webType.web_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>웹타입 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{webType.type_name}' 웹타입을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(webType.web_type, webType.type_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer";
|
||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -41,7 +42,7 @@ export default function RootLayout({
|
|||
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
|
||||
<div id="root" className="h-full">
|
||||
<QueryProvider>
|
||||
{children}
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
</QueryProvider>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { initializeRegistries } from "@/lib/registry/init";
|
||||
|
||||
interface RegistryProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 초기화 프로바이더
|
||||
* 앱 시작 시 모든 웹타입과 버튼 액션을 등록합니다.
|
||||
*/
|
||||
export function RegistryProvider({ children }: RegistryProviderProps) {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 레지스트리 초기화
|
||||
try {
|
||||
initializeRegistries();
|
||||
setIsInitialized(true);
|
||||
console.log("✅ 레지스트리 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 레지스트리 초기화 실패:", error);
|
||||
setIsInitialized(true); // 오류가 있어도 앱은 계속 실행
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기화 중 로딩 표시 (선택사항)
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="border-primary h-12 w-12 animate-spin rounded-full border-b-2"></div>
|
||||
<p className="text-muted-foreground text-sm">시스템 초기화 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 초기화 상태를 확인하는 훅
|
||||
*/
|
||||
export function useRegistryInitialization() {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
initializeRegistries();
|
||||
setIsInitialized(true);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isInitialized, error };
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
|
||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
import { useParams } from "next/navigation";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
interface InteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
allComponents: ComponentData[];
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
hideLabel?: boolean;
|
||||
screenInfo?: {
|
||||
id: number;
|
||||
tableName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
component,
|
||||
allComponents,
|
||||
formData: externalFormData,
|
||||
onFormDataChange,
|
||||
hideLabel = false,
|
||||
screenInfo,
|
||||
}) => {
|
||||
const { userName, user } = useAuth();
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||
|
||||
// 팝업 화면 상태
|
||||
const [popupScreen, setPopupScreen] = useState<{
|
||||
screenId: number;
|
||||
title: string;
|
||||
size: string;
|
||||
} | null>(null);
|
||||
|
||||
// 팝업 화면 레이아웃 상태
|
||||
const [popupLayout, setPopupLayout] = useState<ComponentData[]>([]);
|
||||
const [popupLoading, setPopupLoading] = useState(false);
|
||||
const [popupScreenResolution, setPopupScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
const [popupScreenInfo, setPopupScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||
|
||||
// 팝업 전용 formData 상태
|
||||
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
|
||||
const formData = externalFormData || localFormData;
|
||||
|
||||
// 자동값 생성 함수
|
||||
const generateAutoValue = useCallback(
|
||||
(autoValueType: string): string => {
|
||||
const now = new Date();
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
return now.toISOString().slice(0, 19).replace("T", " ");
|
||||
case "current_date":
|
||||
return now.toISOString().slice(0, 10);
|
||||
case "current_time":
|
||||
return now.toTimeString().slice(0, 8);
|
||||
case "current_user":
|
||||
return userName || "사용자";
|
||||
case "uuid":
|
||||
return crypto.randomUUID();
|
||||
case "sequence":
|
||||
return `SEQ_${Date.now()}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
[userName],
|
||||
);
|
||||
|
||||
// 팝업 화면 레이아웃 로드
|
||||
React.useEffect(() => {
|
||||
if (popupScreen?.screenId) {
|
||||
loadPopupScreen(popupScreen.screenId);
|
||||
}
|
||||
}, [popupScreen?.screenId]);
|
||||
|
||||
const loadPopupScreen = async (screenId: number) => {
|
||||
try {
|
||||
setPopupLoading(true);
|
||||
const response = await screenApi.getScreenLayout(screenId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const screenData = response.data;
|
||||
setPopupLayout(screenData.components || []);
|
||||
setPopupScreenResolution({
|
||||
width: screenData.screenResolution?.width || 1200,
|
||||
height: screenData.screenResolution?.height || 800,
|
||||
});
|
||||
setPopupScreenInfo({
|
||||
id: screenData.id,
|
||||
tableName: screenData.tableName,
|
||||
});
|
||||
} else {
|
||||
toast.error("팝업 화면을 불러올 수 없습니다.");
|
||||
setPopupScreen(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("팝업 화면 로드 오류:", error);
|
||||
toast.error("팝업 화면 로드 중 오류가 발생했습니다.");
|
||||
setPopupScreen(null);
|
||||
} finally {
|
||||
setPopupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 데이터 변경 핸들러
|
||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(fieldName, value);
|
||||
} else {
|
||||
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
// 동적 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (comp.type === "datatable") {
|
||||
return (
|
||||
<InteractiveDataTable
|
||||
component={comp as DataTableComponent}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 컴포넌트 처리
|
||||
if (comp.type === "button") {
|
||||
return renderButton(comp);
|
||||
}
|
||||
|
||||
// 파일 컴포넌트 처리
|
||||
if (comp.type === "file") {
|
||||
return renderFileComponent(comp as FileComponent);
|
||||
}
|
||||
|
||||
// 위젯 컴포넌트가 아닌 경우
|
||||
if (comp.type !== "widget") {
|
||||
return <div className="text-sm text-gray-500">지원되지 않는 컴포넌트 타입</div>;
|
||||
}
|
||||
|
||||
const widget = comp as WidgetComponent;
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = widget;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
||||
// 스타일 적용
|
||||
const applyStyles = (element: React.ReactElement) => {
|
||||
if (!comp.style) return element;
|
||||
|
||||
return React.cloneElement(element, {
|
||||
style: {
|
||||
...element.props.style,
|
||||
...comp.style,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
maxHeight: "100%",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
if (widgetType) {
|
||||
try {
|
||||
const dynamicElement = (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={widgetType}
|
||||
props={{
|
||||
component: widget,
|
||||
value: currentValue,
|
||||
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
||||
readonly: readonly,
|
||||
required: required,
|
||||
placeholder: placeholder,
|
||||
className: "w-full h-full",
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
onEvent={(event: string, data: any) => {
|
||||
// 이벤트 처리
|
||||
console.log(`Widget event: ${event}`, data);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return applyStyles(dynamicElement);
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${widgetType}" 대화형 렌더링 실패:`, error);
|
||||
// 오류 발생 시 폴백으로 기본 input 렌더링
|
||||
const fallbackElement = (
|
||||
<Input
|
||||
type="text"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||
placeholder={`${widgetType} (렌더링 오류)`}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
);
|
||||
return applyStyles(fallbackElement);
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
|
||||
const defaultElement = (
|
||||
<Input
|
||||
type="text"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||
placeholder={placeholder || "입력하세요"}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
);
|
||||
return applyStyles(defaultElement);
|
||||
};
|
||||
|
||||
// 버튼 렌더링
|
||||
const renderButton = (comp: ComponentData) => {
|
||||
const config = (comp as any).webTypeConfig as ButtonTypeConfig | undefined;
|
||||
const { label } = comp;
|
||||
|
||||
// 버튼 액션 핸들러들
|
||||
const handleSaveAction = async () => {
|
||||
if (!screenInfo?.tableName) {
|
||||
toast.error("테이블명이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saveData: DynamicFormData = {
|
||||
tableName: screenInfo.tableName,
|
||||
data: formData,
|
||||
};
|
||||
|
||||
console.log("💾 저장 액션 실행:", saveData);
|
||||
const response = await dynamicFormApi.saveData(saveData);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 오류:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAction = async () => {
|
||||
if (!config?.confirmationEnabled || window.confirm(config.confirmationMessage || "정말 삭제하시겠습니까?")) {
|
||||
console.log("🗑️ 삭제 액션 실행");
|
||||
toast.success("삭제가 완료되었습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopupAction = () => {
|
||||
if (config?.popupScreenId) {
|
||||
setPopupScreen({
|
||||
screenId: config.popupScreenId,
|
||||
title: config.popupTitle || "팝업 화면",
|
||||
size: config.popupSize || "medium",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateAction = () => {
|
||||
const navigateType = config?.navigateType || "url";
|
||||
|
||||
if (navigateType === "screen" && config?.navigateScreenId) {
|
||||
const screenPath = `/screens/${config.navigateScreenId}`;
|
||||
|
||||
if (config.navigateTarget === "_blank") {
|
||||
window.open(screenPath, "_blank");
|
||||
} else {
|
||||
window.location.href = screenPath;
|
||||
}
|
||||
} else if (navigateType === "url" && config?.navigateUrl) {
|
||||
if (config.navigateTarget === "_blank") {
|
||||
window.open(config.navigateUrl, "_blank");
|
||||
} else {
|
||||
window.location.href = config.navigateUrl;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomAction = async () => {
|
||||
if (config?.customAction) {
|
||||
try {
|
||||
const result = eval(config.customAction);
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
console.log("⚡ 커스텀 액션 실행 완료");
|
||||
} catch (error) {
|
||||
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
const actionType = config?.actionType || "save";
|
||||
|
||||
switch (actionType) {
|
||||
case "save":
|
||||
await handleSaveAction();
|
||||
break;
|
||||
case "delete":
|
||||
await handleDeleteAction();
|
||||
break;
|
||||
case "popup":
|
||||
handlePopupAction();
|
||||
break;
|
||||
case "navigate":
|
||||
handleNavigateAction();
|
||||
break;
|
||||
case "custom":
|
||||
await handleCustomAction();
|
||||
break;
|
||||
default:
|
||||
console.log("🔘 기본 버튼 클릭");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 오류:", error);
|
||||
toast.error(error.message || "액션 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
variant={(config?.variant as any) || "default"}
|
||||
size={(config?.size as any) || "default"}
|
||||
disabled={config?.disabled}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundColor: config?.backgroundColor,
|
||||
color: config?.textColor,
|
||||
...comp.style,
|
||||
}}
|
||||
>
|
||||
{label || "버튼"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// 파일 컴포넌트 렌더링
|
||||
const renderFileComponent = (comp: FileComponent) => {
|
||||
const { label, readonly } = comp;
|
||||
const fieldName = comp.columnName || comp.id;
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
if (!screenInfo?.tableName) {
|
||||
toast.error("테이블명이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadData = {
|
||||
files,
|
||||
tableName: screenInfo.tableName,
|
||||
fieldName,
|
||||
recordId: formData.id || undefined,
|
||||
};
|
||||
|
||||
const response = await uploadFilesAndCreateData(uploadData);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("파일이 성공적으로 업로드되었습니다.");
|
||||
handleFormDataChange(fieldName, response.data);
|
||||
} else {
|
||||
toast.error("파일 업로드에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 오류:", error);
|
||||
toast.error("파일 업로드 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{/* 파일 업로드 컴포넌트는 기존 구현 사용 */}
|
||||
<div className="rounded border border-dashed p-2 text-sm text-gray-500">
|
||||
파일 업로드 영역 (동적 렌더링 예정)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 메인 렌더링
|
||||
const { type, position, size, style = {} } = component;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
width: size?.width || 200,
|
||||
height: size?.height || 40,
|
||||
zIndex: position?.z || 1,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
<div className="h-full w-full">
|
||||
{/* 라벨 표시 */}
|
||||
{!hideLabel && component.label && (
|
||||
<div className="mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{component.label}
|
||||
{(component as WidgetComponent).required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 렌더링 */}
|
||||
<div className="flex-1">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
{popupScreen && (
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||
<DialogContent
|
||||
className={` ${
|
||||
popupScreen.size === "small" ? "max-w-md" : popupScreen.size === "large" ? "max-w-6xl" : "max-w-4xl"
|
||||
} max-h-[90vh] overflow-y-auto`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{popupScreen.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative overflow-auto"
|
||||
style={{
|
||||
width: popupScreenResolution?.width || 1200,
|
||||
height: popupScreenResolution?.height || 600,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
}}
|
||||
>
|
||||
{popupLayout.map((popupComponent) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={popupComponent.id}
|
||||
component={popupComponent}
|
||||
allComponents={popupLayout}
|
||||
formData={popupFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setPopupFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
screenInfo={popupScreenInfo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 기존 InteractiveScreenViewer와의 호환성을 위한 export
|
||||
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
|
||||
|
||||
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,430 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FileUpload } from "./widgets/FileUpload";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
Hash,
|
||||
List,
|
||||
AlignLeft,
|
||||
CheckSquare,
|
||||
Radio,
|
||||
Calendar,
|
||||
Code,
|
||||
Building,
|
||||
File,
|
||||
Group,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
Square,
|
||||
CreditCard,
|
||||
Layout,
|
||||
Grid3x3,
|
||||
Columns,
|
||||
Rows,
|
||||
SidebarOpen,
|
||||
Folder,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
|
||||
interface RealtimePreviewProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
||||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||
}
|
||||
|
||||
// 영역 레이아웃에 따른 아이콘 반환
|
||||
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
||||
switch (layoutType) {
|
||||
case "flex":
|
||||
return <Layout className="h-4 w-4 text-blue-600" />;
|
||||
case "grid":
|
||||
return <Grid3x3 className="h-4 w-4 text-green-600" />;
|
||||
case "columns":
|
||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
||||
case "rows":
|
||||
return <Rows className="h-4 w-4 text-orange-600" />;
|
||||
case "sidebar":
|
||||
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
|
||||
case "tabs":
|
||||
return <Folder className="h-4 w-4 text-pink-600" />;
|
||||
default:
|
||||
return <Square className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 영역 렌더링
|
||||
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||
const area = component as AreaComponent;
|
||||
const { areaType, label } = area;
|
||||
|
||||
const renderPlaceholder = () => (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
{getAreaIcon(areaType)}
|
||||
<p className="mt-2 text-sm text-gray-600">{label || `${areaType} 영역`}</p>
|
||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 동적 웹 타입 위젯 렌더링
|
||||
const renderWidget = (component: ComponentData) => {
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (component.type !== "widget") {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
}
|
||||
|
||||
const widget = component as WidgetComponent;
|
||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||
|
||||
// 디버깅: 실제 widgetType 값 확인
|
||||
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
||||
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
const commonProps = {
|
||||
placeholder: placeholder || "입력하세요...",
|
||||
disabled: readonly,
|
||||
required: required,
|
||||
className: `w-full h-full ${borderClass}`,
|
||||
};
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
if (widgetType) {
|
||||
try {
|
||||
return (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={widgetType}
|
||||
props={{
|
||||
...commonProps,
|
||||
component: widget,
|
||||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
||||
// 오류 발생 시 폴백으로 기본 input 렌더링
|
||||
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
|
||||
return <Input type="text" {...commonProps} />;
|
||||
};
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
const getWidgetIcon = (widgetType: WebType | undefined) => {
|
||||
if (!widgetType) {
|
||||
return <Type className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
|
||||
// 레지스트리에서 웹타입 정의 조회
|
||||
const webTypeDefinition = WebTypeRegistry.getWebType(widgetType);
|
||||
if (webTypeDefinition && webTypeDefinition.icon) {
|
||||
const IconComponent = webTypeDefinition.icon;
|
||||
return <IconComponent className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
// 기본 아이콘 매핑 (하위 호환성)
|
||||
switch (widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return <Type className="h-4 w-4 text-blue-600" />;
|
||||
case "number":
|
||||
case "decimal":
|
||||
return <Hash className="h-4 w-4 text-green-600" />;
|
||||
case "date":
|
||||
case "datetime":
|
||||
return <Calendar className="h-4 w-4 text-purple-600" />;
|
||||
case "select":
|
||||
case "dropdown":
|
||||
return <List className="h-4 w-4 text-orange-600" />;
|
||||
case "textarea":
|
||||
case "text_area":
|
||||
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return <CheckSquare className="h-4 w-4 text-blue-600" />;
|
||||
case "radio":
|
||||
return <Radio className="h-4 w-4 text-blue-600" />;
|
||||
case "code":
|
||||
return <Code className="h-4 w-4 text-gray-600" />;
|
||||
case "entity":
|
||||
return <Building className="h-4 w-4 text-cyan-600" />;
|
||||
case "file":
|
||||
return <File className="h-4 w-4 text-yellow-600" />;
|
||||
default:
|
||||
return <Type className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onGroupToggle,
|
||||
children,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
width: size?.width || 200,
|
||||
height: size?.height || 40,
|
||||
zIndex: position?.z || 1,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "2px",
|
||||
}
|
||||
: {};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
onDragStart?.(e);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`component-${id}`}
|
||||
className="absolute cursor-pointer"
|
||||
style={{ ...componentStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
<div className="h-full w-full">
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
||||
{/* 데이터 테이블 타입 */}
|
||||
{type === "datatable" &&
|
||||
(() => {
|
||||
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||
|
||||
// 메모이제이션을 위한 계산 최적화
|
||||
const visibleColumns = React.useMemo(
|
||||
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [],
|
||||
[dataTableComponent.columns],
|
||||
);
|
||||
const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded border bg-white">
|
||||
{/* 테이블 제목 */}
|
||||
{dataTableComponent.title && (
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<h3 className="text-sm font-medium">{dataTableComponent.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 및 필터 영역 */}
|
||||
{(dataTableComponent.showSearchButton || filters.length > 0) && (
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{dataTableComponent.showSearchButton && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input placeholder="검색..." className="h-8 w-48" />
|
||||
<Button size="sm" variant="outline">
|
||||
{dataTableComponent.searchButtonText || "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">필터:</span>
|
||||
{filters.slice(0, 2).map((filter: any, index: number) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{filter.label || filter.columnName}
|
||||
</Badge>
|
||||
))}
|
||||
{filters.length > 2 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{filters.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 본체 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.length > 0 ? (
|
||||
visibleColumns.map((col: any, index: number) => (
|
||||
<TableHead key={col.id || index} className="text-xs">
|
||||
{col.label || col.columnName}
|
||||
{col.sortable && <span className="ml-1 text-gray-400">↕</span>}
|
||||
</TableHead>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<TableHead className="text-xs">컬럼 1</TableHead>
|
||||
<TableHead className="text-xs">컬럼 2</TableHead>
|
||||
<TableHead className="text-xs">컬럼 3</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 샘플 데이터 행들 */}
|
||||
{[1, 2, 3].map((rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{visibleColumns.length > 0 ? (
|
||||
visibleColumns.map((col: any, colIndex: number) => (
|
||||
<TableCell key={col.id || colIndex} className="text-xs">
|
||||
{col.widgetType === "checkbox" ? (
|
||||
<input type="checkbox" className="h-3 w-3" />
|
||||
) : col.widgetType === "select" ? (
|
||||
`옵션 ${rowIndex}`
|
||||
) : col.widgetType === "date" ? (
|
||||
"2024-01-01"
|
||||
) : col.widgetType === "number" ? (
|
||||
`${rowIndex * 100}`
|
||||
) : (
|
||||
`데이터 ${rowIndex}-${colIndex + 1}`
|
||||
)}
|
||||
</TableCell>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-1</TableCell>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-2</TableCell>
|
||||
<TableCell className="text-xs">데이터 {rowIndex}-3</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{dataTableComponent.pagination && (
|
||||
<div className="border-t bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>총 3개 항목</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
이전
|
||||
</Button>
|
||||
<span>1 / 1</span>
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 그룹 타입 */}
|
||||
{type === "group" && (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 */}
|
||||
{type === "widget" && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 타입 */}
|
||||
{type === "file" && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">
|
||||
<FileUpload disabled placeholder="파일 업로드 미리보기" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
|
||||
{type === "widget" && (
|
||||
<div className="flex items-center gap-1">
|
||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||
{(component as WidgetComponent).widgetType || "widget"}
|
||||
</div>
|
||||
)}
|
||||
{type !== "widget" && type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 기존 RealtimePreview와의 호환성을 위한 export
|
||||
export { RealtimePreviewDynamic as RealtimePreview };
|
||||
|
||||
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
ScreenDefinition,
|
||||
ComponentData,
|
||||
LayoutData,
|
||||
Position,
|
||||
ScreenResolution,
|
||||
SCREEN_RESOLUTIONS,
|
||||
} from "@/types/screen";
|
||||
import { generateComponentId } from "@/lib/utils/generateId";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { toast } from "sonner";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
import DesignerToolbar from "./DesignerToolbar";
|
||||
|
||||
interface SimpleScreenDesignerProps {
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onBackToList: () => void;
|
||||
}
|
||||
|
||||
export default function SimpleScreenDesigner({ selectedScreen, onBackToList }: SimpleScreenDesignerProps) {
|
||||
const [layout, setLayout] = useState<LayoutData>({
|
||||
components: [],
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(SCREEN_RESOLUTIONS[0]);
|
||||
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [dragState, setDragState] = useState({
|
||||
isDragging: false,
|
||||
draggedComponent: null as ComponentData | null,
|
||||
originalPosition: { x: 0, y: 0, z: 1 },
|
||||
currentPosition: { x: 0, y: 0, z: 1 },
|
||||
grabOffset: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 레이아웃 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
screenResolution: screenResolution,
|
||||
};
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedScreen?.screenId, layout, screenResolution]);
|
||||
|
||||
// 컴포넌트 추가
|
||||
const addComponent = useCallback((type: ComponentData["type"], position: Position) => {
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: type,
|
||||
position: position,
|
||||
size: { width: 200, height: 80 },
|
||||
title: `새 ${type}`,
|
||||
...(type === "widget" && {
|
||||
webType: "text" as const,
|
||||
label: "라벨",
|
||||
placeholder: "입력하세요",
|
||||
}),
|
||||
} as ComponentData;
|
||||
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
components: [...prev.components, newComponent],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 드래그 시작
|
||||
const startDrag = useCallback((component: ComponentData, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
draggedComponent: component,
|
||||
originalPosition: component.position,
|
||||
currentPosition: component.position,
|
||||
grabOffset: {
|
||||
x: event.clientX - rect.left - component.position.x,
|
||||
y: event.clientY - rect.top - component.position.y,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 드래그 업데이트
|
||||
const updateDragPosition = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const newPosition = {
|
||||
x: Math.max(0, event.clientX - rect.left - dragState.grabOffset.x),
|
||||
y: Math.max(0, event.clientY - rect.top - dragState.grabOffset.y),
|
||||
z: dragState.draggedComponent.position.z || 1,
|
||||
};
|
||||
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
currentPosition: newPosition,
|
||||
}));
|
||||
},
|
||||
[dragState],
|
||||
);
|
||||
|
||||
// 드래그 종료
|
||||
const endDrag = useCallback(() => {
|
||||
if (!dragState.isDragging || !dragState.draggedComponent) return;
|
||||
|
||||
const updatedComponents = layout.components.map((comp) =>
|
||||
comp.id === dragState.draggedComponent!.id ? { ...comp, position: dragState.currentPosition } : comp,
|
||||
);
|
||||
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
components: updatedComponents,
|
||||
}));
|
||||
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
draggedComponent: null,
|
||||
originalPosition: { x: 0, y: 0, z: 1 },
|
||||
currentPosition: { x: 0, y: 0, z: 1 },
|
||||
grabOffset: { x: 0, y: 0 },
|
||||
});
|
||||
}, [dragState, layout.components]);
|
||||
|
||||
// 마우스 이벤트 리스너
|
||||
const handleMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
updateDragPosition(event);
|
||||
},
|
||||
[updateDragPosition],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
React.useEffect(() => {
|
||||
if (dragState.isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [dragState.isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// 캔버스 클릭 처리
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setSelectedComponent(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 툴바 액션들
|
||||
const handleAddText = () => addComponent("widget", { x: 50, y: 50, z: 1 });
|
||||
const handleAddContainer = () => addComponent("container", { x: 100, y: 100, z: 1 });
|
||||
|
||||
if (!selectedScreen) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p>화면을 선택해주세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-gray-100">
|
||||
{/* 상단 툴바 */}
|
||||
<DesignerToolbar
|
||||
screenName={selectedScreen?.screenName}
|
||||
tableName={selectedScreen?.tableName}
|
||||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
onUndo={() => {}}
|
||||
onRedo={() => {}}
|
||||
onPreview={() => toast.info("미리보기 기능은 준비 중입니다.")}
|
||||
onTogglePanel={() => {}}
|
||||
panelStates={{}}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
{/* 간단한 컨트롤 버튼들 */}
|
||||
<div className="border-b border-gray-300 bg-white p-4">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleAddText} className="rounded bg-blue-500 px-3 py-1 text-white hover:bg-blue-600">
|
||||
텍스트 추가
|
||||
</button>
|
||||
<button onClick={handleAddContainer} className="rounded bg-green-500 px-3 py-1 text-white hover:bg-green-600">
|
||||
컨테이너 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 캔버스 영역 */}
|
||||
<div className="relative flex-1 overflow-auto bg-gray-100 p-8">
|
||||
{/* 해상도 정보 표시 */}
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{screenResolution.name} ({screenResolution.width} × {screenResolution.height})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실제 작업 캔버스 */}
|
||||
<div
|
||||
className="mx-auto bg-white shadow-lg"
|
||||
style={{ width: screenResolution.width, height: screenResolution.height }}
|
||||
>
|
||||
<div ref={canvasRef} className="relative h-full w-full overflow-hidden bg-white" onClick={handleCanvasClick}>
|
||||
{/* 컴포넌트들 */}
|
||||
{layout.components.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={selectedComponent?.id === component.id}
|
||||
onSelect={(comp) => setSelectedComponent(comp)}
|
||||
onStartDrag={(comp, event) => startDrag(comp, event)}
|
||||
onUpdateComponent={(updates) => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
components: prev.components.map((c) => (c.id === component.id ? { ...c, ...updates } : c)),
|
||||
}));
|
||||
}}
|
||||
dragPosition={
|
||||
dragState.isDragging && dragState.draggedComponent?.id === component.id
|
||||
? dragState.currentPosition
|
||||
: undefined
|
||||
}
|
||||
hideLabel={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ConfigPanelProps } from "@/lib/registry/types";
|
||||
|
||||
export const ButtonConfigPanel: React.FC<ConfigPanelProps> = ({ config: initialConfig, onConfigChange }) => {
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
label: "버튼",
|
||||
text: "",
|
||||
tooltip: "",
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
...initialConfig,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig({
|
||||
label: "버튼",
|
||||
text: "",
|
||||
tooltip: "",
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
...initialConfig,
|
||||
});
|
||||
}, [initialConfig]);
|
||||
|
||||
const updateConfig = (key: string, value: any) => {
|
||||
const newConfig = { ...localConfig, [key]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="button-label" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
버튼 텍스트
|
||||
</label>
|
||||
<input
|
||||
id="button-label"
|
||||
type="text"
|
||||
value={localConfig.label || ""}
|
||||
onChange={(e) => updateConfig("label", e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="버튼에 표시될 텍스트"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="button-tooltip" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
툴팁 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
id="button-tooltip"
|
||||
type="text"
|
||||
value={localConfig.tooltip || ""}
|
||||
onChange={(e) => updateConfig("tooltip", e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="마우스 오버 시 표시될 텍스트"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="button-variant" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
버튼 스타일
|
||||
</label>
|
||||
<select
|
||||
id="button-variant"
|
||||
value={localConfig.variant || "primary"}
|
||||
onChange={(e) => updateConfig("variant", e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="primary">기본 (파란색)</option>
|
||||
<option value="secondary">보조 (회색)</option>
|
||||
<option value="success">성공 (녹색)</option>
|
||||
<option value="warning">경고 (노란색)</option>
|
||||
<option value="danger">위험 (빨간색)</option>
|
||||
<option value="outline">외곽선</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="button-size" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
버튼 크기
|
||||
</label>
|
||||
<select
|
||||
id="button-size"
|
||||
value={localConfig.size || "medium"}
|
||||
onChange={(e) => updateConfig("size", e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="small">작음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="large">큼</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localConfig.disabled || false}
|
||||
onChange={(e) => updateConfig("disabled", e.target.checked)}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">비활성화</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localConfig.fullWidth || false}
|
||||
onChange={(e) => updateConfig("fullWidth", e.target.checked)}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">전체 너비</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">미리보기</h4>
|
||||
<button
|
||||
type="button"
|
||||
disabled={localConfig.disabled}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors duration-200 ${localConfig.size === "small" ? "px-3 py-1 text-xs" : ""} ${localConfig.size === "large" ? "px-6 py-3 text-base" : ""} ${localConfig.variant === "primary" ? "bg-blue-600 text-white hover:bg-blue-700" : ""} ${localConfig.variant === "secondary" ? "bg-gray-600 text-white hover:bg-gray-700" : ""} ${localConfig.variant === "success" ? "bg-green-600 text-white hover:bg-green-700" : ""} ${localConfig.variant === "warning" ? "bg-yellow-600 text-white hover:bg-yellow-700" : ""} ${localConfig.variant === "danger" ? "bg-red-600 text-white hover:bg-red-700" : ""} ${localConfig.variant === "outline" ? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50" : ""} ${localConfig.fullWidth ? "w-full" : ""} ${localConfig.disabled ? "cursor-not-allowed opacity-50" : ""} `}
|
||||
title={localConfig.tooltip}
|
||||
>
|
||||
{localConfig.label || "버튼"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckSquare, Plus, Trash2 } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, CheckboxTypeConfig } from "@/types/screen";
|
||||
|
||||
interface CheckboxOption {
|
||||
label: string;
|
||||
value: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as CheckboxTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<CheckboxTypeConfig>({
|
||||
// 단일 체크박스용
|
||||
label: config.label || "",
|
||||
checkedValue: config.checkedValue || "Y",
|
||||
uncheckedValue: config.uncheckedValue || "N",
|
||||
defaultChecked: config.defaultChecked || false,
|
||||
|
||||
// 다중 체크박스용 (체크박스 그룹)
|
||||
options: config.options || [],
|
||||
isGroup: config.isGroup || false,
|
||||
groupLabel: config.groupLabel || "",
|
||||
|
||||
// 공통 설정
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
inline: config.inline !== false, // 기본값 true
|
||||
});
|
||||
|
||||
// 새 옵션 추가용 상태
|
||||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||
const [newOptionValue, setNewOptionValue] = useState("");
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as CheckboxTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
label: currentConfig.label || "",
|
||||
checkedValue: currentConfig.checkedValue || "Y",
|
||||
uncheckedValue: currentConfig.uncheckedValue || "N",
|
||||
defaultChecked: currentConfig.defaultChecked || false,
|
||||
options: currentConfig.options || [],
|
||||
isGroup: currentConfig.isGroup || false,
|
||||
groupLabel: currentConfig.groupLabel || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
inline: currentConfig.inline !== false,
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof CheckboxTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 체크박스 유형 변경
|
||||
const toggleCheckboxType = (isGroup: boolean) => {
|
||||
if (isGroup && localConfig.options.length === 0) {
|
||||
// 그룹으로 변경할 때 기본 옵션 추가
|
||||
const defaultOptions: CheckboxOption[] = [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
];
|
||||
updateConfig("options", defaultOptions);
|
||||
}
|
||||
updateConfig("isGroup", isGroup);
|
||||
};
|
||||
|
||||
// 옵션 추가
|
||||
const addOption = () => {
|
||||
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
||||
|
||||
const newOption: CheckboxOption = {
|
||||
label: newOptionLabel.trim(),
|
||||
value: newOptionValue.trim(),
|
||||
checked: false,
|
||||
};
|
||||
|
||||
const newOptions = [...localConfig.options, newOption];
|
||||
updateConfig("options", newOptions);
|
||||
setNewOptionLabel("");
|
||||
setNewOptionValue("");
|
||||
};
|
||||
|
||||
// 옵션 제거
|
||||
const removeOption = (index: number) => {
|
||||
const newOptions = localConfig.options.filter((_, i) => i !== index);
|
||||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOption = (index: number, field: keyof CheckboxOption, value: any) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">체크박스의 라벨, 값, 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 체크박스 유형 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">체크박스 유형</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCheckboxType(false)}
|
||||
className={`rounded border p-3 text-xs ${
|
||||
!localConfig.isGroup ? "bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
<span>단일 체크박스</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCheckboxType(true)}
|
||||
className={`rounded border p-3 text-xs ${
|
||||
localConfig.isGroup ? "bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex gap-1">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
</div>
|
||||
<span>체크박스 그룹</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!localConfig.isGroup ? (
|
||||
/* 단일 체크박스 설정 */
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">단일 체크박스 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="label" className="text-xs">
|
||||
라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={localConfig.label || ""}
|
||||
onChange={(e) => updateConfig("label", e.target.value)}
|
||||
placeholder="체크박스 라벨"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="checkedValue" className="text-xs">
|
||||
체크된 값
|
||||
</Label>
|
||||
<Input
|
||||
id="checkedValue"
|
||||
value={localConfig.checkedValue || ""}
|
||||
onChange={(e) => updateConfig("checkedValue", e.target.value)}
|
||||
placeholder="Y"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="uncheckedValue" className="text-xs">
|
||||
체크 해제된 값
|
||||
</Label>
|
||||
<Input
|
||||
id="uncheckedValue"
|
||||
value={localConfig.uncheckedValue || ""}
|
||||
onChange={(e) => updateConfig("uncheckedValue", e.target.value)}
|
||||
placeholder="N"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="defaultChecked" className="text-xs">
|
||||
기본 체크 상태
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">초기에 체크된 상태로 표시됩니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="defaultChecked"
|
||||
checked={localConfig.defaultChecked || false}
|
||||
onCheckedChange={(checked) => updateConfig("defaultChecked", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 체크박스 그룹 설정 */
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">체크박스 그룹 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupLabel" className="text-xs">
|
||||
그룹 라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="groupLabel"
|
||||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
placeholder="체크박스 그룹 제목"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">옵션 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 옵션 목록 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">옵션 목록 ({localConfig.options.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<Switch
|
||||
checked={option.checked || false}
|
||||
onCheckedChange={(checked) => updateOption(index, "checked", checked)}
|
||||
/>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
|
||||
/>
|
||||
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">공통 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="inline" className="text-xs">
|
||||
가로 배열
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">체크박스들을 가로로 배열합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="inline"
|
||||
checked={localConfig.inline !== false}
|
||||
onCheckedChange={(checked) => updateConfig("inline", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{localConfig.isGroup ? "최소 하나 이상 선택해야 합니다." : "체크박스가 선택되어야 합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">체크박스 상태를 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
{!localConfig.isGroup ? (
|
||||
/* 단일 체크박스 미리보기 */
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="preview-single"
|
||||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
defaultChecked={localConfig.defaultChecked}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label htmlFor="preview-single" className="text-xs">
|
||||
{localConfig.label || "체크박스 라벨"}
|
||||
</Label>
|
||||
</div>
|
||||
) : (
|
||||
/* 체크박스 그룹 미리보기 */
|
||||
<div className="space-y-2">
|
||||
{localConfig.groupLabel && <Label className="text-xs font-medium">{localConfig.groupLabel}</Label>}
|
||||
<div className={`space-y-1 ${localConfig.inline ? "flex gap-4" : ""}`}>
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`preview-group-${index}`}
|
||||
disabled={localConfig.readonly || option.disabled}
|
||||
required={localConfig.required && index === 0} // 첫 번째에만 required 표시
|
||||
defaultChecked={option.checked}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label htmlFor={`preview-group-${index}`} className="text-xs">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.isGroup
|
||||
? `${localConfig.options.length}개 옵션`
|
||||
: `값: ${localConfig.checkedValue}/${localConfig.uncheckedValue}`}
|
||||
{localConfig.inline && " • 가로 배열"}
|
||||
{localConfig.required && " • 필수"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
CheckboxConfigPanel.displayName = "CheckboxConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Code, Monitor, Moon, Sun } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, CodeTypeConfig } from "@/types/screen";
|
||||
|
||||
export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as CodeTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<CodeTypeConfig>({
|
||||
language: config.language || "javascript",
|
||||
theme: config.theme || "light",
|
||||
showLineNumbers: config.showLineNumbers !== false, // 기본값 true
|
||||
wordWrap: config.wordWrap || false,
|
||||
fontSize: config.fontSize || 14,
|
||||
tabSize: config.tabSize || 2,
|
||||
readOnly: config.readOnly || false,
|
||||
showMinimap: config.showMinimap || false,
|
||||
autoComplete: config.autoComplete !== false, // 기본값 true
|
||||
bracketMatching: config.bracketMatching !== false, // 기본값 true
|
||||
defaultValue: config.defaultValue || "",
|
||||
placeholder: config.placeholder || "코드를 입력하세요...",
|
||||
height: config.height || 300,
|
||||
required: config.required || false,
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as CodeTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
language: currentConfig.language || "javascript",
|
||||
theme: currentConfig.theme || "light",
|
||||
showLineNumbers: currentConfig.showLineNumbers !== false,
|
||||
wordWrap: currentConfig.wordWrap || false,
|
||||
fontSize: currentConfig.fontSize || 14,
|
||||
tabSize: currentConfig.tabSize || 2,
|
||||
readOnly: currentConfig.readOnly || false,
|
||||
showMinimap: currentConfig.showMinimap || false,
|
||||
autoComplete: currentConfig.autoComplete !== false,
|
||||
bracketMatching: currentConfig.bracketMatching !== false,
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
placeholder: currentConfig.placeholder || "코드를 입력하세요...",
|
||||
height: currentConfig.height || 300,
|
||||
required: currentConfig.required || false,
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof CodeTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 지원되는 언어 목록
|
||||
const supportedLanguages = [
|
||||
{ value: "javascript", label: "JavaScript", sample: "console.log('Hello World');" },
|
||||
{ value: "typescript", label: "TypeScript", sample: "const message: string = 'Hello World';" },
|
||||
{ value: "python", label: "Python", sample: "print('Hello World')" },
|
||||
{ value: "java", label: "Java", sample: "System.out.println('Hello World');" },
|
||||
{ value: "html", label: "HTML", sample: "<h1>Hello World</h1>" },
|
||||
{ value: "css", label: "CSS", sample: "body { color: #333; }" },
|
||||
{ value: "sql", label: "SQL", sample: "SELECT * FROM users;" },
|
||||
{ value: "json", label: "JSON", sample: '{"message": "Hello World"}' },
|
||||
{ value: "xml", label: "XML", sample: "<message>Hello World</message>" },
|
||||
{ value: "markdown", label: "Markdown", sample: "# Hello World" },
|
||||
{ value: "yaml", label: "YAML", sample: "message: Hello World" },
|
||||
{ value: "shell", label: "Shell", sample: "echo 'Hello World'" },
|
||||
{ value: "php", label: "PHP", sample: "<?php echo 'Hello World'; ?>" },
|
||||
{ value: "go", label: "Go", sample: 'fmt.Println("Hello World")' },
|
||||
{ value: "rust", label: "Rust", sample: 'println!("Hello World");' },
|
||||
{ value: "plaintext", label: "Plain Text", sample: "Hello World" },
|
||||
];
|
||||
|
||||
// 테마 목록
|
||||
const themes = [
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "vs", label: "Visual Studio", icon: Monitor },
|
||||
{ value: "github", label: "GitHub", icon: Monitor },
|
||||
{ value: "monokai", label: "Monokai", icon: Monitor },
|
||||
{ value: "solarized", label: "Solarized", icon: Monitor },
|
||||
];
|
||||
|
||||
// 샘플 코드 설정
|
||||
const setSampleCode = () => {
|
||||
const selectedLang = supportedLanguages.find((lang) => lang.value === localConfig.language);
|
||||
if (selectedLang) {
|
||||
updateConfig("defaultValue", selectedLang.sample);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Code className="h-4 w-4" />
|
||||
코드 에디터 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">코드 편집기의 언어, 테마, 편집 옵션을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language" className="text-xs">
|
||||
프로그래밍 언어
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.language || "javascript"}
|
||||
onValueChange={(value) => updateConfig("language", value)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="언어 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
<div className="flex flex-col">
|
||||
<span>{lang.label}</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">{lang.sample}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme" className="text-xs">
|
||||
테마
|
||||
</Label>
|
||||
<Select value={localConfig.theme || "light"} onValueChange={(value) => updateConfig("theme", value)}>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="테마 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((theme) => (
|
||||
<SelectItem key={theme.value} value={theme.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<theme.icon className="h-3 w-3" />
|
||||
{theme.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="height" className="text-xs">
|
||||
에디터 높이: {localConfig.height}px
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="range"
|
||||
min={150}
|
||||
max={800}
|
||||
step={50}
|
||||
value={localConfig.height || 300}
|
||||
onChange={(e) => updateConfig("height", parseInt(e.target.value))}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>150px</span>
|
||||
<span>800px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 편집기 옵션 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">편집기 옵션</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize" className="text-xs">
|
||||
글꼴 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="number"
|
||||
value={localConfig.fontSize || 14}
|
||||
onChange={(e) => updateConfig("fontSize", parseInt(e.target.value))}
|
||||
min={10}
|
||||
max={24}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tabSize" className="text-xs">
|
||||
탭 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="tabSize"
|
||||
type="number"
|
||||
value={localConfig.tabSize || 2}
|
||||
onChange={(e) => updateConfig("tabSize", parseInt(e.target.value))}
|
||||
min={1}
|
||||
max={8}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="showLineNumbers" className="text-xs">
|
||||
줄 번호 표시
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">코드 라인의 번호를 표시합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showLineNumbers"
|
||||
checked={localConfig.showLineNumbers !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showLineNumbers", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="wordWrap" className="text-xs">
|
||||
줄 바꿈
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">긴 라인을 자동으로 줄바꿈합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="wordWrap"
|
||||
checked={localConfig.wordWrap || false}
|
||||
onCheckedChange={(checked) => updateConfig("wordWrap", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="showMinimap" className="text-xs">
|
||||
미니맵 표시
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">코드의 축소된 미리보기를 표시합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showMinimap"
|
||||
checked={localConfig.showMinimap || false}
|
||||
onCheckedChange={(checked) => updateConfig("showMinimap", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고급 기능 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">고급 기능</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoComplete" className="text-xs">
|
||||
자동 완성
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">코드 자동 완성 기능을 활성화합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoComplete"
|
||||
checked={localConfig.autoComplete !== false}
|
||||
onCheckedChange={(checked) => updateConfig("autoComplete", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bracketMatching" className="text-xs">
|
||||
괄호 매칭
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">매칭되는 괄호를 하이라이트합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="bracketMatching"
|
||||
checked={localConfig.bracketMatching !== false}
|
||||
onCheckedChange={(checked) => updateConfig("bracketMatching", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본값</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="코드를 입력하세요..."
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 코드
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={setSampleCode}
|
||||
className="text-xs text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
샘플 코드 사용
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
placeholder="기본 코드 내용"
|
||||
className="font-mono text-xs"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readOnly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">코드를 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readOnly"
|
||||
checked={localConfig.readOnly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readOnly", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">코드가 입력되어야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<div
|
||||
className={`rounded border font-mono text-xs ${
|
||||
localConfig.theme === "dark" ? "bg-gray-900 text-gray-100" : "bg-white text-gray-900"
|
||||
}`}
|
||||
style={{ height: `${Math.min(localConfig.height || 300, 200)}px` }}
|
||||
>
|
||||
<div className="flex items-center border-b bg-gray-50 px-3 py-1 text-gray-700">
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
<span className="text-xs">
|
||||
{supportedLanguages.find((l) => l.value === localConfig.language)?.label || "JavaScript"}
|
||||
</span>
|
||||
{localConfig.showLineNumbers && <span className="ml-auto text-xs text-gray-500">줄번호</span>}
|
||||
</div>
|
||||
<div className="overflow-auto p-3" style={{ height: "calc(100% - 32px)" }}>
|
||||
{localConfig.defaultValue ? (
|
||||
<pre className="text-xs">
|
||||
{localConfig.showLineNumbers && <span className="mr-3 text-gray-400 select-none">1</span>}
|
||||
{localConfig.defaultValue}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-gray-400 italic">{localConfig.placeholder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-2 space-y-1 text-xs">
|
||||
<div>
|
||||
언어: {supportedLanguages.find((l) => l.value === localConfig.language)?.label}• 테마:{" "}
|
||||
{themes.find((t) => t.value === localConfig.theme)?.label}• 크기: {localConfig.fontSize}px
|
||||
</div>
|
||||
<div>
|
||||
{localConfig.showLineNumbers && "줄번호 • "}
|
||||
{localConfig.wordWrap && "줄바꿈 • "}
|
||||
{localConfig.showMinimap && "미니맵 • "}
|
||||
{localConfig.autoComplete && "자동완성 • "}
|
||||
{localConfig.bracketMatching && "괄호매칭 • "}
|
||||
{localConfig.readOnly && "읽기전용 • "}
|
||||
{localConfig.required && "필수"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
CodeConfigPanel.displayName = "CodeConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
||||
|
||||
export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as DateTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<DateTypeConfig>({
|
||||
format: config.format || "YYYY-MM-DD",
|
||||
showTime: config.showTime || false,
|
||||
minDate: config.minDate || "",
|
||||
maxDate: config.maxDate || "",
|
||||
defaultValue: config.defaultValue || "",
|
||||
placeholder: config.placeholder || "",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as DateTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
format: currentConfig.format || "YYYY-MM-DD",
|
||||
showTime: currentConfig.showTime || false,
|
||||
minDate: currentConfig.minDate || "",
|
||||
maxDate: currentConfig.maxDate || "",
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
placeholder: currentConfig.placeholder || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof DateTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 현재 날짜 설정
|
||||
const setCurrentDate = (field: "minDate" | "maxDate" | "defaultValue") => {
|
||||
const now = new Date();
|
||||
const dateString = localConfig.showTime
|
||||
? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm
|
||||
: now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
updateConfig(field, dateString);
|
||||
};
|
||||
|
||||
// 날짜 형식 옵션
|
||||
const formatOptions = [
|
||||
{ value: "YYYY-MM-DD", label: "2024-12-25", description: "ISO 표준 형식" },
|
||||
{ value: "YYYY-MM-DD HH:mm", label: "2024-12-25 14:30", description: "날짜 + 시간" },
|
||||
{ value: "YYYY-MM-DD HH:mm:ss", label: "2024-12-25 14:30:45", description: "날짜 + 시간 + 초" },
|
||||
{ value: "DD/MM/YYYY", label: "25/12/2024", description: "유럽 형식" },
|
||||
{ value: "MM/DD/YYYY", label: "12/25/2024", description: "미국 형식" },
|
||||
{ value: "YYYY년 MM월 DD일", label: "2024년 12월 25일", description: "한국 형식" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4" />
|
||||
날짜 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">날짜/시간 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="날짜를 선택하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format" className="text-xs">
|
||||
날짜 형식
|
||||
</Label>
|
||||
<Select value={localConfig.format || "YYYY-MM-DD"} onValueChange={(value) => updateConfig("format", value)}>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formatOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="showTime" className="text-xs">
|
||||
시간 포함
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">날짜와 함께 시간도 입력받습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showTime"
|
||||
checked={localConfig.showTime || false}
|
||||
onCheckedChange={(checked) => updateConfig("showTime", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 날짜 범위 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">날짜 범위</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minDate" className="text-xs">
|
||||
최소 날짜
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="minDate"
|
||||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.minDate || ""}
|
||||
onChange={(e) => updateConfig("minDate", e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("minDate")} className="text-xs">
|
||||
오늘
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxDate" className="text-xs">
|
||||
최대 날짜
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="maxDate"
|
||||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.maxDate || ""}
|
||||
onChange={(e) => updateConfig("maxDate", e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("maxDate")} className="text-xs">
|
||||
오늘
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본값</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 날짜
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="defaultValue"
|
||||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("defaultValue")} className="text-xs">
|
||||
현재
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">입력 필드의 초기값으로 사용됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">날짜가 선택되어야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">날짜를 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<Input
|
||||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
placeholder={localConfig.placeholder || "날짜 선택 미리보기"}
|
||||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
min={localConfig.minDate}
|
||||
max={localConfig.maxDate}
|
||||
defaultValue={localConfig.defaultValue}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
형식: {localConfig.format}
|
||||
{localConfig.showTime && " (시간 포함)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
DateConfigPanel.displayName = "DateConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,548 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Database, Search, Plus, Trash2 } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
|
||||
|
||||
interface EntityField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
|
||||
entityType: config.entityType || "",
|
||||
displayFields: config.displayFields || [],
|
||||
searchFields: config.searchFields || [],
|
||||
valueField: config.valueField || "id",
|
||||
labelField: config.labelField || "name",
|
||||
multiple: config.multiple || false,
|
||||
searchable: config.searchable !== false, // 기본값 true
|
||||
placeholder: config.placeholder || "엔티티를 선택하세요",
|
||||
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: config.pageSize || 20,
|
||||
minSearchLength: config.minSearchLength || 1,
|
||||
defaultValue: config.defaultValue || "",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
apiEndpoint: config.apiEndpoint || "",
|
||||
filters: config.filters || {},
|
||||
});
|
||||
|
||||
// 새 필드 추가용 상태
|
||||
const [newFieldName, setNewFieldName] = useState("");
|
||||
const [newFieldLabel, setNewFieldLabel] = useState("");
|
||||
const [newFieldType, setNewFieldType] = useState("string");
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
entityType: currentConfig.entityType || "",
|
||||
displayFields: currentConfig.displayFields || [],
|
||||
searchFields: currentConfig.searchFields || [],
|
||||
valueField: currentConfig.valueField || "id",
|
||||
labelField: currentConfig.labelField || "name",
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable !== false,
|
||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: currentConfig.pageSize || 20,
|
||||
minSearchLength: currentConfig.minSearchLength || 1,
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||
filters: currentConfig.filters || {},
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addDisplayField = () => {
|
||||
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
|
||||
|
||||
const newField: EntityField = {
|
||||
name: newFieldName.trim(),
|
||||
label: newFieldLabel.trim(),
|
||||
type: newFieldType,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const newFields = [...localConfig.displayFields, newField];
|
||||
updateConfig("displayFields", newFields);
|
||||
setNewFieldName("");
|
||||
setNewFieldLabel("");
|
||||
setNewFieldType("string");
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeDisplayField = (index: number) => {
|
||||
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
|
||||
updateConfig("displayFields", newFields);
|
||||
};
|
||||
|
||||
// 필드 업데이트
|
||||
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], [field]: value };
|
||||
updateConfig("displayFields", newFields);
|
||||
};
|
||||
|
||||
// 검색 필드 토글
|
||||
const toggleSearchField = (fieldName: string) => {
|
||||
const currentSearchFields = localConfig.searchFields || [];
|
||||
const newSearchFields = currentSearchFields.includes(fieldName)
|
||||
? currentSearchFields.filter((f) => f !== fieldName)
|
||||
: [...currentSearchFields, fieldName];
|
||||
updateConfig("searchFields", newSearchFields);
|
||||
};
|
||||
|
||||
// 기본 엔티티 타입들
|
||||
const commonEntityTypes = [
|
||||
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
|
||||
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
|
||||
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
|
||||
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
|
||||
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
|
||||
];
|
||||
|
||||
// 기본 엔티티 타입 적용
|
||||
const applyEntityType = (entityType: string) => {
|
||||
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
|
||||
if (!entityConfig) return;
|
||||
|
||||
updateConfig("entityType", entityType);
|
||||
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
|
||||
|
||||
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
|
||||
name: field,
|
||||
label: field.charAt(0).toUpperCase() + field.slice(1),
|
||||
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
|
||||
visible: true,
|
||||
}));
|
||||
|
||||
updateConfig("displayFields", defaultFields);
|
||||
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
|
||||
};
|
||||
|
||||
// 필드 타입 옵션
|
||||
const fieldTypes = [
|
||||
{ value: "string", label: "문자열" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "boolean", label: "불린" },
|
||||
{ value: "email", label: "이메일" },
|
||||
{ value: "url", label: "URL" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Database className="h-4 w-4" />
|
||||
엔티티 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">데이터베이스 엔티티 선택 필드의 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entityType" className="text-xs">
|
||||
엔티티 타입
|
||||
</Label>
|
||||
<Input
|
||||
id="entityType"
|
||||
value={localConfig.entityType || ""}
|
||||
onChange={(e) => updateConfig("entityType", e.target.value)}
|
||||
placeholder="user, product, department..."
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">기본 엔티티 타입</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{commonEntityTypes.map((entity) => (
|
||||
<Button
|
||||
key={entity.value}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyEntityType(entity.value)}
|
||||
className="text-xs"
|
||||
>
|
||||
{entity.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiEndpoint" className="text-xs">
|
||||
API 엔드포인트
|
||||
</Label>
|
||||
<Input
|
||||
id="apiEndpoint"
|
||||
value={localConfig.apiEndpoint || ""}
|
||||
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||
placeholder="/api/entities/user"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">필드 매핑</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="valueField" className="text-xs">
|
||||
값 필드
|
||||
</Label>
|
||||
<Input
|
||||
id="valueField"
|
||||
value={localConfig.valueField || ""}
|
||||
onChange={(e) => updateConfig("valueField", e.target.value)}
|
||||
placeholder="id"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="labelField" className="text-xs">
|
||||
라벨 필드
|
||||
</Label>
|
||||
<Input
|
||||
id="labelField"
|
||||
value={localConfig.labelField || ""}
|
||||
onChange={(e) => updateConfig("labelField", e.target.value)}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 필드 관리 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">표시 필드</h4>
|
||||
|
||||
{/* 새 필드 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">필드 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newFieldName}
|
||||
onChange={(e) => setNewFieldName(e.target.value)}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newFieldLabel}
|
||||
onChange={(e) => setNewFieldLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addDisplayField}
|
||||
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 필드 목록 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">표시 필드 ({localConfig.displayFields.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.displayFields.map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<Switch
|
||||
checked={field.visible}
|
||||
onCheckedChange={(checked) => updateDisplayField(index, "visible", checked)}
|
||||
/>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select value={field.type} onValueChange={(value) => updateDisplayField(index, "type", value)}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
|
||||
onClick={() => toggleSearchField(field.name)}
|
||||
className="p-1 text-xs"
|
||||
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeDisplayField(index)}
|
||||
className="p-1 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">검색 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="엔티티를 선택하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emptyMessage" className="text-xs">
|
||||
빈 결과 메시지
|
||||
</Label>
|
||||
<Input
|
||||
id="emptyMessage"
|
||||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
placeholder="검색 결과가 없습니다"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minSearchLength" className="text-xs">
|
||||
최소 검색 길이
|
||||
</Label>
|
||||
<Input
|
||||
id="minSearchLength"
|
||||
type="number"
|
||||
value={localConfig.minSearchLength || 1}
|
||||
onChange={(e) => updateConfig("minSearchLength", parseInt(e.target.value))}
|
||||
min={0}
|
||||
max={10}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pageSize" className="text-xs">
|
||||
페이지 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="pageSize"
|
||||
type="number"
|
||||
value={localConfig.pageSize || 20}
|
||||
onChange={(e) => updateConfig("pageSize", parseInt(e.target.value))}
|
||||
min={5}
|
||||
max={100}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="searchable" className="text-xs">
|
||||
검색 가능
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">엔티티를 검색할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="searchable"
|
||||
checked={localConfig.searchable !== false}
|
||||
onCheckedChange={(checked) => updateConfig("searchable", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multiple" className="text-xs">
|
||||
다중 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">여러 엔티티를 선택할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="multiple"
|
||||
checked={localConfig.multiple || false}
|
||||
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">추가 필터</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filters" className="text-xs">
|
||||
JSON 필터
|
||||
</Label>
|
||||
<Textarea
|
||||
id="filters"
|
||||
value={JSON.stringify(localConfig.filters || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
updateConfig("filters", parsed);
|
||||
} catch {
|
||||
// 유효하지 않은 JSON은 무시
|
||||
}
|
||||
}}
|
||||
placeholder='{"status": "active", "department": "IT"}'
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">엔티티가 반드시 선택되어야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">엔티티를 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 rounded border bg-white p-2">
|
||||
<Database className="h-4 w-4 text-gray-400" />
|
||||
<span className="flex-1 text-xs text-gray-600">{localConfig.placeholder || "엔티티를 선택하세요"}</span>
|
||||
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
|
||||
</div>
|
||||
|
||||
{localConfig.displayFields.length > 0 && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="font-medium">표시 필드:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{localConfig.displayFields
|
||||
.filter((f) => f.visible)
|
||||
.map((field, index) => (
|
||||
<span key={index} className="rounded bg-gray-100 px-2 py-1">
|
||||
{field.label}
|
||||
{localConfig.searchFields.includes(field.name) && " 🔍"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground text-xs">
|
||||
타입: {localConfig.entityType || "미정"}• 값 필드: {localConfig.valueField}• 라벨 필드:{" "}
|
||||
{localConfig.labelField}
|
||||
{localConfig.multiple && " • 다중선택"}
|
||||
{localConfig.required && " • 필수"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
EntityConfigPanel.displayName = "EntityConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Upload, File, X } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, FileTypeConfig } from "@/types/screen";
|
||||
|
||||
export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as FileTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<FileTypeConfig>({
|
||||
multiple: config.multiple || false,
|
||||
maxFileSize: config.maxFileSize || 10, // MB
|
||||
maxFiles: config.maxFiles || 1,
|
||||
acceptedTypes: config.acceptedTypes || [],
|
||||
showPreview: config.showPreview !== false, // 기본값 true
|
||||
showProgress: config.showProgress !== false, // 기본값 true
|
||||
dragAndDrop: config.dragAndDrop !== false, // 기본값 true
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
uploadText: config.uploadText || "파일을 선택하거나 여기에 드래그하세요",
|
||||
browseText: config.browseText || "파일 선택",
|
||||
});
|
||||
|
||||
// 새 파일 타입 추가용 상태
|
||||
const [newFileType, setNewFileType] = useState("");
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as FileTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
multiple: currentConfig.multiple || false,
|
||||
maxFileSize: currentConfig.maxFileSize || 10,
|
||||
maxFiles: currentConfig.maxFiles || 1,
|
||||
acceptedTypes: currentConfig.acceptedTypes || [],
|
||||
showPreview: currentConfig.showPreview !== false,
|
||||
showProgress: currentConfig.showProgress !== false,
|
||||
dragAndDrop: currentConfig.dragAndDrop !== false,
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
uploadText: currentConfig.uploadText || "파일을 선택하거나 여기에 드래그하세요",
|
||||
browseText: currentConfig.browseText || "파일 선택",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof FileTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 파일 타입 추가
|
||||
const addFileType = () => {
|
||||
if (!newFileType.trim()) return;
|
||||
|
||||
let extension = newFileType.trim();
|
||||
if (!extension.startsWith(".")) {
|
||||
extension = "." + extension;
|
||||
}
|
||||
|
||||
if (!localConfig.acceptedTypes.includes(extension)) {
|
||||
const newTypes = [...localConfig.acceptedTypes, extension];
|
||||
updateConfig("acceptedTypes", newTypes);
|
||||
}
|
||||
|
||||
setNewFileType("");
|
||||
};
|
||||
|
||||
// 파일 타입 제거
|
||||
const removeFileType = (typeToRemove: string) => {
|
||||
const newTypes = localConfig.acceptedTypes.filter((type) => type !== typeToRemove);
|
||||
updateConfig("acceptedTypes", newTypes);
|
||||
};
|
||||
|
||||
// 기본 파일 타입 세트
|
||||
const defaultFileTypeSets = {
|
||||
images: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"],
|
||||
documents: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt"],
|
||||
archives: [".zip", ".rar", ".7z", ".tar", ".gz"],
|
||||
media: [".mp4", ".avi", ".mov", ".wmv", ".mp3", ".wav", ".flac"],
|
||||
web: [".html", ".css", ".js", ".json", ".xml"],
|
||||
all: ["*"],
|
||||
};
|
||||
|
||||
const applyFileTypeSet = (setName: keyof typeof defaultFileTypeSets) => {
|
||||
updateConfig("acceptedTypes", defaultFileTypeSets[setName]);
|
||||
};
|
||||
|
||||
// 파일 크기 단위 변환
|
||||
const formatFileSize = (sizeInMB: number) => {
|
||||
if (sizeInMB < 1) {
|
||||
return `${(sizeInMB * 1024).toFixed(0)}KB`;
|
||||
} else if (sizeInMB >= 1024) {
|
||||
return `${(sizeInMB / 1024).toFixed(1)}GB`;
|
||||
} else {
|
||||
return `${sizeInMB}MB`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Upload className="h-4 w-4" />
|
||||
파일 업로드 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">파일 업로드 필드의 제한사항과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="uploadText" className="text-xs">
|
||||
업로드 안내 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="uploadText"
|
||||
value={localConfig.uploadText || ""}
|
||||
onChange={(e) => updateConfig("uploadText", e.target.value)}
|
||||
placeholder="파일을 선택하거나 여기에 드래그하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="browseText" className="text-xs">
|
||||
찾아보기 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="browseText"
|
||||
value={localConfig.browseText || ""}
|
||||
onChange={(e) => updateConfig("browseText", e.target.value)}
|
||||
placeholder="파일 선택"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multiple" className="text-xs">
|
||||
다중 파일 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">여러 파일을 한 번에 선택할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="multiple"
|
||||
checked={localConfig.multiple || false}
|
||||
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dragAndDrop" className="text-xs">
|
||||
드래그 앤 드롭
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">파일을 드래그해서 업로드할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="dragAndDrop"
|
||||
checked={localConfig.dragAndDrop !== false}
|
||||
onCheckedChange={(checked) => updateConfig("dragAndDrop", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 제한 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">파일 제한</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxFileSize" className="text-xs">
|
||||
최대 파일 크기: {formatFileSize(localConfig.maxFileSize || 10)}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="maxFileSize"
|
||||
type="number"
|
||||
value={localConfig.maxFileSize || 10}
|
||||
onChange={(e) => updateConfig("maxFileSize", parseFloat(e.target.value))}
|
||||
min={0.1}
|
||||
max={1024}
|
||||
step={0.1}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localConfig.multiple && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxFiles" className="text-xs">
|
||||
최대 파일 개수
|
||||
</Label>
|
||||
<Input
|
||||
id="maxFiles"
|
||||
type="number"
|
||||
value={localConfig.maxFiles || 1}
|
||||
onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 허용된 파일 타입 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">허용된 파일 타입</h4>
|
||||
|
||||
{/* 기본 파일 타입 세트 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">기본 세트</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => applyFileTypeSet("images")} className="text-xs">
|
||||
이미지
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyFileTypeSet("documents")} className="text-xs">
|
||||
문서
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyFileTypeSet("archives")} className="text-xs">
|
||||
압축파일
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyFileTypeSet("media")} className="text-xs">
|
||||
미디어
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyFileTypeSet("web")} className="text-xs">
|
||||
웹파일
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyFileTypeSet("all")} className="text-xs">
|
||||
모든 파일
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개별 파일 타입 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">개별 타입 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
placeholder=".pdf 또는 pdf"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addFileType} disabled={!newFileType.trim()} className="text-xs">
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 허용된 타입 목록 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">현재 허용된 타입 ({localConfig.acceptedTypes.length}개)</Label>
|
||||
<div className="flex min-h-8 flex-wrap gap-1 rounded-md border p-2">
|
||||
{localConfig.acceptedTypes.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">모든 파일 타입 허용</span>
|
||||
) : (
|
||||
localConfig.acceptedTypes.map((type, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{type}
|
||||
<button type="button" onClick={() => removeFileType(type)} className="hover:text-destructive ml-1">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="showPreview" className="text-xs">
|
||||
파일 미리보기
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">선택된 파일의 미리보기를 표시합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showPreview"
|
||||
checked={localConfig.showPreview !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showPreview", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="showProgress" className="text-xs">
|
||||
업로드 진행률
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">업로드 진행 상황을 표시합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showProgress"
|
||||
checked={localConfig.showProgress !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showProgress", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 업로드
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">파일이 반드시 업로드되어야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">파일을 업로드할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<div
|
||||
className={`space-y-2 rounded-md border-2 border-dashed p-4 text-center ${
|
||||
localConfig.dragAndDrop && !localConfig.readonly
|
||||
? "border-gray-300 hover:border-gray-400"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<File className="mx-auto h-8 w-8 text-gray-400" />
|
||||
<p className="text-xs text-gray-600">{localConfig.uploadText}</p>
|
||||
<Button size="sm" variant="outline" disabled={localConfig.readonly} className="text-xs">
|
||||
{localConfig.browseText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-2 space-y-1 text-xs">
|
||||
<div>
|
||||
최대 크기: {formatFileSize(localConfig.maxFileSize || 10)}
|
||||
{localConfig.multiple && ` • 최대 ${localConfig.maxFiles}개 파일`}
|
||||
</div>
|
||||
<div>
|
||||
허용 타입:{" "}
|
||||
{localConfig.acceptedTypes.length === 0
|
||||
? "모든 파일"
|
||||
: localConfig.acceptedTypes.slice(0, 3).join(", ") +
|
||||
(localConfig.acceptedTypes.length > 3 ? ` 외 ${localConfig.acceptedTypes.length - 3}개` : "")}
|
||||
</div>
|
||||
<div>
|
||||
{localConfig.dragAndDrop && "드래그 앤 드롭 • "}
|
||||
{localConfig.showPreview && "미리보기 • "}
|
||||
{localConfig.showProgress && "진행률 표시 • "}
|
||||
{localConfig.required && "필수"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
FileConfigPanel.displayName = "FileConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, NumberTypeConfig } from "@/types/screen";
|
||||
|
||||
export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as NumberTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<NumberTypeConfig>({
|
||||
min: config.min || undefined,
|
||||
max: config.max || undefined,
|
||||
step: config.step || undefined,
|
||||
format: config.format || "integer",
|
||||
decimalPlaces: config.decimalPlaces || undefined,
|
||||
thousandSeparator: config.thousandSeparator || false,
|
||||
placeholder: config.placeholder || "",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as NumberTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
min: currentConfig.min || undefined,
|
||||
max: currentConfig.max || undefined,
|
||||
step: currentConfig.step || undefined,
|
||||
format: currentConfig.format || "integer",
|
||||
decimalPlaces: currentConfig.decimalPlaces || undefined,
|
||||
thousandSeparator: currentConfig.thousandSeparator || false,
|
||||
placeholder: currentConfig.placeholder || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof NumberTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">숫자 설정</CardTitle>
|
||||
<CardDescription className="text-xs">숫자 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="숫자를 입력하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min" className="text-xs">
|
||||
최솟값
|
||||
</Label>
|
||||
<Input
|
||||
id="min"
|
||||
type="number"
|
||||
value={localConfig.min ?? ""}
|
||||
onChange={(e) => updateConfig("min", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max" className="text-xs">
|
||||
최댓값
|
||||
</Label>
|
||||
<Input
|
||||
id="max"
|
||||
type="number"
|
||||
value={localConfig.max ?? ""}
|
||||
onChange={(e) => updateConfig("max", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="100"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step" className="text-xs">
|
||||
증감 단위
|
||||
</Label>
|
||||
<Input
|
||||
id="step"
|
||||
type="number"
|
||||
value={localConfig.step ?? ""}
|
||||
onChange={(e) => updateConfig("step", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="1"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">증가/감소 버튼 클릭 시 변경되는 값의 크기</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 형식 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">형식 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format" className="text-xs">
|
||||
숫자 형식
|
||||
</Label>
|
||||
<Select value={localConfig.format || "integer"} onValueChange={(value) => updateConfig("format", value)}>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integer">정수</SelectItem>
|
||||
<SelectItem value="decimal">소수</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
<SelectItem value="percentage">퍼센트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(localConfig.format === "decimal" || localConfig.format === "currency") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="decimalPlaces" className="text-xs">
|
||||
소수점 자릿수
|
||||
</Label>
|
||||
<Input
|
||||
id="decimalPlaces"
|
||||
type="number"
|
||||
value={localConfig.decimalPlaces ?? ""}
|
||||
onChange={(e) => updateConfig("decimalPlaces", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="2"
|
||||
min="0"
|
||||
max="10"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="thousandSeparator" className="text-xs">
|
||||
천 단위 구분자
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">1,000 형태로 표시합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="thousandSeparator"
|
||||
checked={localConfig.thousandSeparator || false}
|
||||
onCheckedChange={(checked) => updateConfig("thousandSeparator", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">값이 입력되어야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">값을 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={localConfig.placeholder || "숫자 입력 미리보기"}
|
||||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
min={localConfig.min}
|
||||
max={localConfig.max}
|
||||
step={localConfig.step}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.format === "currency" && "통화 형식으로 표시됩니다."}
|
||||
{localConfig.format === "percentage" && "퍼센트 형식으로 표시됩니다."}
|
||||
{localConfig.thousandSeparator && "천 단위 구분자가 적용됩니다."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
NumberConfigPanel.displayName = "NumberConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Radio, Plus, Trash2 } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, RadioTypeConfig } from "@/types/screen";
|
||||
|
||||
interface RadioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as RadioTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<RadioTypeConfig>({
|
||||
options: config.options || [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
groupName: config.groupName || "",
|
||||
defaultValue: config.defaultValue || "",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
inline: config.inline !== false, // 기본값 true
|
||||
groupLabel: config.groupLabel || "",
|
||||
});
|
||||
|
||||
// 새 옵션 추가용 상태
|
||||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||
const [newOptionValue, setNewOptionValue] = useState("");
|
||||
const [bulkOptions, setBulkOptions] = useState("");
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as RadioTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
options: currentConfig.options || [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
groupName: currentConfig.groupName || "",
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
inline: currentConfig.inline !== false,
|
||||
groupLabel: currentConfig.groupLabel || "",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof RadioTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 옵션 추가
|
||||
const addOption = () => {
|
||||
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
||||
|
||||
const newOption: RadioOption = {
|
||||
label: newOptionLabel.trim(),
|
||||
value: newOptionValue.trim(),
|
||||
};
|
||||
|
||||
const newOptions = [...localConfig.options, newOption];
|
||||
updateConfig("options", newOptions);
|
||||
setNewOptionLabel("");
|
||||
setNewOptionValue("");
|
||||
};
|
||||
|
||||
// 옵션 제거
|
||||
const removeOption = (index: number) => {
|
||||
const newOptions = localConfig.options.filter((_, i) => i !== index);
|
||||
updateConfig("options", newOptions);
|
||||
|
||||
// 삭제된 옵션이 기본값이었다면 기본값 초기화
|
||||
const removedOption = localConfig.options[index];
|
||||
if (removedOption && localConfig.defaultValue === removedOption.value) {
|
||||
updateConfig("defaultValue", "");
|
||||
}
|
||||
};
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOption = (index: number, field: keyof RadioOption, value: any) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
const oldValue = newOptions[index].value;
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
updateConfig("options", newOptions);
|
||||
|
||||
// 값이 변경되고 해당 값이 기본값이었다면 기본값도 업데이트
|
||||
if (field === "value" && localConfig.defaultValue === oldValue) {
|
||||
updateConfig("defaultValue", value);
|
||||
}
|
||||
};
|
||||
|
||||
// 벌크 옵션 추가
|
||||
const addBulkOptions = () => {
|
||||
if (!bulkOptions.trim()) return;
|
||||
|
||||
const lines = bulkOptions.trim().split("\n");
|
||||
const newOptions: RadioOption[] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (trimmed.includes("|")) {
|
||||
// "라벨|값" 형식
|
||||
const [label, value] = trimmed.split("|").map((s) => s.trim());
|
||||
if (label && value) {
|
||||
newOptions.push({ label, value });
|
||||
}
|
||||
} else {
|
||||
// 라벨과 값이 같은 경우
|
||||
newOptions.push({ label: trimmed, value: trimmed });
|
||||
}
|
||||
});
|
||||
|
||||
if (newOptions.length > 0) {
|
||||
const combinedOptions = [...localConfig.options, ...newOptions];
|
||||
updateConfig("options", combinedOptions);
|
||||
setBulkOptions("");
|
||||
}
|
||||
};
|
||||
|
||||
// 기본 옵션 세트
|
||||
const defaultOptionSets = {
|
||||
yesno: [
|
||||
{ label: "예", value: "Y" },
|
||||
{ label: "아니오", value: "N" },
|
||||
],
|
||||
gender: [
|
||||
{ label: "남성", value: "M" },
|
||||
{ label: "여성", value: "F" },
|
||||
],
|
||||
agreement: [
|
||||
{ label: "동의", value: "agree" },
|
||||
{ label: "비동의", value: "disagree" },
|
||||
],
|
||||
rating: [
|
||||
{ label: "매우 좋음", value: "5" },
|
||||
{ label: "좋음", value: "4" },
|
||||
{ label: "보통", value: "3" },
|
||||
{ label: "나쁨", value: "2" },
|
||||
{ label: "매우 나쁨", value: "1" },
|
||||
],
|
||||
};
|
||||
|
||||
const applyDefaultSet = (setName: keyof typeof defaultOptionSets) => {
|
||||
updateConfig("options", defaultOptionSets[setName]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Radio className="h-4 w-4" />
|
||||
라디오버튼 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">라디오버튼 그룹의 옵션과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupLabel" className="text-xs">
|
||||
그룹 라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="groupLabel"
|
||||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
placeholder="라디오버튼 그룹 제목"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupName" className="text-xs">
|
||||
그룹 이름 (name 속성)
|
||||
</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
value={localConfig.groupName || ""}
|
||||
onChange={(e) => updateConfig("groupName", e.target.value)}
|
||||
placeholder="자동 생성 (필드명 기반)"
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">비워두면 필드명을 기반으로 자동 생성됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="inline" className="text-xs">
|
||||
가로 배열
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">라디오버튼들을 가로로 배열합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="inline"
|
||||
checked={localConfig.inline !== false}
|
||||
onCheckedChange={(checked) => updateConfig("inline", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 옵션 세트 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 옵션 세트</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
|
||||
예/아니오
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("gender")} className="text-xs">
|
||||
성별
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("agreement")} className="text-xs">
|
||||
동의/비동의
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("rating")} className="text-xs">
|
||||
평점
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 관리 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">옵션 관리</h4>
|
||||
|
||||
{/* 개별 옵션 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">개별 옵션 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 벌크 옵션 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">벌크 옵션 추가</Label>
|
||||
<Textarea
|
||||
value={bulkOptions}
|
||||
onChange={(e) => setBulkOptions(e.target.value)}
|
||||
placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu"
|
||||
className="h-20 text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addBulkOptions} disabled={!bulkOptions.trim()} className="text-xs">
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 현재 옵션 목록 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">현재 옵션 ({localConfig.options.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
|
||||
/>
|
||||
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본값</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 선택값
|
||||
</Label>
|
||||
<select
|
||||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
>
|
||||
<option value="">선택하지 않음</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
<option key={index} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">반드시 하나의 옵션을 선택해야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">선택값을 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<div className="space-y-2">
|
||||
{localConfig.groupLabel && <Label className="text-xs font-medium">{localConfig.groupLabel}</Label>}
|
||||
<div className={`space-y-1 ${localConfig.inline ? "flex flex-wrap gap-4" : ""}`}>
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`preview-radio-${index}`}
|
||||
name="preview-radio-group"
|
||||
value={option.value}
|
||||
disabled={localConfig.readonly || option.disabled}
|
||||
required={localConfig.required && index === 0} // 첫 번째에만 required 표시
|
||||
defaultChecked={localConfig.defaultValue === option.value}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label htmlFor={`preview-radio-${index}`} className="text-xs">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.options.length}개 옵션
|
||||
{localConfig.inline && " • 가로 배열"}
|
||||
{localConfig.required && " • 필수 선택"}
|
||||
{localConfig.defaultValue &&
|
||||
` • 기본값: ${localConfig.options.find((o) => o.value === localConfig.defaultValue)?.label}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
RadioConfigPanel.displayName = "RadioConfigPanel";
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, ChevronDown, List } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
||||
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as SelectTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<SelectTypeConfig>({
|
||||
options: config.options || [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
multiple: config.multiple || false,
|
||||
searchable: config.searchable || false,
|
||||
placeholder: config.placeholder || "선택하세요",
|
||||
defaultValue: config.defaultValue || "",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||
});
|
||||
|
||||
// 새 옵션 추가용 상태
|
||||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||
const [newOptionValue, setNewOptionValue] = useState("");
|
||||
const [bulkOptions, setBulkOptions] = useState("");
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as SelectTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
options: currentConfig.options || [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable || false,
|
||||
placeholder: currentConfig.placeholder || "선택하세요",
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 옵션 추가
|
||||
const addOption = () => {
|
||||
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
||||
|
||||
const newOption: SelectOption = {
|
||||
label: newOptionLabel.trim(),
|
||||
value: newOptionValue.trim(),
|
||||
};
|
||||
|
||||
const newOptions = [...localConfig.options, newOption];
|
||||
updateConfig("options", newOptions);
|
||||
setNewOptionLabel("");
|
||||
setNewOptionValue("");
|
||||
};
|
||||
|
||||
// 옵션 제거
|
||||
const removeOption = (index: number) => {
|
||||
const newOptions = localConfig.options.filter((_, i) => i !== index);
|
||||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOption = (index: number, field: keyof SelectOption, value: any) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
// 벌크 옵션 추가
|
||||
const addBulkOptions = () => {
|
||||
if (!bulkOptions.trim()) return;
|
||||
|
||||
const lines = bulkOptions.trim().split("\n");
|
||||
const newOptions: SelectOption[] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (trimmed.includes("|")) {
|
||||
// "라벨|값" 형식
|
||||
const [label, value] = trimmed.split("|").map((s) => s.trim());
|
||||
if (label && value) {
|
||||
newOptions.push({ label, value });
|
||||
}
|
||||
} else {
|
||||
// 라벨과 값이 같은 경우
|
||||
newOptions.push({ label: trimmed, value: trimmed });
|
||||
}
|
||||
});
|
||||
|
||||
if (newOptions.length > 0) {
|
||||
const combinedOptions = [...localConfig.options, ...newOptions];
|
||||
updateConfig("options", combinedOptions);
|
||||
setBulkOptions("");
|
||||
}
|
||||
};
|
||||
|
||||
// 기본 옵션 세트
|
||||
const defaultOptionSets = {
|
||||
yesno: [
|
||||
{ label: "예", value: "Y" },
|
||||
{ label: "아니오", value: "N" },
|
||||
],
|
||||
status: [
|
||||
{ label: "활성", value: "active" },
|
||||
{ label: "비활성", value: "inactive" },
|
||||
{ label: "대기", value: "pending" },
|
||||
],
|
||||
priority: [
|
||||
{ label: "높음", value: "high" },
|
||||
{ label: "보통", value: "medium" },
|
||||
{ label: "낮음", value: "low" },
|
||||
],
|
||||
};
|
||||
|
||||
const applyDefaultSet = (setName: keyof typeof defaultOptionSets) => {
|
||||
updateConfig("options", defaultOptionSets[setName]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<List className="h-4 w-4" />
|
||||
선택박스 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">드롭다운 선택박스의 옵션과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="선택하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emptyMessage" className="text-xs">
|
||||
빈 목록 메시지
|
||||
</Label>
|
||||
<Input
|
||||
id="emptyMessage"
|
||||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
placeholder="선택 가능한 옵션이 없습니다"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multiple" className="text-xs">
|
||||
다중 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">여러 옵션을 선택할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="multiple"
|
||||
checked={localConfig.multiple || false}
|
||||
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="searchable" className="text-xs">
|
||||
검색 가능
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">옵션을 검색할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="searchable"
|
||||
checked={localConfig.searchable || false}
|
||||
onCheckedChange={(checked) => updateConfig("searchable", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 옵션 세트 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 옵션 세트</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
|
||||
예/아니오
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
|
||||
상태
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
|
||||
우선순위
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 관리 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">옵션 관리</h4>
|
||||
|
||||
{/* 개별 옵션 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">개별 옵션 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 벌크 옵션 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">벌크 옵션 추가</Label>
|
||||
<Textarea
|
||||
value={bulkOptions}
|
||||
onChange={(e) => setBulkOptions(e.target.value)}
|
||||
placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu"
|
||||
className="h-20 text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addBulkOptions} disabled={!bulkOptions.trim()} className="text-xs">
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 현재 옵션 목록 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">현재 옵션 ({localConfig.options.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
|
||||
/>
|
||||
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본값</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 선택값
|
||||
</Label>
|
||||
<select
|
||||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
>
|
||||
<option value="">선택하지 않음</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
<option key={index} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">옵션을 반드시 선택해야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">선택값을 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<select
|
||||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
multiple={localConfig.multiple}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
defaultValue={localConfig.defaultValue}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{localConfig.placeholder}
|
||||
</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
<option key={index} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.multiple && "다중 선택 가능"}
|
||||
{localConfig.searchable && " • 검색 가능"}
|
||||
{localConfig.required && " • 필수 선택"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
SelectConfigPanel.displayName = "SelectConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
// import { Switch } from "@/components/ui/switch";
|
||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, TextTypeConfig } from "@/types/screen";
|
||||
|
||||
export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as TextTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<TextTypeConfig>({
|
||||
minLength: config.minLength || undefined,
|
||||
maxLength: config.maxLength || undefined,
|
||||
pattern: config.pattern || "",
|
||||
placeholder: config.placeholder || "",
|
||||
autoComplete: config.autoComplete || "off",
|
||||
format: config.format || "none",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as TextTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
minLength: currentConfig.minLength || undefined,
|
||||
maxLength: currentConfig.maxLength || undefined,
|
||||
pattern: currentConfig.pattern || "",
|
||||
placeholder: currentConfig.placeholder || "",
|
||||
autoComplete: currentConfig.autoComplete || "off",
|
||||
format: currentConfig.format || "none",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof TextTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">텍스트 설정</CardTitle>
|
||||
<CardDescription className="text-xs">텍스트 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minLength" className="text-xs">
|
||||
최소 길이
|
||||
</Label>
|
||||
<Input
|
||||
id="minLength"
|
||||
type="number"
|
||||
value={localConfig.minLength || ""}
|
||||
onChange={(e) => updateConfig("minLength", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength" className="text-xs">
|
||||
최대 길이
|
||||
</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
value={localConfig.maxLength || ""}
|
||||
onChange={(e) => updateConfig("maxLength", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="100"
|
||||
min="1"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 형식 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">형식 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format" className="text-xs">
|
||||
입력 형식
|
||||
</Label>
|
||||
<select
|
||||
id="format"
|
||||
value={localConfig.format || "none"}
|
||||
onChange={(e) => updateConfig("format", e.target.value)}
|
||||
className="border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="none">제한 없음</option>
|
||||
<option value="email">이메일</option>
|
||||
<option value="phone">전화번호</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="korean">한글만</option>
|
||||
<option value="english">영문만</option>
|
||||
<option value="alphanumeric">영숫자만</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pattern" className="text-xs">
|
||||
정규식 패턴
|
||||
</Label>
|
||||
<Input
|
||||
id="pattern"
|
||||
value={localConfig.pattern || ""}
|
||||
onChange={(e) => updateConfig("pattern", e.target.value)}
|
||||
placeholder="예: [A-Za-z0-9]+"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">JavaScript 정규식 패턴을 입력하세요.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="autoComplete" className="text-xs">
|
||||
자동완성
|
||||
</Label>
|
||||
<select
|
||||
id="autoComplete"
|
||||
value={localConfig.autoComplete || "off"}
|
||||
onChange={(e) => updateConfig("autoComplete", e.target.value)}
|
||||
className="border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="off">사용 안함</option>
|
||||
<option value="on">사용</option>
|
||||
<option value="name">이름</option>
|
||||
<option value="email">이메일</option>
|
||||
<option value="username">사용자명</option>
|
||||
<option value="current-password">현재 비밀번호</option>
|
||||
<option value="new-password">새 비밀번호</option>
|
||||
<option value="organization">조직명</option>
|
||||
<option value="street-address">주소</option>
|
||||
<option value="tel">전화번호</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">값이 입력되어야 합니다.</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onChange={(e) => updateConfig("required", e.target.checked)}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">값을 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onChange={(e) => updateConfig("readonly", e.target.checked)}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<Input
|
||||
placeholder={localConfig.placeholder || "미리보기"}
|
||||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
maxLength={localConfig.maxLength}
|
||||
minLength={localConfig.minLength}
|
||||
pattern={localConfig.pattern}
|
||||
autoComplete={localConfig.autoComplete}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
TextConfigPanel.displayName = "TextConfigPanel";
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { AlignLeft } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, TextareaTypeConfig } from "@/types/screen";
|
||||
|
||||
export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as TextareaTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
const [localConfig, setLocalConfig] = useState<TextareaTypeConfig>({
|
||||
rows: config.rows || 4,
|
||||
cols: config.cols || undefined,
|
||||
minLength: config.minLength || undefined,
|
||||
maxLength: config.maxLength || undefined,
|
||||
placeholder: config.placeholder || "",
|
||||
defaultValue: config.defaultValue || "",
|
||||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
resizable: config.resizable !== false, // 기본값 true
|
||||
autoHeight: config.autoHeight || false,
|
||||
showCharCount: config.showCharCount || false,
|
||||
wrap: config.wrap || "soft",
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as TextareaTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
rows: currentConfig.rows || 4,
|
||||
cols: currentConfig.cols || undefined,
|
||||
minLength: currentConfig.minLength || undefined,
|
||||
maxLength: currentConfig.maxLength || undefined,
|
||||
placeholder: currentConfig.placeholder || "",
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
resizable: currentConfig.resizable !== false,
|
||||
autoHeight: currentConfig.autoHeight || false,
|
||||
showCharCount: currentConfig.showCharCount || false,
|
||||
wrap: currentConfig.wrap || "soft",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: keyof TextareaTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 현재 문자 수 계산
|
||||
const currentCharCount = (localConfig.defaultValue || "").length;
|
||||
const isOverLimit = localConfig.maxLength ? currentCharCount > localConfig.maxLength : false;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
텍스트영역 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">여러 줄 텍스트 입력 영역의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="내용을 입력하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본값
|
||||
</Label>
|
||||
<Textarea
|
||||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
placeholder="기본 텍스트 내용"
|
||||
className="text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
{localConfig.showCharCount && (
|
||||
<div className={`text-xs ${isOverLimit ? "text-red-500" : "text-muted-foreground"}`}>
|
||||
{currentCharCount}
|
||||
{localConfig.maxLength && ` / ${localConfig.maxLength}`} 글자
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">크기 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rows" className="text-xs">
|
||||
행 수: {localConfig.rows}
|
||||
</Label>
|
||||
<Slider
|
||||
id="rows"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={[localConfig.rows || 4]}
|
||||
onValueChange={([value]) => updateConfig("rows", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>1줄</span>
|
||||
<span>20줄</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cols" className="text-xs">
|
||||
열 수 (선택사항)
|
||||
</Label>
|
||||
<Input
|
||||
id="cols"
|
||||
type="number"
|
||||
value={localConfig.cols || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
||||
updateConfig("cols", value);
|
||||
}}
|
||||
placeholder="자동 (CSS로 제어)"
|
||||
min={10}
|
||||
max={200}
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">비워두면 CSS width로 제어됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="resizable" className="text-xs">
|
||||
크기 조절 가능
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">사용자가 텍스트영역 크기를 조절할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="resizable"
|
||||
checked={localConfig.resizable || false}
|
||||
onCheckedChange={(checked) => updateConfig("resizable", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoHeight" className="text-xs">
|
||||
자동 높이
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">내용에 따라 높이가 자동으로 조절됩니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoHeight"
|
||||
checked={localConfig.autoHeight || false}
|
||||
onCheckedChange={(checked) => updateConfig("autoHeight", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 제한 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">텍스트 제한</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minLength" className="text-xs">
|
||||
최소 글자 수
|
||||
</Label>
|
||||
<Input
|
||||
id="minLength"
|
||||
type="number"
|
||||
value={localConfig.minLength || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
||||
updateConfig("minLength", value);
|
||||
}}
|
||||
placeholder="제한 없음"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength" className="text-xs">
|
||||
최대 글자 수
|
||||
</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
value={localConfig.maxLength || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
||||
updateConfig("maxLength", value);
|
||||
}}
|
||||
placeholder="제한 없음"
|
||||
min={1}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="showCharCount" className="text-xs">
|
||||
글자 수 표시
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">현재 입력된 글자 수를 표시합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showCharCount"
|
||||
checked={localConfig.showCharCount || false}
|
||||
onCheckedChange={(checked) => updateConfig("showCharCount", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 줄바꿈 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">줄바꿈 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">줄바꿈 방식</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateConfig("wrap", "soft")}
|
||||
className={`rounded border p-2 text-xs ${
|
||||
localConfig.wrap === "soft" ? "bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
Soft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateConfig("wrap", "hard")}
|
||||
className={`rounded border p-2 text-xs ${
|
||||
localConfig.wrap === "hard" ? "bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
Hard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateConfig("wrap", "off")}
|
||||
className={`rounded border p-2 text-xs ${
|
||||
localConfig.wrap === "off" ? "bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{localConfig.wrap === "soft" && "화면에서만 줄바꿈 (기본값)"}
|
||||
{localConfig.wrap === "hard" && "실제 텍스트에 줄바꿈 포함"}
|
||||
{localConfig.wrap === "off" && "줄바꿈 없음 (스크롤)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">텍스트가 입력되어야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
checked={localConfig.required || false}
|
||||
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">텍스트를 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
checked={localConfig.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<Textarea
|
||||
placeholder={localConfig.placeholder || "텍스트 입력 미리보기"}
|
||||
rows={localConfig.rows}
|
||||
cols={localConfig.cols}
|
||||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
minLength={localConfig.minLength}
|
||||
maxLength={localConfig.maxLength}
|
||||
defaultValue={localConfig.defaultValue}
|
||||
style={{
|
||||
resize: localConfig.resizable ? "both" : "none",
|
||||
minHeight: localConfig.autoHeight ? "auto" : undefined,
|
||||
}}
|
||||
className="text-xs"
|
||||
wrap={localConfig.wrap}
|
||||
/>
|
||||
{localConfig.showCharCount && (
|
||||
<div className="text-muted-foreground mt-1 text-right text-xs">
|
||||
0{localConfig.maxLength && ` / ${localConfig.maxLength}`} 글자
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.rows}행{localConfig.cols && ` × ${localConfig.cols}열`}
|
||||
{localConfig.resizable && " • 크기조절가능"}
|
||||
{localConfig.autoHeight && " • 자동높이"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
TextareaConfigPanel.displayName = "TextareaConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// Config panels for different web types
|
||||
export { TextConfigPanel } from "./TextConfigPanel";
|
||||
export { NumberConfigPanel } from "./NumberConfigPanel";
|
||||
export { DateConfigPanel } from "./DateConfigPanel";
|
||||
export { SelectConfigPanel } from "./SelectConfigPanel";
|
||||
export { TextareaConfigPanel } from "./TextareaConfigPanel";
|
||||
export { CheckboxConfigPanel } from "./CheckboxConfigPanel";
|
||||
export { RadioConfigPanel } from "./RadioConfigPanel";
|
||||
export { FileConfigPanel } from "./FileConfigPanel";
|
||||
export { CodeConfigPanel } from "./CodeConfigPanel";
|
||||
export { EntityConfigPanel } from "./EntityConfigPanel";
|
||||
|
||||
// Config panel registry mapping
|
||||
export const CONFIG_PANEL_REGISTRY = {
|
||||
// Text-based types
|
||||
text: "TextConfigPanel",
|
||||
email: "TextConfigPanel",
|
||||
password: "TextConfigPanel",
|
||||
tel: "TextConfigPanel",
|
||||
|
||||
// Number types
|
||||
number: "NumberConfigPanel",
|
||||
decimal: "NumberConfigPanel",
|
||||
|
||||
// Date types
|
||||
date: "DateConfigPanel",
|
||||
datetime: "DateConfigPanel",
|
||||
|
||||
// Selection types
|
||||
select: "SelectConfigPanel",
|
||||
dropdown: "SelectConfigPanel",
|
||||
|
||||
// Text area
|
||||
textarea: "TextareaConfigPanel",
|
||||
text_area: "TextareaConfigPanel",
|
||||
|
||||
// Boolean/Checkbox types
|
||||
boolean: "CheckboxConfigPanel",
|
||||
checkbox: "CheckboxConfigPanel",
|
||||
|
||||
// Radio button
|
||||
radio: "RadioConfigPanel",
|
||||
|
||||
// File upload
|
||||
file: "FileConfigPanel",
|
||||
|
||||
// Code editor
|
||||
code: "CodeConfigPanel",
|
||||
|
||||
// Entity selection
|
||||
entity: "EntityConfigPanel",
|
||||
} as const;
|
||||
|
||||
export type ConfigPanelType = keyof typeof CONFIG_PANEL_REGISTRY;
|
||||
export type ConfigPanelComponent = (typeof CONFIG_PANEL_REGISTRY)[ConfigPanelType];
|
||||
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { Table, Plus, Trash2, Settings, Filter, Columns, ChevronDown } from "lucide-react";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen";
|
||||
import { generateComponentId } from "@/lib/utils/generateId";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
|
||||
interface DataTableConfigPanelProps {
|
||||
component: DataTableComponent;
|
||||
|
|
@ -22,25 +23,20 @@ interface DataTableConfigPanelProps {
|
|||
onUpdateComponent: (updates: Partial<DataTableComponent>) => void;
|
||||
}
|
||||
|
||||
const webTypeOptions: { value: WebType; label: string }[] = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "decimal", label: "소수" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "datetime", label: "날짜시간" },
|
||||
{ value: "select", label: "선택박스" },
|
||||
{ value: "checkbox", label: "체크박스" },
|
||||
{ value: "email", label: "이메일" },
|
||||
{ value: "tel", label: "전화번호" },
|
||||
];
|
||||
|
||||
export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||
const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||
component,
|
||||
tables,
|
||||
activeTab: externalActiveTab,
|
||||
onTabChange,
|
||||
onUpdateComponent,
|
||||
}) => {
|
||||
// 동적 웹타입 옵션 가져오기
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const webTypeOptions = webTypes.map((wt) => ({
|
||||
value: wt.web_type as WebType,
|
||||
label: wt.type_name,
|
||||
}));
|
||||
|
||||
const [selectedTable, setSelectedTable] = useState<TableInfo | null>(null);
|
||||
|
||||
// 로컬 입력 상태 (실시간 타이핑용)
|
||||
|
|
@ -58,6 +54,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
deleteButtonText: component.deleteButtonText || "삭제",
|
||||
// 모달 설정
|
||||
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
||||
// 테이블명도 로컬 상태로 관리
|
||||
tableName: component.tableName || "",
|
||||
modalDescription: component.addModalConfig?.description || "",
|
||||
modalWidth: component.addModalConfig?.width || "lg",
|
||||
modalLayout: component.addModalConfig?.layout || "two-column",
|
||||
|
|
@ -176,6 +174,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
showFirstLast: component.pagination?.showFirstLast ?? true,
|
||||
gridColumns: component.gridColumns || 6,
|
||||
// 테이블명 동기화
|
||||
tableName: component.tableName || "",
|
||||
});
|
||||
|
||||
// 컬럼 라벨 로컬 상태 초기화 (기존 값이 없는 경우만)
|
||||
|
|
@ -283,14 +283,12 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
component.id,
|
||||
component.title,
|
||||
component.searchButtonText,
|
||||
component.columns,
|
||||
component.filters,
|
||||
component.showSearchButton,
|
||||
component.enableExport,
|
||||
component.enableRefresh,
|
||||
component.pagination,
|
||||
component.columns.length, // 컬럼 개수 변경 감지
|
||||
component.filters.length, // 필터 개수 변경 감지
|
||||
component.columns.length, // 컬럼 개수만 감지
|
||||
component.filters.length, // 필터 개수만 감지
|
||||
]);
|
||||
|
||||
// 선택된 테이블 정보 로드
|
||||
|
|
@ -304,18 +302,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
// 테이블 변경 시 컬럼 자동 설정
|
||||
const handleTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
// 이미 같은 테이블이 선택되어 있으면 무시
|
||||
if (localValues.tableName === tableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 로컬 상태 먼저 업데이트
|
||||
setLocalValues((prev) => ({ ...prev, tableName }));
|
||||
|
||||
const table = tables.find((t) => t.tableName === tableName);
|
||||
if (!table) return;
|
||||
|
||||
console.log("🔄 테이블 변경:", {
|
||||
tableName,
|
||||
currentTableName: localValues.tableName,
|
||||
table,
|
||||
columnsCount: table.columns.length,
|
||||
columns: table.columns.map((col) => ({
|
||||
name: col.columnName,
|
||||
label: col.columnLabel,
|
||||
type: col.dataType,
|
||||
})),
|
||||
});
|
||||
|
||||
// 테이블의 모든 컬럼을 기본 설정으로 추가
|
||||
|
|
@ -331,22 +333,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
|
||||
}));
|
||||
|
||||
// 필터는 사용자가 수동으로 추가
|
||||
|
||||
console.log("✅ 생성된 컬럼 설정:", {
|
||||
defaultColumnsCount: defaultColumns.length,
|
||||
visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
||||
});
|
||||
|
||||
// 상태 업데이트를 한 번에 처리
|
||||
setTimeout(() => {
|
||||
onUpdateComponent({
|
||||
tableName,
|
||||
columns: defaultColumns,
|
||||
filters: [], // 빈 필터 배열
|
||||
});
|
||||
|
||||
setSelectedTable(table);
|
||||
}, 0);
|
||||
},
|
||||
[tables, onUpdateComponent],
|
||||
[tables, onUpdateComponent, localValues.tableName],
|
||||
);
|
||||
|
||||
// 컬럼 타입 추론
|
||||
|
|
@ -556,15 +558,15 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
{webType === "radio" ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">기본값 설정</Label>
|
||||
<Select
|
||||
<select
|
||||
value={localSettings.defaultValue || "__NONE__"}
|
||||
onValueChange={(value) => updateSettings({ defaultValue: value === "__NONE__" ? "" : value })}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings({ defaultValue: value === "__NONE__" ? "" : value });
|
||||
}}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-full items-center justify-between rounded-md border px-3 py-1 text-xs focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="기본값 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__NONE__">선택 안함</SelectItem>
|
||||
<option value="__NONE__">선택 안함</option>
|
||||
{(localSettings.options || []).map((option: any, index: number) => {
|
||||
// 안전한 문자열 변환
|
||||
const getStringValue = (val: any): string => {
|
||||
|
|
@ -577,17 +579,15 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
const optionValue = getStringValue(option.value || option.label || option) || `option-${index}`;
|
||||
const optionLabel =
|
||||
getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
|
||||
const optionLabel = getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
|
||||
|
||||
return (
|
||||
<SelectItem key={index} value={optionValue}>
|
||||
<option key={index} value={optionValue}>
|
||||
{optionLabel}
|
||||
</SelectItem>
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -943,8 +943,9 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
// 웹 타입별 필터 가능 여부 확인
|
||||
const isFilterableWebType = (webType: WebType): boolean => {
|
||||
const filterableTypes: WebType[] = ["text", "number", "decimal", "date", "datetime", "select", "email", "tel"];
|
||||
return filterableTypes.includes(webType);
|
||||
// 대부분의 웹타입은 필터링 가능 (파일, 버튼 등만 제외)
|
||||
const nonFilterableTypes = ["file", "button", "image"];
|
||||
return !nonFilterableTypes.includes(webType);
|
||||
};
|
||||
|
||||
// 컬럼 추가 (테이블에서 선택)
|
||||
|
|
@ -1148,18 +1149,18 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="table-select">연결 테이블</Label>
|
||||
<Select value={component.tableName} onValueChange={handleTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localValues.tableName}
|
||||
onChange={(e) => handleTableChange(e.target.value)}
|
||||
>
|
||||
<option value="">테이블을 선택하세요</option>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
<option key={table.tableName} value={table.tableName}>
|
||||
{table.tableLabel || table.tableName}
|
||||
</SelectItem>
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1314,27 +1315,24 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
<Label htmlFor="modal-width" className="text-sm">
|
||||
모달 크기
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
value={localValues.modalWidth}
|
||||
onValueChange={(value) => {
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, modalWidth: value as any }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, width: value as any },
|
||||
});
|
||||
}}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (384px)</SelectItem>
|
||||
<SelectItem value="md">보통 (448px)</SelectItem>
|
||||
<SelectItem value="lg">큼 (512px)</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (576px)</SelectItem>
|
||||
<SelectItem value="2xl">특대 (672px)</SelectItem>
|
||||
<SelectItem value="full">전체 너비</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="sm">작음 (384px)</option>
|
||||
<option value="md">보통 (448px)</option>
|
||||
<option value="lg">큼 (512px)</option>
|
||||
<option value="xl">매우 큼 (576px)</option>
|
||||
<option value="2xl">특대 (672px)</option>
|
||||
<option value="full">전체 너비</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1362,24 +1360,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
<Label htmlFor="modal-layout" className="text-sm">
|
||||
레이아웃
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
value={localValues.modalLayout}
|
||||
onValueChange={(value) => {
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, modalLayout: value as any }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, layout: value as any },
|
||||
});
|
||||
}}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single">단일 컬럼</SelectItem>
|
||||
<SelectItem value="two-column">2컬럼</SelectItem>
|
||||
<SelectItem value="grid">그리드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="single">단일 컬럼</option>
|
||||
<option value="two-column">2컬럼</option>
|
||||
<option value="grid">그리드</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{localValues.modalLayout === "grid" && (
|
||||
|
|
@ -1387,25 +1382,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
<Label htmlFor="modal-grid-columns" className="text-sm">
|
||||
그리드 컬럼 수
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
value={localValues.modalGridColumns.toString()}
|
||||
onValueChange={(value) => {
|
||||
const gridColumns = parseInt(value);
|
||||
onChange={(e) => {
|
||||
const gridColumns = parseInt(e.target.value);
|
||||
setLocalValues((prev) => ({ ...prev, modalGridColumns: gridColumns }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, gridColumns },
|
||||
});
|
||||
}}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2컬럼</SelectItem>
|
||||
<SelectItem value="3">3컬럼</SelectItem>
|
||||
<SelectItem value="4">4컬럼</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="2">2컬럼</option>
|
||||
<option value="3">3컬럼</option>
|
||||
<option value="4">4컬럼</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1454,26 +1445,23 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="grid-columns">그리드 컬럼 수</Label>
|
||||
<Select
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localValues.gridColumns.toString()}
|
||||
onValueChange={(value) => {
|
||||
const gridColumns = parseInt(value, 10);
|
||||
onChange={(e) => {
|
||||
const gridColumns = parseInt(e.target.value, 10);
|
||||
console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
|
||||
setLocalValues((prev) => ({ ...prev, gridColumns }));
|
||||
onUpdateComponent({ gridColumns });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="그리드 컬럼 수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<option value="">그리드 컬럼 수 선택</option>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
<option key={num} value={num.toString()}>
|
||||
{num}컬럼 ({Math.round((num / 12) * 100)}%)
|
||||
</SelectItem>
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -1541,18 +1529,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
);
|
||||
|
||||
return availableColumns.length > 0 ? (
|
||||
<Select onValueChange={(value) => addColumn(value)}>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="DB 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addColumn(e.target.value);
|
||||
e.target.value = ""; // 선택 후 초기화
|
||||
}
|
||||
}}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-32 items-center justify-between rounded-md border px-3 py-1 text-xs focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">DB 컬럼</option>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
<option key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
) : (
|
||||
<Button size="sm" disabled>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
|
@ -2196,28 +2188,24 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>페이지당 행 수</Label>
|
||||
<Select
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={component.pagination.pageSize.toString()}
|
||||
onValueChange={(value) =>
|
||||
onChange={(e) =>
|
||||
onUpdateComponent({
|
||||
pagination: {
|
||||
...component.pagination,
|
||||
pageSize: parseInt(value),
|
||||
pageSize: parseInt(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[5, 10, 20, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
<option key={size} value={size.toString()}>
|
||||
{size}개
|
||||
</SelectItem>
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2299,4 +2287,42 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// React.memo로 감싸서 불필요한 리렌더링 방지
|
||||
export const DataTableConfigPanel = React.memo(DataTableConfigPanelComponent, (prevProps, nextProps) => {
|
||||
// 컴포넌트 ID가 다르면 리렌더링
|
||||
if (prevProps.component.id !== nextProps.component.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 테이블 목록이 변경되면 리렌더링
|
||||
if (prevProps.tables.length !== nextProps.tables.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 활성 탭이 변경되면 리렌더링
|
||||
if (prevProps.activeTab !== nextProps.activeTab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 컬럼 개수나 필터 개수가 변경되면 리렌더링
|
||||
if (
|
||||
prevProps.component.columns?.length !== nextProps.component.columns?.length ||
|
||||
prevProps.component.filters?.length !== nextProps.component.filters?.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 기본 속성들이 변경되면 리렌더링
|
||||
if (
|
||||
prevProps.component.title !== nextProps.component.title ||
|
||||
prevProps.component.tableName !== nextProps.component.tableName ||
|
||||
prevProps.component.searchButtonText !== nextProps.component.searchButtonText
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 그 외의 경우는 리렌더링하지 않음
|
||||
return true;
|
||||
});
|
||||
|
||||
export default DataTableConfigPanel;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import React from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
|
|
@ -30,6 +31,7 @@ import { CheckboxTypeConfigPanel } from "./webtype-configs/CheckboxTypeConfigPan
|
|||
import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
|
||||
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
|
||||
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
|
||||
import { RatingTypeConfigPanel, RatingTypeConfig } from "./webtype-configs/RatingTypeConfigPanel";
|
||||
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
|
||||
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
|
|
@ -47,24 +49,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
currentTable,
|
||||
currentTableName,
|
||||
}) => {
|
||||
// 입력 가능한 웹타입들 정의
|
||||
const inputableWebTypes = [
|
||||
"text",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"select",
|
||||
"dropdown",
|
||||
"textarea",
|
||||
"email",
|
||||
"tel",
|
||||
"code",
|
||||
"entity",
|
||||
"file",
|
||||
"checkbox",
|
||||
"radio",
|
||||
];
|
||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
|
||||
|
||||
// 웹타입별 상세 설정 렌더링 함수
|
||||
const renderWebTypeConfig = React.useCallback(
|
||||
|
|
@ -185,6 +172,17 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
/>
|
||||
);
|
||||
|
||||
case "rating":
|
||||
case "star":
|
||||
case "score":
|
||||
return (
|
||||
<RatingTypeConfigPanel
|
||||
key={`${widget.id}-rating`}
|
||||
config={currentConfig as RatingTypeConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "entity":
|
||||
return (
|
||||
<EntityTypeConfigPanel
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // 임시 비활성화
|
||||
// import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
||||
import {
|
||||
ComponentData,
|
||||
|
|
@ -20,6 +20,77 @@ import {
|
|||
TableInfo,
|
||||
} from "@/types/screen";
|
||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||
import { DynamicConfigPanel } from "@/lib/registry";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
|
||||
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
|
||||
const DataTableConfigPanelWrapper: React.FC<{
|
||||
selectedComponent: DataTableComponent;
|
||||
tables: TableInfo[];
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
onUpdateProperty: (property: string, value: any) => void;
|
||||
}> = React.memo(
|
||||
({ selectedComponent, tables, activeTab, onTabChange, onUpdateProperty }) => {
|
||||
// 안정화된 업데이트 핸들러
|
||||
const handleUpdateComponent = React.useCallback(
|
||||
(updates: Partial<DataTableComponent>) => {
|
||||
console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates);
|
||||
|
||||
// 변경사항이 있는지 확인 (간단한 비교로 성능 향상)
|
||||
const hasChanges = Object.entries(updates).some(([key, value]) => {
|
||||
const currentValue = (selectedComponent as any)[key];
|
||||
// 배열의 경우 길이만 비교
|
||||
if (Array.isArray(currentValue) && Array.isArray(value)) {
|
||||
return currentValue.length !== value.length;
|
||||
}
|
||||
// 기본값 비교
|
||||
return currentValue !== value;
|
||||
});
|
||||
|
||||
if (!hasChanges) {
|
||||
console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 속성을 개별적으로 업데이트
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
onUpdateProperty(key, value);
|
||||
});
|
||||
},
|
||||
[selectedComponent.id, onUpdateProperty],
|
||||
); // ID만 의존성으로 사용
|
||||
|
||||
return (
|
||||
<DataTableConfigPanel
|
||||
component={selectedComponent}
|
||||
tables={tables}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
onUpdateComponent={handleUpdateComponent}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// 컴포넌트 ID가 다르면 리렌더링
|
||||
if (prevProps.selectedComponent.id !== nextProps.selectedComponent.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 테이블 목록이 변경되면 리렌더링
|
||||
if (prevProps.tables.length !== nextProps.tables.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 활성 탭이 변경되면 리렌더링
|
||||
if (prevProps.activeTab !== nextProps.activeTab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 그 외의 경우는 리렌더링하지 않음
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
interface PropertiesPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
|
|
@ -33,27 +104,9 @@ interface PropertiesPanelProps {
|
|||
canUngroup?: boolean;
|
||||
}
|
||||
|
||||
const webTypeOptions: { value: WebType; label: string }[] = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "email", label: "이메일" },
|
||||
{ value: "tel", label: "전화번호" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "decimal", label: "소수" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "datetime", label: "날짜시간" },
|
||||
{ value: "select", label: "선택박스" },
|
||||
{ value: "dropdown", label: "드롭다운" },
|
||||
{ value: "textarea", label: "텍스트영역" },
|
||||
{ value: "boolean", label: "불린" },
|
||||
{ value: "checkbox", label: "체크박스" },
|
||||
{ value: "radio", label: "라디오" },
|
||||
{ value: "code", label: "코드" },
|
||||
{ value: "entity", label: "엔티티" },
|
||||
{ value: "file", label: "파일" },
|
||||
{ value: "button", label: "버튼" },
|
||||
];
|
||||
// 동적 웹타입 옵션은 컴포넌트 내부에서 useWebTypes 훅으로 가져옵니다
|
||||
|
||||
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
selectedComponent,
|
||||
tables = [],
|
||||
onUpdateProperty,
|
||||
|
|
@ -64,6 +117,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
canGroup = false,
|
||||
canUngroup = false,
|
||||
}) => {
|
||||
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
|
||||
const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" });
|
||||
|
||||
// 웹타입 옵션 생성 - 데이터베이스 기반
|
||||
const webTypeOptions = webTypes.map((webType) => ({
|
||||
value: webType.web_type as WebType,
|
||||
label: webType.type_name,
|
||||
}));
|
||||
|
||||
// 데이터테이블 설정 탭 상태를 여기서 관리
|
||||
const [dataTableActiveTab, setDataTableActiveTab] = useState("basic");
|
||||
// 최신 값들의 참조를 유지
|
||||
|
|
@ -93,6 +155,9 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
|
||||
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
|
||||
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
|
||||
// widgetType도 로컬 상태로 관리
|
||||
widgetType:
|
||||
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -136,14 +201,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
required: widget?.required || false,
|
||||
readonly: widget?.readonly || false,
|
||||
labelDisplay: selectedComponent.style?.labelDisplay !== false,
|
||||
// widgetType 동기화
|
||||
widgetType: widget?.widgetType || "text",
|
||||
});
|
||||
}
|
||||
}, [
|
||||
selectedComponent,
|
||||
selectedComponent?.position,
|
||||
selectedComponent?.size,
|
||||
selectedComponent?.style,
|
||||
selectedComponent?.label,
|
||||
selectedComponent?.id, // ID만 감지하여 컴포넌트 변경 시에만 업데이트
|
||||
]);
|
||||
|
||||
if (!selectedComponent) {
|
||||
|
|
@ -187,30 +250,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
|
||||
{/* 데이터 테이블 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<DataTableConfigPanel
|
||||
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`}
|
||||
component={selectedComponent as DataTableComponent}
|
||||
<DataTableConfigPanelWrapper
|
||||
selectedComponent={selectedComponent as DataTableComponent}
|
||||
tables={tables}
|
||||
activeTab={dataTableActiveTab}
|
||||
onTabChange={setDataTableActiveTab}
|
||||
onUpdateComponent={(updates) => {
|
||||
console.log("🔄 DataTable 컴포넌트 업데이트:", updates);
|
||||
console.log("🔄 업데이트 항목들:", Object.keys(updates));
|
||||
|
||||
// 각 속성을 개별적으로 업데이트
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
console.log(` - ${key}:`, value);
|
||||
if (key === "columns") {
|
||||
console.log(` 컬럼 개수: ${Array.isArray(value) ? value.length : 0}`);
|
||||
}
|
||||
if (key === "filters") {
|
||||
console.log(` 필터 개수: ${Array.isArray(value) ? value.length : 0}`);
|
||||
}
|
||||
onUpdateProperty(key, value);
|
||||
});
|
||||
|
||||
console.log("✅ DataTable 컴포넌트 업데이트 완료");
|
||||
}}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -289,23 +334,56 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
<Label htmlFor="widgetType" className="text-sm font-medium">
|
||||
위젯 타입
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedComponent.widgetType || "text"}
|
||||
onValueChange={(value) => onUpdateProperty("widgetType", value)}
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localInputs.widgetType}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as WebType;
|
||||
setLocalInputs((prev) => ({ ...prev, widgetType: value }));
|
||||
onUpdateProperty("widgetType", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{webTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 동적 웹타입 설정 패널 */}
|
||||
{selectedComponent.widgetType && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-sm font-medium">웹타입 세부 설정</Label>
|
||||
<div className="mt-2">
|
||||
<DynamicConfigPanel
|
||||
webType={selectedComponent.widgetType}
|
||||
component={selectedComponent}
|
||||
onUpdateComponent={(updatedComponent) => {
|
||||
// 컴포넌트 전체 업데이트
|
||||
Object.keys(updatedComponent).forEach((key) => {
|
||||
if (key !== "id") {
|
||||
onUpdateProperty(key, updatedComponent[key]);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onUpdateProperty={(property, value) => {
|
||||
// 웹타입 설정 업데이트
|
||||
if (property === "webTypeConfig") {
|
||||
onUpdateProperty("webTypeConfig", value);
|
||||
} else {
|
||||
onUpdateProperty(property, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="placeholder" className="text-sm font-medium">
|
||||
플레이스홀더
|
||||
|
|
@ -326,13 +404,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={localInputs.required}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalInputs((prev) => ({ ...prev, required: !!checked }));
|
||||
onUpdateProperty("required", checked);
|
||||
onChange={(e) => {
|
||||
setLocalInputs((prev) => ({ ...prev, required: e.target.checked }));
|
||||
onUpdateProperty("required", e.target.checked);
|
||||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<Label htmlFor="required" className="text-sm">
|
||||
필수 입력
|
||||
|
|
@ -340,13 +420,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
<input
|
||||
type="checkbox"
|
||||
id="readonly"
|
||||
checked={localInputs.readonly}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalInputs((prev) => ({ ...prev, readonly: !!checked }));
|
||||
onUpdateProperty("readonly", checked);
|
||||
onChange={(e) => {
|
||||
setLocalInputs((prev) => ({ ...prev, readonly: e.target.checked }));
|
||||
onUpdateProperty("readonly", e.target.checked);
|
||||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-sm">
|
||||
읽기 전용
|
||||
|
|
@ -498,14 +580,16 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
<Label htmlFor="labelDisplay" className="text-sm font-medium">
|
||||
라벨 표시
|
||||
</Label>
|
||||
<Checkbox
|
||||
<input
|
||||
type="checkbox"
|
||||
id="labelDisplay"
|
||||
checked={localInputs.labelDisplay}
|
||||
onCheckedChange={(checked) => {
|
||||
console.log("🔄 라벨 표시 변경:", checked);
|
||||
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
|
||||
onUpdateProperty("style.labelDisplay", checked);
|
||||
onChange={(e) => {
|
||||
console.log("🔄 라벨 표시 변경:", e.target.checked);
|
||||
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
|
||||
onUpdateProperty("style.labelDisplay", e.target.checked);
|
||||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -570,46 +654,38 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
|
||||
폰트 굵기
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onValueChange={(value) => onUpdateProperty("style.labelFontWeight", value)}
|
||||
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="bold">Bold</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="300">300</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
<SelectItem value="800">800</SelectItem>
|
||||
<SelectItem value="900">900</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="bold">Bold</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="300">300</option>
|
||||
<option value="400">400</option>
|
||||
<option value="500">500</option>
|
||||
<option value="600">600</option>
|
||||
<option value="700">700</option>
|
||||
<option value="800">800</option>
|
||||
<option value="900">900</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
|
||||
텍스트 정렬
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={selectedComponent.style?.labelTextAlign || "left"}
|
||||
onValueChange={(value) => onUpdateProperty("style.labelTextAlign", value)}
|
||||
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -712,27 +788,23 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
<Label htmlFor="layoutType" className="text-sm font-medium">
|
||||
레이아웃 타입
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={(selectedComponent as AreaComponent).layoutType}
|
||||
onValueChange={(value: AreaLayoutType) => onUpdateProperty("layoutType", value)}
|
||||
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="box">기본 박스</SelectItem>
|
||||
<SelectItem value="card">카드</SelectItem>
|
||||
<SelectItem value="panel">패널 (헤더 포함)</SelectItem>
|
||||
<SelectItem value="section">섹션</SelectItem>
|
||||
<SelectItem value="grid">그리드</SelectItem>
|
||||
<SelectItem value="flex-row">가로 플렉스</SelectItem>
|
||||
<SelectItem value="flex-column">세로 플렉스</SelectItem>
|
||||
<SelectItem value="sidebar">사이드바</SelectItem>
|
||||
<SelectItem value="header-content">헤더-컨텐츠</SelectItem>
|
||||
<SelectItem value="tabs">탭</SelectItem>
|
||||
<SelectItem value="accordion">아코디언</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="box">기본 박스</option>
|
||||
<option value="card">카드</option>
|
||||
<option value="panel">패널 (헤더 포함)</option>
|
||||
<option value="section">섹션</option>
|
||||
<option value="grid">그리드</option>
|
||||
<option value="flex-row">가로 플렉스</option>
|
||||
<option value="flex-column">세로 플렉스</option>
|
||||
<option value="sidebar">사이드바</option>
|
||||
<option value="header-content">헤더-컨텐츠</option>
|
||||
<option value="tabs">탭</option>
|
||||
<option value="accordion">아코디언</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃별 상세 설정 */}
|
||||
|
|
@ -777,22 +849,18 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
<h5 className="text-sm font-medium">플렉스 설정</h5>
|
||||
<div>
|
||||
<Label className="text-xs">정렬 방식</Label>
|
||||
<Select
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
|
||||
onValueChange={(value) => onUpdateProperty("layoutConfig.justifyContent", value)}
|
||||
onChange={(e) => onUpdateProperty("layoutConfig.justifyContent", e.target.value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flex-start">시작</SelectItem>
|
||||
<SelectItem value="flex-end">끝</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="space-between">양끝 정렬</SelectItem>
|
||||
<SelectItem value="space-around">균등 분배</SelectItem>
|
||||
<SelectItem value="space-evenly">균등 간격</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="flex-start">시작</option>
|
||||
<option value="flex-end">끝</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="space-between">양끝 정렬</option>
|
||||
<option value="space-around">균등 분배</option>
|
||||
<option value="space-evenly">균등 간격</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">간격 (px)</Label>
|
||||
|
|
@ -815,18 +883,14 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
<h5 className="text-sm font-medium">사이드바 설정</h5>
|
||||
<div>
|
||||
<Label className="text-xs">사이드바 위치</Label>
|
||||
<Select
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
|
||||
onValueChange={(value) => onUpdateProperty("layoutConfig.sidebarPosition", value)}
|
||||
onChange={(e) => onUpdateProperty("layoutConfig.sidebarPosition", e.target.value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">사이드바 너비 (px)</Label>
|
||||
|
|
@ -851,4 +915,30 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// React.memo로 감싸서 불필요한 리렌더링 방지
|
||||
export const PropertiesPanel = React.memo(PropertiesPanelComponent, (prevProps, nextProps) => {
|
||||
// 선택된 컴포넌트 ID가 다르면 리렌더링
|
||||
if (prevProps.selectedComponent?.id !== nextProps.selectedComponent?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 선택된 컴포넌트가 없는 상태에서 있는 상태로 변경되거나 그 반대인 경우
|
||||
if (!prevProps.selectedComponent !== !nextProps.selectedComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 테이블 목록이 변경되면 리렌더링
|
||||
if (prevProps.tables.length !== nextProps.tables.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 그룹 관련 props가 변경되면 리렌더링
|
||||
if (prevProps.canGroup !== nextProps.canGroup || prevProps.canUngroup !== nextProps.canUngroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 그 외의 경우는 리렌더링하지 않음
|
||||
return true;
|
||||
});
|
||||
|
||||
export default PropertiesPanel;
|
||||
|
|
|
|||
|
|
@ -149,16 +149,16 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
|||
<Label htmlFor="layout" className="text-sm font-medium">
|
||||
레이아웃 방향
|
||||
</Label>
|
||||
<Select value={localValues.layout} onValueChange={(value) => updateConfig("layout", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="레이아웃 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
<SelectItem value="grid">격자 (2열)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localValues.layout}
|
||||
onChange={(e) => updateConfig("layout", e.target.value)}
|
||||
>
|
||||
<option value="">레이아웃 선택</option>
|
||||
<option value="vertical">세로</option>
|
||||
<option value="horizontal">가로</option>
|
||||
<option value="grid">격자 (2열)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 기본값 */}
|
||||
|
|
@ -166,22 +166,18 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
|||
<Label htmlFor="defaultValue" className="text-sm font-medium">
|
||||
기본 선택값
|
||||
</Label>
|
||||
<Select
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localValues.defaultValue || "__none__"}
|
||||
onValueChange={(value) => updateConfig("defaultValue", value)}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="기본값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
<option value="__none__">선택 안함</option>
|
||||
{(safeConfig.options || []).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 선택 안함 허용 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export interface RatingTypeConfig {
|
||||
maxRating?: number;
|
||||
allowHalf?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
interface RatingTypeConfigPanelProps {
|
||||
config: RatingTypeConfig;
|
||||
onConfigChange: (config: RatingTypeConfig) => void;
|
||||
}
|
||||
|
||||
export const RatingTypeConfigPanel: React.FC<RatingTypeConfigPanelProps> = ({ config, onConfigChange }) => {
|
||||
const handleMaxRatingChange = (value: string) => {
|
||||
const maxRating = parseInt(value) || 5;
|
||||
onConfigChange({ ...config, maxRating });
|
||||
};
|
||||
|
||||
const handleAllowHalfChange = (allowHalf: boolean) => {
|
||||
onConfigChange({ ...config, allowHalf });
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: "sm" | "md" | "lg") => {
|
||||
onConfigChange({ ...config, size });
|
||||
};
|
||||
|
||||
const handleShowLabelChange = (showLabel: boolean) => {
|
||||
onConfigChange({ ...config, showLabel });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxRating">최대 별점</Label>
|
||||
<Input
|
||||
id="maxRating"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={config.maxRating || 5}
|
||||
onChange={(e) => handleMaxRatingChange(e.target.value)}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="allowHalf">반점 허용</Label>
|
||||
<Switch id="allowHalf" checked={config.allowHalf || false} onCheckedChange={handleAllowHalfChange} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size">크기</Label>
|
||||
<Select value={config.size || "md"} onValueChange={handleSizeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작게</SelectItem>
|
||||
<SelectItem value="md">보통</SelectItem>
|
||||
<SelectItem value="lg">크게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showLabel">점수 표시</Label>
|
||||
<Switch id="showLabel" checked={config.showLabel ?? true} onCheckedChange={handleShowLabelChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RatingTypeConfigPanel.displayName = "RatingTypeConfigPanel";
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
|
||||
export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||
config,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
readonly,
|
||||
placeholder,
|
||||
required,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
||||
console.log("Button clicked:", config);
|
||||
|
||||
// onChange를 통해 클릭 이벤트 전달
|
||||
if (onChange) {
|
||||
onChange("clicked");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled || readonly}
|
||||
className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
|
||||
style={style}
|
||||
title={config?.tooltip || placeholder}
|
||||
>
|
||||
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, CheckboxTypeConfig } from "@/types/screen";
|
||||
|
||||
export const CheckboxWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { required } = widget;
|
||||
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
|
||||
|
||||
// 체크박스 값 처리
|
||||
const isChecked = value === true || value === "true" || value === "Y" || value === 1;
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
// 설정에 따라 값 형식 결정
|
||||
const outputValue =
|
||||
config?.outputFormat === "YN"
|
||||
? checked
|
||||
? "Y"
|
||||
: "N"
|
||||
: config?.outputFormat === "10"
|
||||
? checked
|
||||
? 1
|
||||
: 0
|
||||
: checked;
|
||||
|
||||
onChange?.(outputValue);
|
||||
};
|
||||
|
||||
// 체크박스 텍스트
|
||||
const checkboxText = config?.text || "체크하세요";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`checkbox-${widget.id}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`checkbox-${widget.id}`}
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{checkboxText}
|
||||
{required && <span className="ml-1 text-red-500">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CheckboxWidget.displayName = "CheckboxWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, CodeTypeConfig } from "@/types/screen";
|
||||
|
||||
export const CodeWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
||||
// 코드 목록 가져오기
|
||||
const getCodeOptions = () => {
|
||||
if (config?.codeCategory) {
|
||||
// 실제 구현에서는 API를 통해 코드 목록을 가져옴
|
||||
// 여기서는 예시 데이터 사용
|
||||
return [
|
||||
{ code: "CODE001", name: "코드 1", category: config.codeCategory },
|
||||
{ code: "CODE002", name: "코드 2", category: config.codeCategory },
|
||||
{ code: "CODE003", name: "코드 3", category: config.codeCategory },
|
||||
];
|
||||
}
|
||||
|
||||
// 기본 코드 옵션들
|
||||
return [
|
||||
{ code: "DEFAULT001", name: "기본 코드 1", category: "DEFAULT" },
|
||||
{ code: "DEFAULT002", name: "기본 코드 2", category: "DEFAULT" },
|
||||
{ code: "DEFAULT003", name: "기본 코드 3", category: "DEFAULT" },
|
||||
];
|
||||
};
|
||||
|
||||
const codeOptions = getCodeOptions();
|
||||
|
||||
// 선택된 코드 정보 찾기
|
||||
const selectedCode = codeOptions.find((option) => option.code === value);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={readonly} required={required}>
|
||||
<SelectTrigger className={`h-full w-full ${hasCustomBorder ? "!border-0" : ""}`} style={style}>
|
||||
<SelectValue placeholder={placeholder || config?.placeholder || "코드를 선택하세요..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeOptions.map((option) => (
|
||||
<SelectItem key={option.code} value={option.code}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{option.code}
|
||||
</Badge>
|
||||
<span>{option.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 선택된 코드 정보 표시 */}
|
||||
{selectedCode && config?.showDetails && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{selectedCode.category} - {selectedCode.code}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 코드 카테고리 표시 */}
|
||||
{config?.codeCategory && (
|
||||
<div className="mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{config.codeCategory}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CodeWidget.displayName = "CodeWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
||||
|
||||
export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
const formatDateValue = (val: string) => {
|
||||
if (!val) return "";
|
||||
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return val;
|
||||
|
||||
if (widget.widgetType === "datetime") {
|
||||
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
|
||||
} else {
|
||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
} catch {
|
||||
return val;
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 유효성 검증
|
||||
const validateDate = (dateStr: string): boolean => {
|
||||
if (!dateStr) return true;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return false;
|
||||
|
||||
// 최소/최대 날짜 검증
|
||||
if (config?.minDate) {
|
||||
const minDate = new Date(config.minDate);
|
||||
if (date < minDate) return false;
|
||||
}
|
||||
|
||||
if (config?.maxDate) {
|
||||
const maxDate = new Date(config.maxDate);
|
||||
if (date > maxDate) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 입력값 처리
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (validateDate(inputValue)) {
|
||||
onChange?.(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 웹타입에 따른 input type 결정
|
||||
const getInputType = () => {
|
||||
switch (widget.widgetType) {
|
||||
case "datetime":
|
||||
return "datetime-local";
|
||||
case "date":
|
||||
default:
|
||||
return "date";
|
||||
}
|
||||
};
|
||||
|
||||
// 기본값 설정 (현재 날짜/시간)
|
||||
const getDefaultValue = () => {
|
||||
if (config?.defaultValue === "current") {
|
||||
const now = new Date();
|
||||
if (widget.widgetType === "datetime") {
|
||||
return now.toISOString().slice(0, 16);
|
||||
} else {
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const finalValue = value || getDefaultValue();
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={getInputType()}
|
||||
value={formatDateValue(finalValue)}
|
||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||
onChange={handleChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={`h-full w-full ${borderClass}`}
|
||||
min={config?.minDate}
|
||||
max={config?.maxDate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DateWidget.displayName = "DateWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, ExternalLink } from "lucide-react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
|
||||
|
||||
export const EntityWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
||||
// 엔티티 목록 가져오기 (실제로는 API 호출)
|
||||
const getEntityOptions = () => {
|
||||
const entityType = config?.entityType || "default";
|
||||
|
||||
// 예시 데이터 - 실제로는 API에서 데이터를 가져옴
|
||||
const entities = {
|
||||
customer: [
|
||||
{ id: "CUST001", name: "삼성전자", code: "SAMSUNG", type: "대기업" },
|
||||
{ id: "CUST002", name: "LG전자", code: "LG", type: "대기업" },
|
||||
{ id: "CUST003", name: "SK하이닉스", code: "SKHYNIX", type: "대기업" },
|
||||
],
|
||||
supplier: [
|
||||
{ id: "SUPP001", name: "공급업체 A", code: "SUPPA", type: "협력사" },
|
||||
{ id: "SUPP002", name: "공급업체 B", code: "SUPPB", type: "협력사" },
|
||||
{ id: "SUPP003", name: "공급업체 C", code: "SUPPC", type: "협력사" },
|
||||
],
|
||||
employee: [
|
||||
{ id: "EMP001", name: "김철수", code: "KCS", type: "정규직" },
|
||||
{ id: "EMP002", name: "이영희", code: "LYH", type: "정규직" },
|
||||
{ id: "EMP003", name: "박민수", code: "PMS", type: "계약직" },
|
||||
],
|
||||
default: [
|
||||
{ id: "ENT001", name: "엔티티 1", code: "ENT1", type: "기본" },
|
||||
{ id: "ENT002", name: "엔티티 2", code: "ENT2", type: "기본" },
|
||||
{ id: "ENT003", name: "엔티티 3", code: "ENT3", type: "기본" },
|
||||
],
|
||||
};
|
||||
|
||||
return entities[entityType as keyof typeof entities] || entities.default;
|
||||
};
|
||||
|
||||
const entityOptions = getEntityOptions();
|
||||
|
||||
// 검색 필터링
|
||||
const filteredOptions = entityOptions.filter(
|
||||
(option) =>
|
||||
!searchTerm ||
|
||||
option.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
option.code.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 선택된 엔티티 정보 찾기
|
||||
const selectedEntity = entityOptions.find((option) => option.id === value);
|
||||
|
||||
// 검색 모드 토글
|
||||
const toggleSearchMode = () => {
|
||||
setIsSearchMode(!isSearchMode);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
// 상세 정보 보기 (팝업 등)
|
||||
const handleViewDetails = () => {
|
||||
if (selectedEntity) {
|
||||
// 실제로는 상세 정보 팝업 또는 새 창 열기
|
||||
alert(`${selectedEntity.name} 상세 정보`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full space-y-2">
|
||||
{/* 검색 모드 */}
|
||||
{isSearchMode ? (
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="엔티티 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={toggleSearchMode}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
{/* 엔티티 선택 */}
|
||||
<div className="flex-1">
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={readonly} required={required}>
|
||||
<SelectTrigger className={`w-full ${hasCustomBorder ? "!border-0" : ""}`} style={style}>
|
||||
<SelectValue placeholder={placeholder || config?.placeholder || "엔티티를 선택하세요..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{option.code}
|
||||
</Badge>
|
||||
<span>{option.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{option.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 검색 버튼 */}
|
||||
<Button size="sm" variant="outline" onClick={toggleSearchMode} disabled={readonly}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 상세보기 버튼 */}
|
||||
{selectedEntity && config?.showDetails && (
|
||||
<Button size="sm" variant="outline" onClick={handleViewDetails}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 엔티티 정보 표시 */}
|
||||
{selectedEntity && (
|
||||
<div className="bg-muted rounded-md p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedEntity.code}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{selectedEntity.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedEntity.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{config?.allowClear && !readonly && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onChange?.("")} className="h-6 w-6 p-0">
|
||||
×
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 엔티티 타입 표시 */}
|
||||
{config?.entityType && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{config.entityType}
|
||||
</Badge>
|
||||
|
||||
{config?.allowMultiple && <span className="text-muted-foreground text-xs">다중 선택 가능</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EntityWidget.displayName = "EntityWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Upload, File, X } from "lucide-react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, FileTypeConfig } from "@/types/screen";
|
||||
|
||||
export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { required, style } = widget;
|
||||
const config = widget.webTypeConfig as FileTypeConfig | undefined;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 정보 파싱
|
||||
const parseFileValue = (val: any) => {
|
||||
if (!val) return [];
|
||||
|
||||
if (typeof val === "string") {
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch {
|
||||
return [{ name: val, size: 0, url: val }];
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const files = parseFileValue(value);
|
||||
|
||||
// 파일 크기 포맷팅
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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];
|
||||
};
|
||||
|
||||
// 파일 선택 처리
|
||||
const handleFileSelect = () => {
|
||||
if (readonly) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 파일 업로드 처리
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
|
||||
// 파일 개수 제한 검사
|
||||
if (config?.maxFiles && files.length + selectedFiles.length > config.maxFiles) {
|
||||
alert(`최대 ${config.maxFiles}개 파일까지 업로드 가능합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 검사
|
||||
if (config?.maxSize) {
|
||||
const oversizedFiles = selectedFiles.filter((file) => file.size > config.maxSize! * 1024 * 1024);
|
||||
if (oversizedFiles.length > 0) {
|
||||
alert(`파일 크기는 최대 ${config.maxSize}MB까지 가능합니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 형식 검사
|
||||
if (config?.accept) {
|
||||
const acceptedTypes = config.accept.split(",").map((type) => type.trim());
|
||||
const invalidFiles = selectedFiles.filter((file) => {
|
||||
return !acceptedTypes.some((acceptType) => {
|
||||
if (acceptType.startsWith(".")) {
|
||||
return file.name.toLowerCase().endsWith(acceptType.toLowerCase());
|
||||
}
|
||||
return file.type.match(acceptType.replace("*", ".*"));
|
||||
});
|
||||
});
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
alert(`허용되지 않는 파일 형식입니다. 허용 형식: ${config.accept}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 새 파일 정보 생성
|
||||
const newFiles = selectedFiles.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
url: URL.createObjectURL(file),
|
||||
file: file, // 실제 File 객체 (업로드용)
|
||||
}));
|
||||
|
||||
const updatedFiles = config?.multiple !== false ? [...files, ...newFiles] : newFiles;
|
||||
onChange?.(JSON.stringify(updatedFiles));
|
||||
};
|
||||
|
||||
// 파일 제거
|
||||
const removeFile = (index: number) => {
|
||||
if (readonly) return;
|
||||
|
||||
const updatedFiles = files.filter((_, i) => i !== index);
|
||||
onChange?.(JSON.stringify(updatedFiles));
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
|
||||
// 파일 input change 이벤트와 같은 로직 적용
|
||||
const fakeEvent = {
|
||||
target: { files: droppedFiles },
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
handleFileChange(fakeEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full space-y-2">
|
||||
{/* 파일 업로드 영역 */}
|
||||
<div
|
||||
className="border-muted-foreground/25 hover:border-muted-foreground/50 cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors"
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={style}
|
||||
>
|
||||
<Upload className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{readonly ? "파일 업로드 불가" : "파일을 선택하거나 드래그하여 업로드"}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{config?.accept && `허용 형식: ${config.accept}`}
|
||||
{config?.maxSize && ` (최대 ${config.maxSize}MB)`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 숨겨진 파일 input */}
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple={config?.multiple !== false}
|
||||
accept={config?.accept}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-32 space-y-2 overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="bg-muted flex items-center justify-between rounded-md p-2">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2">
|
||||
<File className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.size > 0 && <p className="text-muted-foreground text-xs">{formatFileSize(file.size)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readonly && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 개수 표시 */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{files.length}개 파일
|
||||
</Badge>
|
||||
{config?.maxFiles && <span className="text-muted-foreground text-xs">최대 {config.maxFiles}개</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{required && files.length === 0 && <div className="text-xs text-red-500">* 파일을 업로드해야 합니다</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FileWidget.displayName = "FileWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, NumberTypeConfig } from "@/types/screen";
|
||||
|
||||
export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
// 숫자 포맷팅 함수
|
||||
const formatNumber = (val: string | number) => {
|
||||
if (!val) return "";
|
||||
const numValue = typeof val === "string" ? parseFloat(val) : val;
|
||||
if (isNaN(numValue)) return "";
|
||||
|
||||
if (config?.format === "currency") {
|
||||
return new Intl.NumberFormat("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
}).format(numValue);
|
||||
}
|
||||
|
||||
if (config?.format === "percentage") {
|
||||
return `${numValue}%`;
|
||||
}
|
||||
|
||||
if (config?.thousandSeparator) {
|
||||
return new Intl.NumberFormat("ko-KR").format(numValue);
|
||||
}
|
||||
|
||||
return numValue.toString();
|
||||
};
|
||||
|
||||
// 입력값 처리
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let inputValue = e.target.value;
|
||||
|
||||
// 숫자가 아닌 문자 제거 (소수점과 마이너스 제외)
|
||||
if (widget.widgetType === "number") {
|
||||
inputValue = inputValue.replace(/[^0-9-]/g, "");
|
||||
} else if (widget.widgetType === "decimal") {
|
||||
inputValue = inputValue.replace(/[^0-9.-]/g, "");
|
||||
}
|
||||
|
||||
// 범위 검증
|
||||
if (config?.min !== undefined || config?.max !== undefined) {
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (!isNaN(numValue)) {
|
||||
if (config.min !== undefined && numValue < config.min) {
|
||||
inputValue = config.min.toString();
|
||||
}
|
||||
if (config.max !== undefined && numValue > config.max) {
|
||||
inputValue = config.max.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(inputValue);
|
||||
};
|
||||
|
||||
// 웹타입에 따른 input type과 step 결정
|
||||
const getInputProps = () => {
|
||||
if (widget.widgetType === "decimal") {
|
||||
return {
|
||||
type: "number",
|
||||
step: config?.step || 0.01,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "number",
|
||||
step: config?.step || 1,
|
||||
};
|
||||
};
|
||||
|
||||
const inputProps = getInputProps();
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...inputProps}
|
||||
value={value || ""}
|
||||
placeholder={placeholder || config?.placeholder || "숫자를 입력하세요..."}
|
||||
onChange={handleChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={`h-full w-full ${borderClass}`}
|
||||
min={config?.min}
|
||||
max={config?.max}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
NumberWidget.displayName = "NumberWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, RadioTypeConfig } from "@/types/screen";
|
||||
|
||||
export const RadioWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { required } = widget;
|
||||
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
|
||||
|
||||
// 옵션 목록 가져오기
|
||||
const getOptions = () => {
|
||||
if (config?.options && Array.isArray(config.options)) {
|
||||
return config.options;
|
||||
}
|
||||
|
||||
// 기본 옵션들
|
||||
return [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
{ label: "옵션 3", value: "option3" },
|
||||
];
|
||||
};
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
// 레이아웃 방향 결정
|
||||
const isHorizontal = config?.layout === "horizontal";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<RadioGroup
|
||||
value={value || ""}
|
||||
onValueChange={onChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={isHorizontal ? "flex flex-row space-x-4" : "flex flex-col space-y-2"}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const optionValue = option.value || `option_${index}`;
|
||||
return (
|
||||
<div key={optionValue} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={optionValue} id={`radio-${widget.id}-${optionValue}`} />
|
||||
<Label
|
||||
htmlFor={`radio-${widget.id}-${optionValue}`}
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
|
||||
{required && <div className="mt-1 text-xs text-red-500">* 필수 선택 항목입니다</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RadioWidget.displayName = "RadioWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
|
||||
export interface RatingWidgetProps extends WebTypeComponentProps {
|
||||
maxRating?: number;
|
||||
allowHalf?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const RatingWidget: React.FC<RatingWidgetProps> = ({
|
||||
value = 0,
|
||||
onChange,
|
||||
onEvent,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
placeholder = "별점을 선택하세요",
|
||||
className = "",
|
||||
webTypeConfig = {},
|
||||
maxRating = 5,
|
||||
allowHalf = false,
|
||||
size = "md",
|
||||
...props
|
||||
}) => {
|
||||
const [hoverRating, setHoverRating] = useState<number>(0);
|
||||
const [currentRating, setCurrentRating] = useState<number>(Number(value) || 0);
|
||||
|
||||
// 웹타입 설정에서 값 가져오기
|
||||
const finalMaxRating = webTypeConfig.maxRating || maxRating;
|
||||
const finalAllowHalf = webTypeConfig.allowHalf ?? allowHalf;
|
||||
const finalSize = webTypeConfig.size || size;
|
||||
|
||||
// 크기별 스타일
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
};
|
||||
|
||||
const handleStarClick = (rating: number) => {
|
||||
if (disabled || readonly) return;
|
||||
|
||||
setCurrentRating(rating);
|
||||
onChange?.(rating);
|
||||
onEvent?.("change", { value: rating, webType: "rating" });
|
||||
};
|
||||
|
||||
const handleStarHover = (rating: number) => {
|
||||
if (disabled || readonly) return;
|
||||
setHoverRating(rating);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoverRating(0);
|
||||
};
|
||||
|
||||
const renderStars = () => {
|
||||
const stars = [];
|
||||
const displayRating = hoverRating || currentRating;
|
||||
|
||||
for (let i = 1; i <= finalMaxRating; i++) {
|
||||
const isFilled = i <= displayRating;
|
||||
const isHalfFilled = finalAllowHalf && i - 0.5 <= displayRating && i > displayRating;
|
||||
|
||||
stars.push(
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={` ${sizeClasses[finalSize]} transition-colors duration-150 ${disabled || readonly ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:scale-110"} ${isFilled ? "text-yellow-400" : "text-gray-300"} `}
|
||||
onClick={() => handleStarClick(i)}
|
||||
onMouseEnter={() => handleStarHover(i)}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<Star className={`${sizeClasses[finalSize]} ${isFilled ? "fill-current" : ""}`} />
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`} onMouseLeave={handleMouseLeave} {...props}>
|
||||
{/* 별점 표시 */}
|
||||
<div className="flex items-center gap-0.5">{renderStars()}</div>
|
||||
|
||||
{/* 현재 점수 표시 */}
|
||||
{!readonly && (
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{currentRating > 0 ? `${currentRating}/${finalMaxRating}` : placeholder}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 숨겨진 input (폼 제출용) */}
|
||||
<input type="hidden" name={props.name} value={currentRating} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RatingWidget.displayName = "RatingWidget";
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
||||
|
||||
export const SelectWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
||||
// 옵션 목록 가져오기
|
||||
const getOptions = () => {
|
||||
if (config?.options && Array.isArray(config.options)) {
|
||||
return config.options;
|
||||
}
|
||||
|
||||
// 기본 옵션들
|
||||
return [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
{ label: "옵션 3", value: "option3" },
|
||||
];
|
||||
};
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
return (
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={readonly} required={required}>
|
||||
<SelectTrigger className={`h-full w-full ${hasCustomBorder ? "!border-0" : ""}`} style={style}>
|
||||
<SelectValue placeholder={placeholder || config?.placeholder || "선택하세요..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option, index) => (
|
||||
<SelectItem key={option.value || index} value={option.value || `option_${index}`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
SelectWidget.displayName = "SelectWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, TextTypeConfig } from "@/types/screen";
|
||||
|
||||
export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as TextTypeConfig | undefined;
|
||||
|
||||
// 입력 타입에 따른 처리
|
||||
const isAutoInput = widget.inputType === "auto";
|
||||
|
||||
// 자동 값 생성 함수
|
||||
const getAutoValue = (autoValueType: string) => {
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
return new Date().toLocaleString("ko-KR");
|
||||
case "current_date":
|
||||
return new Date().toLocaleDateString("ko-KR");
|
||||
case "current_time":
|
||||
return new Date().toLocaleTimeString("ko-KR");
|
||||
case "current_user":
|
||||
return "현재사용자";
|
||||
case "uuid":
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
case "sequence":
|
||||
return "SEQ_001";
|
||||
case "user_defined":
|
||||
return "사용자정의값";
|
||||
default:
|
||||
return "자동생성값";
|
||||
}
|
||||
};
|
||||
|
||||
// 자동 값 플레이스홀더 생성 함수
|
||||
const getAutoPlaceholder = (autoValueType: string) => {
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
return "현재 날짜시간";
|
||||
case "current_date":
|
||||
return "현재 날짜";
|
||||
case "current_time":
|
||||
return "현재 시간";
|
||||
case "current_user":
|
||||
return "현재 사용자";
|
||||
case "uuid":
|
||||
return "UUID";
|
||||
case "sequence":
|
||||
return "시퀀스";
|
||||
case "user_defined":
|
||||
return "사용자 정의";
|
||||
default:
|
||||
return "자동 생성됨";
|
||||
}
|
||||
};
|
||||
|
||||
// 플레이스홀더 처리
|
||||
const finalPlaceholder = isAutoInput
|
||||
? getAutoPlaceholder(widget.autoValueType || "")
|
||||
: placeholder || config?.placeholder || "입력하세요...";
|
||||
|
||||
// 값 처리
|
||||
const finalValue = isAutoInput ? getAutoValue(widget.autoValueType || "") : value || "";
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
||||
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
// 웹타입에 따른 input type 결정
|
||||
const getInputType = () => {
|
||||
switch (widget.widgetType) {
|
||||
case "email":
|
||||
return "email";
|
||||
case "tel":
|
||||
return "tel";
|
||||
case "text":
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={getInputType()}
|
||||
value={finalValue}
|
||||
placeholder={finalPlaceholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={readonly || isAutoInput}
|
||||
required={required}
|
||||
className={`h-full w-full ${borderClass}`}
|
||||
maxLength={config?.maxLength}
|
||||
minLength={config?.minLength}
|
||||
pattern={config?.pattern}
|
||||
autoComplete={config?.autoComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TextWidget.displayName = "TextWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, TextareaTypeConfig } from "@/types/screen";
|
||||
|
||||
export const TextareaWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
// 글자 수 계산
|
||||
const currentLength = (value || "").length;
|
||||
const maxLength = config?.maxLength;
|
||||
const showCounter = maxLength && maxLength > 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<Textarea
|
||||
value={value || ""}
|
||||
placeholder={placeholder || config?.placeholder || "내용을 입력하세요..."}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={`h-full w-full resize-none ${borderClass} ${showCounter ? "pb-6" : ""}`}
|
||||
maxLength={maxLength}
|
||||
minLength={config?.minLength}
|
||||
rows={config?.rows || 3}
|
||||
/>
|
||||
|
||||
{/* 글자 수 카운터 */}
|
||||
{showCounter && (
|
||||
<div className="text-muted-foreground absolute right-2 bottom-1 text-xs">
|
||||
{currentLength} / {maxLength}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TextareaWidget.displayName = "TextareaWidget";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
// 웹타입 컴포넌트들을 내보내는 인덱스 파일
|
||||
import React from "react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
|
||||
// 개별 컴포넌트 import
|
||||
import { TextWidget } from "./TextWidget";
|
||||
import { NumberWidget } from "./NumberWidget";
|
||||
import { DateWidget } from "./DateWidget";
|
||||
import { SelectWidget } from "./SelectWidget";
|
||||
import { TextareaWidget } from "./TextareaWidget";
|
||||
import { CheckboxWidget } from "./CheckboxWidget";
|
||||
import { RadioWidget } from "./RadioWidget";
|
||||
import { FileWidget } from "./FileWidget";
|
||||
import { CodeWidget } from "./CodeWidget";
|
||||
import { EntityWidget } from "./EntityWidget";
|
||||
import { RatingWidget } from "./RatingWidget";
|
||||
|
||||
// 개별 컴포넌트 export
|
||||
export { TextWidget } from "./TextWidget";
|
||||
export { NumberWidget } from "./NumberWidget";
|
||||
export { DateWidget } from "./DateWidget";
|
||||
export { SelectWidget } from "./SelectWidget";
|
||||
export { TextareaWidget } from "./TextareaWidget";
|
||||
export { CheckboxWidget } from "./CheckboxWidget";
|
||||
export { RadioWidget } from "./RadioWidget";
|
||||
export { FileWidget } from "./FileWidget";
|
||||
export { CodeWidget } from "./CodeWidget";
|
||||
export { EntityWidget } from "./EntityWidget";
|
||||
export { RatingWidget } from "./RatingWidget";
|
||||
|
||||
// 컴포넌트 이름으로 직접 매핑하는 함수 (DB의 component_name 필드용)
|
||||
export const getWidgetComponentByName = (componentName: string): React.ComponentType<WebTypeComponentProps> => {
|
||||
switch (componentName) {
|
||||
case "TextWidget":
|
||||
return TextWidget;
|
||||
case "NumberWidget":
|
||||
return NumberWidget;
|
||||
case "DateWidget":
|
||||
return DateWidget;
|
||||
case "SelectWidget":
|
||||
return SelectWidget;
|
||||
case "TextareaWidget":
|
||||
return TextareaWidget;
|
||||
case "CheckboxWidget":
|
||||
return CheckboxWidget;
|
||||
case "RadioWidget":
|
||||
return RadioWidget;
|
||||
case "FileWidget":
|
||||
return FileWidget;
|
||||
case "CodeWidget":
|
||||
return CodeWidget;
|
||||
case "EntityWidget":
|
||||
return EntityWidget;
|
||||
case "RatingWidget":
|
||||
return RatingWidget;
|
||||
default:
|
||||
console.warn(`알 수 없는 컴포넌트명: ${componentName}, TextWidget으로 폴백`);
|
||||
return TextWidget;
|
||||
}
|
||||
};
|
||||
|
||||
// 기본 컴포넌트 매핑 룰 (카테고리나 타입 기반)
|
||||
export const getWidgetComponentByWebType = (webType: string): React.ComponentType<WebTypeComponentProps> => {
|
||||
// 기본 매핑 룰 - 웹타입에 따른 컴포넌트 결정
|
||||
switch (webType.toLowerCase()) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
case "url":
|
||||
case "password":
|
||||
return TextWidget;
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
case "integer":
|
||||
case "float":
|
||||
return NumberWidget;
|
||||
|
||||
case "date":
|
||||
case "datetime":
|
||||
case "time":
|
||||
return DateWidget;
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
case "combobox":
|
||||
return SelectWidget;
|
||||
|
||||
case "textarea":
|
||||
case "text_area":
|
||||
case "multiline":
|
||||
return TextareaWidget;
|
||||
|
||||
case "checkbox":
|
||||
case "boolean":
|
||||
case "toggle":
|
||||
return CheckboxWidget;
|
||||
|
||||
case "radio":
|
||||
case "radiobutton":
|
||||
return RadioWidget;
|
||||
|
||||
case "file":
|
||||
case "upload":
|
||||
case "attachment":
|
||||
return FileWidget;
|
||||
|
||||
case "code":
|
||||
case "script":
|
||||
return CodeWidget;
|
||||
|
||||
case "entity":
|
||||
case "reference":
|
||||
case "lookup":
|
||||
return EntityWidget;
|
||||
|
||||
case "rating":
|
||||
case "star":
|
||||
case "score":
|
||||
return RatingWidget;
|
||||
|
||||
default:
|
||||
// 기본적으로 텍스트 위젯 사용
|
||||
return TextWidget;
|
||||
}
|
||||
};
|
||||
|
||||
// 동적 웹타입 컴포넌트 맵 생성 함수
|
||||
export const createWebTypeComponents = (
|
||||
webTypes: string[],
|
||||
): Record<string, React.ComponentType<WebTypeComponentProps>> => {
|
||||
const components: Record<string, React.ComponentType<WebTypeComponentProps>> = {};
|
||||
|
||||
webTypes.forEach((webType) => {
|
||||
components[webType] = getWidgetComponentByWebType(webType);
|
||||
});
|
||||
|
||||
return components;
|
||||
};
|
||||
|
||||
// 기존 하드코딩된 맵 (호환성 유지)
|
||||
export const WebTypeComponents: Record<string, React.ComponentType<WebTypeComponentProps>> = {
|
||||
text: TextWidget,
|
||||
email: TextWidget,
|
||||
tel: TextWidget,
|
||||
number: NumberWidget,
|
||||
decimal: NumberWidget,
|
||||
date: DateWidget,
|
||||
datetime: DateWidget,
|
||||
select: SelectWidget,
|
||||
dropdown: SelectWidget,
|
||||
textarea: TextareaWidget,
|
||||
text_area: TextareaWidget,
|
||||
boolean: CheckboxWidget,
|
||||
checkbox: CheckboxWidget,
|
||||
radio: RadioWidget,
|
||||
file: FileWidget,
|
||||
code: CodeWidget,
|
||||
entity: EntityWidget,
|
||||
rating: RatingWidget,
|
||||
};
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// 버튼 액션 데이터 인터페이스
|
||||
export interface ButtonActionStandard {
|
||||
action_type: string;
|
||||
action_name: string;
|
||||
action_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
default_text?: string;
|
||||
default_text_eng?: string;
|
||||
default_icon?: string;
|
||||
default_color?: string;
|
||||
default_variant?: string;
|
||||
confirmation_required: boolean;
|
||||
confirmation_message?: string;
|
||||
validation_rules?: any;
|
||||
action_config?: any;
|
||||
sort_order?: number;
|
||||
is_active: string;
|
||||
created_date?: string;
|
||||
created_by?: string;
|
||||
updated_date?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
// 버튼 액션 생성/수정 데이터
|
||||
export interface ButtonActionFormData {
|
||||
action_type: string;
|
||||
action_name: string;
|
||||
action_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
default_text?: string;
|
||||
default_text_eng?: string;
|
||||
default_icon?: string;
|
||||
default_color?: string;
|
||||
default_variant?: string;
|
||||
confirmation_required?: boolean;
|
||||
confirmation_message?: string;
|
||||
validation_rules?: any;
|
||||
action_config?: any;
|
||||
sort_order?: number;
|
||||
is_active?: string;
|
||||
}
|
||||
|
||||
// API 응답 인터페이스
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 인터페이스
|
||||
interface ButtonActionQueryParams {
|
||||
active?: string;
|
||||
category?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 관리 훅
|
||||
*/
|
||||
export const useButtonActions = (params?: ButtonActionQueryParams) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 버튼 액션 목록 조회
|
||||
const {
|
||||
data: buttonActions,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["buttonActions", params],
|
||||
queryFn: async (): Promise<ButtonActionStandard[]> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.active) searchParams.append("active", params.active);
|
||||
if (params?.category) searchParams.append("category", params.category);
|
||||
if (params?.search) searchParams.append("search", params.search);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/admin/button-actions?${searchParams.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: ApiResponse<ButtonActionStandard[]> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to fetch button actions");
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
|
||||
cacheTime: 10 * 60 * 1000, // 10분간 메모리 보관
|
||||
});
|
||||
|
||||
// 버튼 액션 생성
|
||||
const createButtonActionMutation = useMutation({
|
||||
mutationFn: async (
|
||||
data: ButtonActionFormData
|
||||
): Promise<ButtonActionStandard> => {
|
||||
const response = await fetch("/api/admin/button-actions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: ApiResponse<ButtonActionStandard> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to create button action");
|
||||
}
|
||||
|
||||
return result.data!;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["buttonActions"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 버튼 액션 수정
|
||||
const updateButtonActionMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
actionType,
|
||||
data,
|
||||
}: {
|
||||
actionType: string;
|
||||
data: Partial<ButtonActionFormData>;
|
||||
}): Promise<ButtonActionStandard> => {
|
||||
const response = await fetch(`/api/admin/button-actions/${actionType}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: ApiResponse<ButtonActionStandard> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to update button action");
|
||||
}
|
||||
|
||||
return result.data!;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["buttonActions"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 버튼 액션 삭제
|
||||
const deleteButtonActionMutation = useMutation({
|
||||
mutationFn: async (actionType: string): Promise<void> => {
|
||||
const response = await fetch(`/api/admin/button-actions/${actionType}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: ApiResponse<void> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to delete button action");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["buttonActions"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 정렬 순서 업데이트
|
||||
const updateSortOrderMutation = useMutation({
|
||||
mutationFn: async (
|
||||
buttonActions: { action_type: string; sort_order: number }[]
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
"/api/admin/button-actions/sort-order/bulk",
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ buttonActions }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: ApiResponse<void> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to update sort order");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["buttonActions"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 편의 메서드들
|
||||
const createButtonAction = useCallback(
|
||||
(data: ButtonActionFormData) => {
|
||||
return createButtonActionMutation.mutateAsync(data);
|
||||
},
|
||||
[createButtonActionMutation]
|
||||
);
|
||||
|
||||
const updateButtonAction = useCallback(
|
||||
(actionType: string, data: Partial<ButtonActionFormData>) => {
|
||||
return updateButtonActionMutation.mutateAsync({ actionType, data });
|
||||
},
|
||||
[updateButtonActionMutation]
|
||||
);
|
||||
|
||||
const deleteButtonAction = useCallback(
|
||||
(actionType: string) => {
|
||||
return deleteButtonActionMutation.mutateAsync(actionType);
|
||||
},
|
||||
[deleteButtonActionMutation]
|
||||
);
|
||||
|
||||
const updateSortOrder = useCallback(
|
||||
(buttonActions: { action_type: string; sort_order: number }[]) => {
|
||||
return updateSortOrderMutation.mutateAsync(buttonActions);
|
||||
},
|
||||
[updateSortOrderMutation]
|
||||
);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
buttonActions: buttonActions || [],
|
||||
|
||||
// 로딩 상태
|
||||
isLoading,
|
||||
isCreating: createButtonActionMutation.isPending,
|
||||
isUpdating: updateButtonActionMutation.isPending,
|
||||
isDeleting: deleteButtonActionMutation.isPending,
|
||||
isSortingUpdating: updateSortOrderMutation.isPending,
|
||||
|
||||
// 에러
|
||||
error,
|
||||
createError: createButtonActionMutation.error,
|
||||
updateError: updateButtonActionMutation.error,
|
||||
deleteError: deleteButtonActionMutation.error,
|
||||
sortError: updateSortOrderMutation.error,
|
||||
|
||||
// 액션
|
||||
createButtonAction,
|
||||
updateButtonAction,
|
||||
deleteButtonAction,
|
||||
updateSortOrder,
|
||||
refetch,
|
||||
|
||||
// 상태 초기화
|
||||
resetCreateError: createButtonActionMutation.reset,
|
||||
resetUpdateError: updateButtonActionMutation.reset,
|
||||
resetDeleteError: deleteButtonActionMutation.reset,
|
||||
resetSortError: updateSortOrderMutation.reset,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 웹타입 데이터 인터페이스
|
||||
export interface WebTypeStandard {
|
||||
web_type: string;
|
||||
type_name: string;
|
||||
type_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
component_name?: string;
|
||||
default_config?: any;
|
||||
validation_rules?: any;
|
||||
default_style?: any;
|
||||
input_properties?: any;
|
||||
sort_order?: number;
|
||||
is_active: string;
|
||||
created_date?: string;
|
||||
created_by?: string;
|
||||
updated_date?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
// 웹타입 생성/수정 데이터
|
||||
export interface WebTypeFormData {
|
||||
web_type: string;
|
||||
type_name: string;
|
||||
type_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
component_name?: string;
|
||||
default_config?: any;
|
||||
validation_rules?: any;
|
||||
default_style?: any;
|
||||
input_properties?: any;
|
||||
sort_order?: number;
|
||||
is_active?: string;
|
||||
}
|
||||
|
||||
// API 응답 인터페이스
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 인터페이스
|
||||
interface WebTypeQueryParams {
|
||||
active?: string;
|
||||
category?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 관리 훅
|
||||
*/
|
||||
export const useWebTypes = (params?: WebTypeQueryParams) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 웹타입 목록 조회
|
||||
const {
|
||||
data: webTypes,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["webTypes", params],
|
||||
queryFn: async (): Promise<WebTypeStandard[]> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.active) searchParams.append("active", params.active);
|
||||
if (params?.category) searchParams.append("category", params.category);
|
||||
if (params?.search) searchParams.append("search", params.search);
|
||||
|
||||
const response = await apiClient.get(`/admin/web-types?${searchParams.toString()}`);
|
||||
|
||||
const result: ApiResponse<WebTypeStandard[]> = response.data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to fetch web types");
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
|
||||
cacheTime: 10 * 60 * 1000, // 10분간 메모리 보관
|
||||
});
|
||||
|
||||
// 웹타입 생성
|
||||
const createWebTypeMutation = useMutation({
|
||||
mutationFn: async (data: WebTypeFormData): Promise<WebTypeStandard> => {
|
||||
const response = await apiClient.post("/admin/web-types", data);
|
||||
|
||||
const result: ApiResponse<WebTypeStandard> = response.data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to create web type");
|
||||
}
|
||||
|
||||
return result.data!;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["webTypes"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 웹타입 수정
|
||||
const updateWebTypeMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
webType,
|
||||
data,
|
||||
}: {
|
||||
webType: string;
|
||||
data: Partial<WebTypeFormData>;
|
||||
}): Promise<WebTypeStandard> => {
|
||||
const response = await apiClient.put(`/admin/web-types/${webType}`, data);
|
||||
|
||||
const result: ApiResponse<WebTypeStandard> = response.data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to update web type");
|
||||
}
|
||||
|
||||
return result.data!;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["webTypes"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 웹타입 삭제
|
||||
const deleteWebTypeMutation = useMutation({
|
||||
mutationFn: async (webType: string): Promise<void> => {
|
||||
const response = await apiClient.delete(`/admin/web-types/${webType}`);
|
||||
|
||||
const result: ApiResponse<void> = response.data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to delete web type");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["webTypes"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 정렬 순서 업데이트
|
||||
const updateSortOrderMutation = useMutation({
|
||||
mutationFn: async (webTypes: { web_type: string; sort_order: number }[]): Promise<void> => {
|
||||
const response = await apiClient.put("/admin/web-types/sort-order/bulk", { webTypes });
|
||||
|
||||
const result: ApiResponse<void> = response.data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to update sort order");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ["webTypes"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 편의 메서드들
|
||||
const createWebType = useCallback(
|
||||
(data: WebTypeFormData) => {
|
||||
return createWebTypeMutation.mutateAsync(data);
|
||||
},
|
||||
[createWebTypeMutation],
|
||||
);
|
||||
|
||||
const updateWebType = useCallback(
|
||||
(webType: string, data: Partial<WebTypeFormData>) => {
|
||||
return updateWebTypeMutation.mutateAsync({ webType, data });
|
||||
},
|
||||
[updateWebTypeMutation],
|
||||
);
|
||||
|
||||
const deleteWebType = useCallback(
|
||||
(webType: string) => {
|
||||
return deleteWebTypeMutation.mutateAsync(webType);
|
||||
},
|
||||
[deleteWebTypeMutation],
|
||||
);
|
||||
|
||||
const updateSortOrder = useCallback(
|
||||
(webTypes: { web_type: string; sort_order: number }[]) => {
|
||||
return updateSortOrderMutation.mutateAsync(webTypes);
|
||||
},
|
||||
[updateSortOrderMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
webTypes: webTypes || [],
|
||||
|
||||
// 로딩 상태
|
||||
isLoading,
|
||||
isCreating: createWebTypeMutation.isPending,
|
||||
isUpdating: updateWebTypeMutation.isPending,
|
||||
isDeleting: deleteWebTypeMutation.isPending,
|
||||
isSortingUpdating: updateSortOrderMutation.isPending,
|
||||
|
||||
// 에러
|
||||
error,
|
||||
createError: createWebTypeMutation.error,
|
||||
updateError: updateWebTypeMutation.error,
|
||||
deleteError: deleteWebTypeMutation.error,
|
||||
sortError: updateSortOrderMutation.error,
|
||||
|
||||
// 액션
|
||||
createWebType,
|
||||
updateWebType,
|
||||
deleteWebType,
|
||||
updateSortOrder,
|
||||
refetch,
|
||||
|
||||
// 상태 초기화
|
||||
resetCreateError: createWebTypeMutation.reset,
|
||||
resetUpdateError: updateWebTypeMutation.reset,
|
||||
resetDeleteError: deleteWebTypeMutation.reset,
|
||||
resetSortError: updateSortOrderMutation.reset,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCall, API_BASE_URL } from "@/lib/api/client";
|
||||
|
||||
|
|
@ -98,6 +98,7 @@ export const useAuth = () => {
|
|||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// API 기본 URL 설정 (동적으로 결정)
|
||||
|
||||
|
|
@ -443,6 +444,13 @@ export const useAuth = () => {
|
|||
* 초기 인증 상태 확인
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 실행하지 않음
|
||||
if (initializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializedRef.current = true;
|
||||
|
||||
console.log("=== useAuth 초기 인증 상태 확인 ===");
|
||||
console.log("현재 경로:", window.location.pathname);
|
||||
|
||||
|
|
@ -479,7 +487,7 @@ export const useAuth = () => {
|
|||
router.push("/login");
|
||||
}, 3000);
|
||||
}
|
||||
}, [refreshUserData, router]); // refreshUserData 의존성 추가
|
||||
}, []); // 초기 마운트 시에만 실행
|
||||
|
||||
/**
|
||||
* 세션 만료 감지 및 처리
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
import { WebTypeConfigPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 동적 설정 패널 렌더러 컴포넌트
|
||||
* 레지스트리에서 웹타입을 조회하여 해당 설정 패널을 동적으로 렌더링합니다.
|
||||
*/
|
||||
export const DynamicConfigPanel: React.FC<
|
||||
WebTypeConfigPanelProps & {
|
||||
webType: string;
|
||||
}
|
||||
> = ({ webType, component, onUpdateComponent, onUpdateProperty }) => {
|
||||
// 레지스트리에서 웹타입 정의 조회
|
||||
const webTypeDefinition = useMemo(() => {
|
||||
return WebTypeRegistry.getWebType(webType);
|
||||
}, [webType]);
|
||||
|
||||
// 웹타입이 등록되지 않은 경우
|
||||
if (!webTypeDefinition) {
|
||||
console.warn(`웹타입 "${webType}"이 레지스트리에 등록되지 않았습니다.`);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<span className="text-sm font-medium">⚠️ 설정 패널 없음</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-500">웹타입 "{webType}"의 설정 패널을 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 설정 패널 컴포넌트가 없는 경우
|
||||
if (!webTypeDefinition.configPanel) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4">
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<span className="text-sm font-medium">⚠️ 설정 패널 미구현</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-yellow-500">웹타입 "{webType}"에 대한 설정 패널이 구현되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ConfigPanelComponent = webTypeDefinition.configPanel;
|
||||
|
||||
// 설정 패널 props 구성
|
||||
const configPanelProps: WebTypeConfigPanelProps = {
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
};
|
||||
|
||||
try {
|
||||
return <ConfigPanelComponent {...configPanelProps} />;
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${webType}" 설정 패널 렌더링 중 오류 발생:`, error);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<span className="text-sm font-medium">💥 설정 패널 오류</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-500">웹타입 "{webType}" 설정 패널 렌더링 중 오류가 발생했습니다.</p>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<pre className="mt-2 overflow-auto text-xs text-red-400">
|
||||
{error instanceof Error ? error.stack : String(error)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DynamicConfigPanel.displayName = "DynamicConfigPanel";
|
||||
|
||||
/**
|
||||
* 웹타입별 설정 패널을 렌더링하는 헬퍼 함수
|
||||
*/
|
||||
export function renderConfigPanel(
|
||||
webType: string,
|
||||
component: any,
|
||||
onUpdateComponent: (component: any) => void,
|
||||
onUpdateProperty: (property: string, value: any) => void,
|
||||
): React.ReactElement | null {
|
||||
return (
|
||||
<DynamicConfigPanel
|
||||
webType={webType}
|
||||
component={component}
|
||||
onUpdateComponent={onUpdateComponent}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입이 설정 패널을 지원하는지 확인하는 헬퍼 함수
|
||||
*/
|
||||
export function hasConfigPanel(webType: string): boolean {
|
||||
const webTypeDefinition = WebTypeRegistry.getWebType(webType);
|
||||
return !!(webTypeDefinition && webTypeDefinition.configPanel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입의 기본 설정을 가져오는 헬퍼 함수
|
||||
*/
|
||||
export function getDefaultConfig(webType: string): Record<string, any> | null {
|
||||
const webTypeDefinition = WebTypeRegistry.getWebType(webType);
|
||||
return webTypeDefinition ? webTypeDefinition.defaultConfig : null;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
import { DynamicComponentProps } from "./types";
|
||||
import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
|
||||
/**
|
||||
* 동적 웹타입 렌더러 컴포넌트
|
||||
* 레지스트리에서 웹타입을 조회하여 동적으로 렌더링합니다.
|
||||
*/
|
||||
export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||
webType,
|
||||
props = {},
|
||||
config = {},
|
||||
onEvent,
|
||||
}) => {
|
||||
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
||||
const webTypeDefinition = useMemo(() => {
|
||||
return WebTypeRegistry.getWebType(webType);
|
||||
}, [webType]);
|
||||
|
||||
// 데이터베이스에서 웹타입 정보 조회
|
||||
const dbWebType = useMemo(() => {
|
||||
return webTypes.find((wt) => wt.web_type === webType);
|
||||
}, [webTypes, webType]);
|
||||
|
||||
// 기본 설정과 전달받은 설정을 병합 (조건부로 사용되지만 항상 계산)
|
||||
const mergedConfig = useMemo(() => {
|
||||
if (!webTypeDefinition) return config;
|
||||
return {
|
||||
...webTypeDefinition.defaultConfig,
|
||||
...config,
|
||||
};
|
||||
}, [webTypeDefinition?.defaultConfig, config]);
|
||||
|
||||
// 최종 props 구성 (조건부로 사용되지만 항상 계산)
|
||||
const finalProps = useMemo(() => {
|
||||
return {
|
||||
...props,
|
||||
webTypeConfig: mergedConfig,
|
||||
webType: webType,
|
||||
onEvent: onEvent,
|
||||
};
|
||||
}, [props, mergedConfig, webType, onEvent]);
|
||||
|
||||
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
|
||||
if (dbWebType?.component_name) {
|
||||
try {
|
||||
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
||||
console.log(`DB 웹타입 정보:`, dbWebType);
|
||||
console.log(`웹타입 데이터 배열:`, webTypes);
|
||||
const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
|
||||
console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
||||
return <ComponentByName {...props} />;
|
||||
} catch (error) {
|
||||
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 레지스트리에 등록된 웹타입 사용
|
||||
if (webTypeDefinition) {
|
||||
console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
|
||||
|
||||
// 웹타입이 비활성화된 경우
|
||||
if (!webTypeDefinition.isActive) {
|
||||
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4">
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<span className="text-sm font-medium">⚠️ 비활성화된 웹타입</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-yellow-500">웹타입 "{webType}"이 비활성화되어 있습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = webTypeDefinition.component;
|
||||
try {
|
||||
return <Component {...finalProps} />;
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${webType}" 레지스트리 컴포넌트 렌더링 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3순위: 웹타입명으로 자동 매핑 (폴백)
|
||||
try {
|
||||
console.warn(`웹타입 "${webType}" → 자동 매핑 폴백 사용`);
|
||||
const FallbackComponent = getWidgetComponentByWebType(webType);
|
||||
return <FallbackComponent {...props} />;
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<span className="text-sm font-medium">⚠️ 알 수 없는 웹타입</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-500">웹타입 "{webType}"을 렌더링할 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DynamicWebTypeRenderer.displayName = "DynamicWebTypeRenderer";
|
||||
|
||||
/**
|
||||
* 웹타입 미리보기 렌더러
|
||||
* 관리 페이지에서 웹타입을 미리보기할 때 사용
|
||||
*/
|
||||
export const WebTypePreviewRenderer: React.FC<{
|
||||
webType: string;
|
||||
config?: Record<string, any>;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}> = ({ webType, config = {}, size = "md" }) => {
|
||||
const webTypeDefinition = WebTypeRegistry.getWebType(webType);
|
||||
|
||||
if (!webTypeDefinition) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center">
|
||||
<span className="text-xs text-gray-500">웹타입 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "text-xs p-1",
|
||||
md: "text-sm p-2",
|
||||
lg: "text-base p-3",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border ${sizeClasses[size]}`}>
|
||||
<DynamicWebTypeRenderer
|
||||
webType={webType}
|
||||
config={config}
|
||||
props={{
|
||||
placeholder: `${webTypeDefinition.name} 미리보기`,
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WebTypePreviewRenderer.displayName = "WebTypePreviewRenderer";
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
WebTypeDefinition,
|
||||
ButtonActionDefinition,
|
||||
RegistryEvent,
|
||||
WebTypeFilterOptions,
|
||||
ButtonActionFilterOptions,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 웹타입 레지스트리 클래스
|
||||
* 동적으로 웹타입 컴포넌트를 등록, 관리, 조회할 수 있는 중앙 레지스트리
|
||||
*/
|
||||
export class WebTypeRegistry {
|
||||
private static webTypes = new Map<string, WebTypeDefinition>();
|
||||
private static buttonActions = new Map<string, ButtonActionDefinition>();
|
||||
private static eventListeners: Array<(event: RegistryEvent) => void> = [];
|
||||
|
||||
/**
|
||||
* 웹타입 등록
|
||||
*/
|
||||
static registerWebType(definition: WebTypeDefinition): void {
|
||||
this.webTypes.set(definition.id, definition);
|
||||
this.emitEvent({
|
||||
type: "webtype_registered",
|
||||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`✅ 웹타입 등록: ${definition.id} (${definition.name})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 등록 해제
|
||||
*/
|
||||
static unregisterWebType(id: string): void {
|
||||
const definition = this.webTypes.get(id);
|
||||
if (definition) {
|
||||
this.webTypes.delete(id);
|
||||
this.emitEvent({
|
||||
type: "webtype_unregistered",
|
||||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`❌ 웹타입 등록 해제: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 조회
|
||||
*/
|
||||
static getWebType(id: string): WebTypeDefinition | undefined {
|
||||
return this.webTypes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 웹타입 조회
|
||||
*/
|
||||
static getAllWebTypes(): WebTypeDefinition[] {
|
||||
return Array.from(this.webTypes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 필터링
|
||||
*/
|
||||
static getWebTypes(options: WebTypeFilterOptions = {}): WebTypeDefinition[] {
|
||||
let webTypes = this.getAllWebTypes();
|
||||
|
||||
// 활성화 상태 필터
|
||||
if (options.isActive !== undefined) {
|
||||
webTypes = webTypes.filter((wt) => wt.isActive === options.isActive);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (options.category) {
|
||||
webTypes = webTypes.filter((wt) => wt.category === options.category);
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (options.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
webTypes = webTypes.filter(
|
||||
(wt) =>
|
||||
wt.name.toLowerCase().includes(searchLower) ||
|
||||
wt.description.toLowerCase().includes(searchLower) ||
|
||||
wt.id.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
// 태그 필터
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
webTypes = webTypes.filter((wt) => wt.tags && wt.tags.some((tag) => options.tags!.includes(tag)));
|
||||
}
|
||||
|
||||
return webTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 웹타입 그룹화
|
||||
*/
|
||||
static getWebTypesByCategory(): Record<string, WebTypeDefinition[]> {
|
||||
const webTypes = this.getWebTypes({ isActive: true });
|
||||
const grouped: Record<string, WebTypeDefinition[]> = {};
|
||||
|
||||
webTypes.forEach((webType) => {
|
||||
if (!grouped[webType.category]) {
|
||||
grouped[webType.category] = [];
|
||||
}
|
||||
grouped[webType.category].push(webType);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 존재 여부 확인
|
||||
*/
|
||||
static hasWebType(id: string): boolean {
|
||||
return this.webTypes.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 등록
|
||||
*/
|
||||
static registerButtonAction(definition: ButtonActionDefinition): void {
|
||||
this.buttonActions.set(definition.id, definition);
|
||||
this.emitEvent({
|
||||
type: "buttonaction_registered",
|
||||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`✅ 버튼 액션 등록: ${definition.id} (${definition.name})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 등록 해제
|
||||
*/
|
||||
static unregisterButtonAction(id: string): void {
|
||||
const definition = this.buttonActions.get(id);
|
||||
if (definition) {
|
||||
this.buttonActions.delete(id);
|
||||
this.emitEvent({
|
||||
type: "buttonaction_unregistered",
|
||||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`❌ 버튼 액션 등록 해제: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 조회
|
||||
*/
|
||||
static getButtonAction(id: string): ButtonActionDefinition | undefined {
|
||||
return this.buttonActions.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 버튼 액션 조회
|
||||
*/
|
||||
static getAllButtonActions(): ButtonActionDefinition[] {
|
||||
return Array.from(this.buttonActions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 필터링
|
||||
*/
|
||||
static getButtonActions(options: ButtonActionFilterOptions = {}): ButtonActionDefinition[] {
|
||||
let buttonActions = this.getAllButtonActions();
|
||||
|
||||
// 활성화 상태 필터
|
||||
if (options.isActive !== undefined) {
|
||||
buttonActions = buttonActions.filter((ba) => ba.isActive === options.isActive);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (options.category) {
|
||||
buttonActions = buttonActions.filter((ba) => ba.category === options.category);
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (options.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
buttonActions = buttonActions.filter(
|
||||
(ba) =>
|
||||
ba.name.toLowerCase().includes(searchLower) ||
|
||||
ba.description.toLowerCase().includes(searchLower) ||
|
||||
ba.id.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
// 확인 필요 여부 필터
|
||||
if (options.requiresConfirmation !== undefined) {
|
||||
buttonActions = buttonActions.filter((ba) => ba.requiresConfirmation === options.requiresConfirmation);
|
||||
}
|
||||
|
||||
return buttonActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
*/
|
||||
static subscribe(callback: (event: RegistryEvent) => void): () => void {
|
||||
this.eventListeners.push(callback);
|
||||
|
||||
// 구독 해제 함수 반환
|
||||
return () => {
|
||||
const index = this.eventListeners.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.eventListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 발생
|
||||
*/
|
||||
private static emitEvent(event: RegistryEvent): void {
|
||||
this.eventListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error("레지스트리 이벤트 리스너 오류:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 상태 정보
|
||||
*/
|
||||
static getRegistryInfo() {
|
||||
return {
|
||||
webTypesCount: this.webTypes.size,
|
||||
buttonActionsCount: this.buttonActions.size,
|
||||
activeWebTypesCount: this.getWebTypes({ isActive: true }).length,
|
||||
activeButtonActionsCount: this.getButtonActions({ isActive: true }).length,
|
||||
categories: [...new Set(this.getAllWebTypes().map((wt) => wt.category))],
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 초기화 (개발/테스트용)
|
||||
*/
|
||||
static clear(): void {
|
||||
this.webTypes.clear();
|
||||
this.buttonActions.clear();
|
||||
this.eventListeners.length = 0;
|
||||
console.log("🧹 레지스트리 초기화됨");
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 내용을 JSON으로 내보내기
|
||||
*/
|
||||
static exportToJSON() {
|
||||
return {
|
||||
webTypes: Object.fromEntries(
|
||||
Array.from(this.webTypes.entries()).map(([id, def]) => [
|
||||
id,
|
||||
{
|
||||
...def,
|
||||
// 함수/컴포넌트는 제외하고 메타데이터만 내보내기
|
||||
component: def.component.name || "Unknown",
|
||||
configPanel: def.configPanel.name || "Unknown",
|
||||
},
|
||||
]),
|
||||
),
|
||||
buttonActions: Object.fromEntries(
|
||||
Array.from(this.buttonActions.entries()).map(([id, def]) => [
|
||||
id,
|
||||
{
|
||||
...def,
|
||||
// 함수는 제외하고 메타데이터만 내보내기
|
||||
handler: def.handler.name || "Unknown",
|
||||
},
|
||||
]),
|
||||
),
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// Registry system exports
|
||||
export { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
export { DynamicWebTypeRenderer, WebTypePreviewRenderer } from "./DynamicWebTypeRenderer";
|
||||
export { DynamicConfigPanel, renderConfigPanel, hasConfigPanel, getDefaultConfig } from "./DynamicConfigPanel";
|
||||
|
||||
// Registry hooks
|
||||
export {
|
||||
useRegistry,
|
||||
useWebTypes,
|
||||
useButtonActions,
|
||||
useWebTypesByCategory,
|
||||
useRegistryInfo,
|
||||
useWebTypeExists,
|
||||
} from "./useRegistry";
|
||||
|
||||
// Initialization
|
||||
export { initializeRegistries, initializeWebTypeRegistry } from "./init";
|
||||
|
||||
// Type definitions
|
||||
export type {
|
||||
WebTypeDefinition,
|
||||
ButtonActionDefinition,
|
||||
WebTypeComponentProps,
|
||||
WebTypeConfigPanelProps,
|
||||
ButtonActionContext,
|
||||
DynamicComponentProps,
|
||||
RegistryEvent,
|
||||
RegistryEventType,
|
||||
UseRegistryReturn,
|
||||
WebTypeFilterOptions,
|
||||
ButtonActionFilterOptions,
|
||||
WebTypeCategory,
|
||||
ButtonActionCategory,
|
||||
} from "./types";
|
||||
|
||||
// Component registry types
|
||||
export type { WidgetComponent } from "@/types/screen";
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
"use client";
|
||||
|
||||
import { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
|
||||
// 개별적으로 위젯 컴포넌트들을 import
|
||||
import { TextWidget } from "@/components/screen/widgets/types/TextWidget";
|
||||
import { NumberWidget } from "@/components/screen/widgets/types/NumberWidget";
|
||||
import { DateWidget } from "@/components/screen/widgets/types/DateWidget";
|
||||
import { SelectWidget } from "@/components/screen/widgets/types/SelectWidget";
|
||||
import { TextareaWidget } from "@/components/screen/widgets/types/TextareaWidget";
|
||||
import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget";
|
||||
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
|
||||
import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
|
||||
import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget";
|
||||
import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget";
|
||||
import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget";
|
||||
|
||||
// 개별적으로 설정 패널들을 import
|
||||
import { TextConfigPanel } from "@/components/screen/config-panels/TextConfigPanel";
|
||||
import { NumberConfigPanel } from "@/components/screen/config-panels/NumberConfigPanel";
|
||||
import { DateConfigPanel } from "@/components/screen/config-panels/DateConfigPanel";
|
||||
import { SelectConfigPanel } from "@/components/screen/config-panels/SelectConfigPanel";
|
||||
import { TextareaConfigPanel } from "@/components/screen/config-panels/TextareaConfigPanel";
|
||||
import { CheckboxConfigPanel } from "@/components/screen/config-panels/CheckboxConfigPanel";
|
||||
import { RadioConfigPanel } from "@/components/screen/config-panels/RadioConfigPanel";
|
||||
import { FileConfigPanel } from "@/components/screen/config-panels/FileConfigPanel";
|
||||
import { CodeConfigPanel } from "@/components/screen/config-panels/CodeConfigPanel";
|
||||
import { EntityConfigPanel } from "@/components/screen/config-panels/EntityConfigPanel";
|
||||
import { ButtonConfigPanel } from "@/components/screen/config-panels/ButtonConfigPanel";
|
||||
|
||||
/**
|
||||
* 웹타입 레지스트리 초기화
|
||||
* 모든 기본 웹타입 컴포넌트와 설정 패널을 등록합니다.
|
||||
*/
|
||||
export function initializeWebTypeRegistry() {
|
||||
// Text-based types
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "text",
|
||||
name: "텍스트",
|
||||
category: "input",
|
||||
description: "단일 라인 텍스트 입력 필드",
|
||||
component: TextWidget,
|
||||
configPanel: TextConfigPanel,
|
||||
defaultConfig: {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "email",
|
||||
name: "이메일",
|
||||
category: "input",
|
||||
description: "이메일 주소 입력 필드",
|
||||
component: TextWidget,
|
||||
configPanel: TextConfigPanel,
|
||||
defaultConfig: {
|
||||
placeholder: "이메일을 입력하세요",
|
||||
inputType: "email",
|
||||
validation: "email",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "password",
|
||||
name: "비밀번호",
|
||||
category: "input",
|
||||
description: "비밀번호 입력 필드",
|
||||
component: TextWidget,
|
||||
configPanel: TextConfigPanel,
|
||||
defaultConfig: {
|
||||
placeholder: "비밀번호를 입력하세요",
|
||||
inputType: "password",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "tel",
|
||||
name: "전화번호",
|
||||
category: "input",
|
||||
description: "전화번호 입력 필드",
|
||||
component: TextWidget,
|
||||
configPanel: TextConfigPanel,
|
||||
defaultConfig: {
|
||||
placeholder: "전화번호를 입력하세요",
|
||||
inputType: "tel",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Number types
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "number",
|
||||
name: "숫자",
|
||||
category: "input",
|
||||
description: "정수 입력 필드",
|
||||
component: NumberWidget,
|
||||
configPanel: NumberConfigPanel,
|
||||
defaultConfig: {
|
||||
placeholder: "숫자를 입력하세요",
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
step: 1,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "decimal",
|
||||
name: "소수",
|
||||
category: "input",
|
||||
description: "소수점 숫자 입력 필드",
|
||||
component: NumberWidget,
|
||||
configPanel: NumberConfigPanel,
|
||||
defaultConfig: {
|
||||
placeholder: "소수를 입력하세요",
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
step: 0.01,
|
||||
decimalPlaces: 2,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Date types
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "date",
|
||||
name: "날짜",
|
||||
category: "input",
|
||||
description: "날짜 선택 필드",
|
||||
component: DateWidget,
|
||||
configPanel: DateConfigPanel,
|
||||
defaultConfig: {
|
||||
format: "YYYY-MM-DD",
|
||||
showTime: false,
|
||||
placeholder: "날짜를 선택하세요",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "datetime",
|
||||
name: "날짜시간",
|
||||
category: "input",
|
||||
description: "날짜와 시간 선택 필드",
|
||||
component: DateWidget,
|
||||
configPanel: DateConfigPanel,
|
||||
defaultConfig: {
|
||||
format: "YYYY-MM-DD HH:mm",
|
||||
showTime: true,
|
||||
placeholder: "날짜와 시간을 선택하세요",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Selection types
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "select",
|
||||
name: "선택박스",
|
||||
category: "input",
|
||||
description: "드롭다운 선택 필드",
|
||||
component: SelectWidget,
|
||||
configPanel: SelectConfigPanel,
|
||||
defaultConfig: {
|
||||
options: [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
multiple: false,
|
||||
searchable: false,
|
||||
placeholder: "선택하세요",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "dropdown",
|
||||
name: "드롭다운",
|
||||
category: "input",
|
||||
description: "검색 가능한 드롭다운 필드",
|
||||
component: SelectWidget,
|
||||
configPanel: SelectConfigPanel,
|
||||
defaultConfig: {
|
||||
options: [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
multiple: false,
|
||||
searchable: true,
|
||||
placeholder: "검색하여 선택하세요",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Text area
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "textarea",
|
||||
name: "텍스트영역",
|
||||
category: "input",
|
||||
description: "여러 줄 텍스트 입력 필드",
|
||||
component: TextareaWidget,
|
||||
configPanel: TextareaConfigPanel,
|
||||
defaultConfig: {
|
||||
rows: 4,
|
||||
placeholder: "내용을 입력하세요",
|
||||
resizable: true,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "text_area",
|
||||
name: "텍스트 영역",
|
||||
category: "input",
|
||||
description: "여러 줄 텍스트 입력 필드 (언더스코어 형식)",
|
||||
component: TextareaWidget,
|
||||
configPanel: TextareaConfigPanel,
|
||||
defaultConfig: {
|
||||
rows: 4,
|
||||
placeholder: "내용을 입력하세요",
|
||||
resizable: true,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Boolean/Checkbox types
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "boolean",
|
||||
name: "불린",
|
||||
category: "input",
|
||||
description: "참/거짓 선택 체크박스",
|
||||
component: CheckboxWidget,
|
||||
configPanel: CheckboxConfigPanel,
|
||||
defaultConfig: {
|
||||
label: "선택",
|
||||
checkedValue: true,
|
||||
uncheckedValue: false,
|
||||
defaultChecked: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "checkbox",
|
||||
name: "체크박스",
|
||||
category: "input",
|
||||
description: "체크박스 입력 필드",
|
||||
component: CheckboxWidget,
|
||||
configPanel: CheckboxConfigPanel,
|
||||
defaultConfig: {
|
||||
label: "체크박스",
|
||||
checkedValue: "Y",
|
||||
uncheckedValue: "N",
|
||||
defaultChecked: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Radio button
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "radio",
|
||||
name: "라디오버튼",
|
||||
category: "input",
|
||||
description: "라디오버튼 그룹 선택 필드",
|
||||
component: RadioWidget,
|
||||
configPanel: RadioConfigPanel,
|
||||
defaultConfig: {
|
||||
options: [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
],
|
||||
inline: true,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// File upload
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "file",
|
||||
name: "파일 업로드",
|
||||
category: "input",
|
||||
description: "파일 업로드 필드",
|
||||
component: FileWidget,
|
||||
configPanel: FileConfigPanel,
|
||||
defaultConfig: {
|
||||
multiple: false,
|
||||
maxFileSize: 10, // MB
|
||||
acceptedTypes: [],
|
||||
showPreview: true,
|
||||
dragAndDrop: true,
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Code editor
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "code",
|
||||
name: "코드 에디터",
|
||||
category: "input",
|
||||
description: "코드 편집 필드",
|
||||
component: CodeWidget,
|
||||
configPanel: CodeConfigPanel,
|
||||
defaultConfig: {
|
||||
language: "javascript",
|
||||
theme: "light",
|
||||
showLineNumbers: true,
|
||||
height: 300,
|
||||
required: false,
|
||||
readOnly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Entity selection
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "entity",
|
||||
name: "엔티티 선택",
|
||||
category: "input",
|
||||
description: "데이터베이스 엔티티 선택 필드",
|
||||
component: EntityWidget,
|
||||
configPanel: EntityConfigPanel,
|
||||
defaultConfig: {
|
||||
entityType: "",
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
multiple: false,
|
||||
searchable: true,
|
||||
placeholder: "엔티티를 선택하세요",
|
||||
required: false,
|
||||
readonly: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Button
|
||||
WebTypeRegistry.registerWebType({
|
||||
id: "button",
|
||||
name: "버튼",
|
||||
category: "action",
|
||||
description: "클릭 가능한 버튼 컴포넌트",
|
||||
component: ButtonWidget,
|
||||
configPanel: ButtonConfigPanel,
|
||||
defaultConfig: {
|
||||
label: "버튼",
|
||||
text: "",
|
||||
tooltip: "",
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
console.log("웹타입 레지스트리 초기화 완료:", WebTypeRegistry.getAllWebTypes().length, "개 웹타입 등록됨");
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 시작 시 호출되어야 하는 초기화 함수
|
||||
*/
|
||||
export function initializeRegistries() {
|
||||
initializeWebTypeRegistry();
|
||||
|
||||
// 필요한 경우 버튼 액션 레지스트리도 여기서 초기화
|
||||
// initializeButtonActionRegistry();
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import React from "react";
|
||||
|
||||
/**
|
||||
* 웹타입 정의 인터페이스
|
||||
*/
|
||||
export interface WebTypeDefinition {
|
||||
/** 고유 식별자 */
|
||||
id: string;
|
||||
/** 표시 이름 */
|
||||
name: string;
|
||||
/** 카테고리 (input, display, layout 등) */
|
||||
category: string;
|
||||
/** 설명 */
|
||||
description: string;
|
||||
/** 렌더링 컴포넌트 */
|
||||
component: React.ComponentType<any>;
|
||||
/** 설정 패널 컴포넌트 */
|
||||
configPanel: React.ComponentType<WebTypeConfigPanelProps>;
|
||||
/** 기본 설정값 */
|
||||
defaultConfig: Record<string, any>;
|
||||
/** 활성화 상태 */
|
||||
isActive: boolean;
|
||||
/** 아이콘 (선택사항) */
|
||||
icon?: React.ComponentType<any>;
|
||||
/** 태그 (선택사항) */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 정의 인터페이스
|
||||
*/
|
||||
export interface ButtonActionDefinition {
|
||||
/** 고유 식별자 */
|
||||
id: string;
|
||||
/** 표시 이름 */
|
||||
name: string;
|
||||
/** 카테고리 (save, delete, navigate 등) */
|
||||
category: string;
|
||||
/** 설명 */
|
||||
description: string;
|
||||
/** 핸들러 함수 */
|
||||
handler: (context: ButtonActionContext) => void | Promise<void>;
|
||||
/** 기본 설정값 */
|
||||
defaultConfig: Record<string, any>;
|
||||
/** 활성화 상태 */
|
||||
isActive: boolean;
|
||||
/** 아이콘 (선택사항) */
|
||||
icon?: React.ComponentType<any>;
|
||||
/** 확인 메시지 필요 여부 */
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 컴포넌트 Props
|
||||
*/
|
||||
export interface WebTypeComponentProps {
|
||||
/** 컴포넌트 객체 */
|
||||
component: any;
|
||||
/** 현재 값 */
|
||||
value?: any;
|
||||
/** 값 변경 핸들러 */
|
||||
onChange?: (value: any) => void;
|
||||
/** 읽기 전용 모드 */
|
||||
readonly?: boolean;
|
||||
/** 추가 속성들 */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 설정 패널 Props
|
||||
*/
|
||||
export interface WebTypeConfigPanelProps {
|
||||
/** 컴포넌트 객체 */
|
||||
component: any;
|
||||
/** 컴포넌트 업데이트 핸들러 */
|
||||
onUpdateComponent: (component: any) => void;
|
||||
/** 속성 업데이트 핸들러 */
|
||||
onUpdateProperty: (property: string, value: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 실행 컨텍스트
|
||||
*/
|
||||
export interface ButtonActionContext {
|
||||
/** 현재 화면 데이터 */
|
||||
screenData: Record<string, any>;
|
||||
/** 선택된 데이터 */
|
||||
selectedData?: Record<string, any>;
|
||||
/** 화면 설정 */
|
||||
screenConfig: Record<string, any>;
|
||||
/** 사용자 정보 */
|
||||
user: any;
|
||||
/** 네비게이션 함수 */
|
||||
navigate: (path: string) => void;
|
||||
/** 메시지 표시 함수 */
|
||||
showMessage: (message: string, type?: "success" | "error" | "warning" | "info") => void;
|
||||
/** API 호출 함수 */
|
||||
api: {
|
||||
get: (url: string, params?: any) => Promise<any>;
|
||||
post: (url: string, data?: any) => Promise<any>;
|
||||
put: (url: string, data?: any) => Promise<any>;
|
||||
delete: (url: string) => Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 이벤트 타입
|
||||
*/
|
||||
export type RegistryEventType =
|
||||
| "webtype_registered"
|
||||
| "webtype_unregistered"
|
||||
| "buttonaction_registered"
|
||||
| "buttonaction_unregistered";
|
||||
|
||||
/**
|
||||
* 레지스트리 이벤트 인터페이스
|
||||
*/
|
||||
export interface RegistryEvent {
|
||||
type: RegistryEventType;
|
||||
data: WebTypeDefinition | ButtonActionDefinition;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 카테고리
|
||||
*/
|
||||
export type WebTypeCategory = "input" | "display" | "layout" | "media" | "data" | "action";
|
||||
|
||||
/**
|
||||
* 버튼 액션 카테고리
|
||||
*/
|
||||
export type ButtonActionCategory = "save" | "delete" | "navigate" | "export" | "import" | "custom";
|
||||
|
||||
/**
|
||||
* 동적 컴포넌트 렌더러 Props
|
||||
*/
|
||||
export interface DynamicComponentProps {
|
||||
/** 웹타입 ID */
|
||||
webType: string;
|
||||
/** 컴포넌트 속성 */
|
||||
props: Record<string, any>;
|
||||
/** 컴포넌트 설정 */
|
||||
config?: Record<string, any>;
|
||||
/** 이벤트 핸들러 */
|
||||
onEvent?: (event: string, data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 훅 반환 타입
|
||||
*/
|
||||
export interface UseRegistryReturn {
|
||||
/** 등록된 웹타입 목록 */
|
||||
webTypes: WebTypeDefinition[];
|
||||
/** 등록된 버튼 액션 목록 */
|
||||
buttonActions: ButtonActionDefinition[];
|
||||
/** 웹타입 등록 */
|
||||
registerWebType: (definition: WebTypeDefinition) => void;
|
||||
/** 웹타입 등록 해제 */
|
||||
unregisterWebType: (id: string) => void;
|
||||
/** 버튼 액션 등록 */
|
||||
registerButtonAction: (definition: ButtonActionDefinition) => void;
|
||||
/** 버튼 액션 등록 해제 */
|
||||
unregisterButtonAction: (id: string) => void;
|
||||
/** 웹타입 조회 */
|
||||
getWebType: (id: string) => WebTypeDefinition | undefined;
|
||||
/** 버튼 액션 조회 */
|
||||
getButtonAction: (id: string) => ButtonActionDefinition | undefined;
|
||||
/** 이벤트 구독 */
|
||||
subscribe: (callback: (event: RegistryEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 필터 옵션
|
||||
*/
|
||||
export interface WebTypeFilterOptions {
|
||||
/** 카테고리 필터 */
|
||||
category?: WebTypeCategory;
|
||||
/** 활성화 상태 필터 */
|
||||
isActive?: boolean;
|
||||
/** 검색어 */
|
||||
search?: string;
|
||||
/** 태그 필터 */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 필터 옵션
|
||||
*/
|
||||
export interface ButtonActionFilterOptions {
|
||||
/** 카테고리 필터 */
|
||||
category?: ButtonActionCategory;
|
||||
/** 활성화 상태 필터 */
|
||||
isActive?: boolean;
|
||||
/** 검색어 */
|
||||
search?: string;
|
||||
/** 확인 필요 여부 필터 */
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
import {
|
||||
WebTypeDefinition,
|
||||
ButtonActionDefinition,
|
||||
UseRegistryReturn,
|
||||
WebTypeFilterOptions,
|
||||
ButtonActionFilterOptions,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 레지스트리 관리를 위한 React 훅
|
||||
*/
|
||||
export function useRegistry(): UseRegistryReturn {
|
||||
const [webTypes, setWebTypes] = useState<WebTypeDefinition[]>([]);
|
||||
const [buttonActions, setButtonActions] = useState<ButtonActionDefinition[]>([]);
|
||||
|
||||
// 웹타입 목록 업데이트
|
||||
const updateWebTypes = useCallback(() => {
|
||||
setWebTypes(WebTypeRegistry.getAllWebTypes());
|
||||
}, []);
|
||||
|
||||
// 버튼 액션 목록 업데이트
|
||||
const updateButtonActions = useCallback(() => {
|
||||
setButtonActions(WebTypeRegistry.getAllButtonActions());
|
||||
}, []);
|
||||
|
||||
// 레지스트리 이벤트 구독
|
||||
useEffect(() => {
|
||||
// 초기 데이터 로드
|
||||
updateWebTypes();
|
||||
updateButtonActions();
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
const unsubscribe = WebTypeRegistry.subscribe((event) => {
|
||||
if (event.type === "webtype_registered" || event.type === "webtype_unregistered") {
|
||||
updateWebTypes();
|
||||
} else if (event.type === "buttonaction_registered" || event.type === "buttonaction_unregistered") {
|
||||
updateButtonActions();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [updateWebTypes, updateButtonActions]);
|
||||
|
||||
// 웹타입 등록
|
||||
const registerWebType = useCallback((definition: WebTypeDefinition) => {
|
||||
WebTypeRegistry.registerWebType(definition);
|
||||
}, []);
|
||||
|
||||
// 웹타입 등록 해제
|
||||
const unregisterWebType = useCallback((id: string) => {
|
||||
WebTypeRegistry.unregisterWebType(id);
|
||||
}, []);
|
||||
|
||||
// 버튼 액션 등록
|
||||
const registerButtonAction = useCallback((definition: ButtonActionDefinition) => {
|
||||
WebTypeRegistry.registerButtonAction(definition);
|
||||
}, []);
|
||||
|
||||
// 버튼 액션 등록 해제
|
||||
const unregisterButtonAction = useCallback((id: string) => {
|
||||
WebTypeRegistry.unregisterButtonAction(id);
|
||||
}, []);
|
||||
|
||||
// 웹타입 조회
|
||||
const getWebType = useCallback((id: string) => {
|
||||
return WebTypeRegistry.getWebType(id);
|
||||
}, []);
|
||||
|
||||
// 버튼 액션 조회
|
||||
const getButtonAction = useCallback((id: string) => {
|
||||
return WebTypeRegistry.getButtonAction(id);
|
||||
}, []);
|
||||
|
||||
// 이벤트 구독
|
||||
const subscribe = useCallback((callback: (event: any) => void) => {
|
||||
return WebTypeRegistry.subscribe(callback);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
webTypes,
|
||||
buttonActions,
|
||||
registerWebType,
|
||||
unregisterWebType,
|
||||
registerButtonAction,
|
||||
unregisterButtonAction,
|
||||
getWebType,
|
||||
getButtonAction,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터링된 웹타입을 가져오는 훅
|
||||
*/
|
||||
export function useWebTypes(options: WebTypeFilterOptions = {}) {
|
||||
const [webTypes, setWebTypes] = useState<WebTypeDefinition[]>([]);
|
||||
|
||||
const filteredWebTypes = useMemo(() => {
|
||||
return WebTypeRegistry.getWebTypes(options);
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentWebTypes = WebTypeRegistry.getWebTypes(options);
|
||||
setWebTypes(currentWebTypes);
|
||||
|
||||
// 레지스트리 변경 감지
|
||||
const unsubscribe = WebTypeRegistry.subscribe((event) => {
|
||||
if (event.type === "webtype_registered" || event.type === "webtype_unregistered") {
|
||||
setWebTypes(WebTypeRegistry.getWebTypes(options));
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [options]);
|
||||
|
||||
return webTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터링된 버튼 액션을 가져오는 훅
|
||||
*/
|
||||
export function useButtonActions(options: ButtonActionFilterOptions = {}) {
|
||||
const [buttonActions, setButtonActions] = useState<ButtonActionDefinition[]>([]);
|
||||
|
||||
const filteredButtonActions = useMemo(() => {
|
||||
return WebTypeRegistry.getButtonActions(options);
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
setButtonActions(filteredButtonActions);
|
||||
|
||||
// 레지스트리 변경 감지
|
||||
const unsubscribe = WebTypeRegistry.subscribe((event) => {
|
||||
if (event.type === "buttonaction_registered" || event.type === "buttonaction_unregistered") {
|
||||
setButtonActions(WebTypeRegistry.getButtonActions(options));
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [filteredButtonActions, options]);
|
||||
|
||||
return buttonActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입별로 그룹화된 데이터를 가져오는 훅
|
||||
*/
|
||||
export function useWebTypesByCategory() {
|
||||
const [groupedWebTypes, setGroupedWebTypes] = useState<Record<string, WebTypeDefinition[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const updateGroupedWebTypes = () => {
|
||||
setGroupedWebTypes(WebTypeRegistry.getWebTypesByCategory());
|
||||
};
|
||||
|
||||
updateGroupedWebTypes();
|
||||
|
||||
// 레지스트리 변경 감지
|
||||
const unsubscribe = WebTypeRegistry.subscribe((event) => {
|
||||
if (event.type === "webtype_registered" || event.type === "webtype_unregistered") {
|
||||
updateGroupedWebTypes();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return groupedWebTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 상태 정보를 가져오는 훅
|
||||
*/
|
||||
export function useRegistryInfo() {
|
||||
const [registryInfo, setRegistryInfo] = useState(WebTypeRegistry.getRegistryInfo());
|
||||
|
||||
useEffect(() => {
|
||||
const updateRegistryInfo = () => {
|
||||
setRegistryInfo(WebTypeRegistry.getRegistryInfo());
|
||||
};
|
||||
|
||||
// 레지스트리 변경 감지
|
||||
const unsubscribe = WebTypeRegistry.subscribe(() => {
|
||||
updateRegistryInfo();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return registryInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 웹타입의 존재 여부를 확인하는 훅
|
||||
*/
|
||||
export function useWebTypeExists(webTypeId: string) {
|
||||
const [exists, setExists] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkExists = () => {
|
||||
setExists(WebTypeRegistry.hasWebType(webTypeId));
|
||||
};
|
||||
|
||||
checkExists();
|
||||
|
||||
// 레지스트리 변경 감지
|
||||
const unsubscribe = WebTypeRegistry.subscribe((event) => {
|
||||
if (event.type === "webtype_registered" || event.type === "webtype_unregistered") {
|
||||
checkExists();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [webTypeId]);
|
||||
|
||||
return exists;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// 사용 가능한 위젯 컴포넌트 목록
|
||||
export const AVAILABLE_COMPONENTS = [
|
||||
{ value: "TextWidget", label: "텍스트 입력", description: "기본 텍스트 입력 필드" },
|
||||
{ value: "NumberWidget", label: "숫자 입력", description: "숫자 전용 입력 필드" },
|
||||
{ value: "DateWidget", label: "날짜 선택", description: "날짜/시간 선택기" },
|
||||
{ value: "SelectWidget", label: "선택 목록", description: "드롭다운 선택 목록" },
|
||||
{ value: "TextareaWidget", label: "긴 텍스트", description: "여러 줄 텍스트 입력" },
|
||||
{ value: "CheckboxWidget", label: "체크박스", description: "참/거짓 선택" },
|
||||
{ value: "RadioWidget", label: "라디오 버튼", description: "단일 선택 라디오 버튼" },
|
||||
{ value: "FileWidget", label: "파일 업로드", description: "파일 선택 및 업로드" },
|
||||
{ value: "CodeWidget", label: "코드 입력", description: "코드 편집기" },
|
||||
{ value: "EntityWidget", label: "엔티티 선택", description: "관련 데이터 선택" },
|
||||
{ value: "RatingWidget", label: "별점 평가", description: "별점 선택 위젯" },
|
||||
] as const;
|
||||
|
||||
export type ComponentName = (typeof AVAILABLE_COMPONENTS)[number]["value"];
|
||||
|
||||
// 컴포넌트명으로 정보 조회
|
||||
export const getComponentInfo = (componentName: string) => {
|
||||
return AVAILABLE_COMPONENTS.find((comp) => comp.value === componentName);
|
||||
};
|
||||
|
||||
// 컴포넌트 존재 여부 확인
|
||||
export const isValidComponent = (componentName: string): componentName is ComponentName => {
|
||||
return AVAILABLE_COMPONENTS.some((comp) => comp.value === componentName);
|
||||
};
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WebTypeStandard } from "./admin/useWebTypes";
|
||||
import { ButtonActionStandard } from "./admin/useButtonActions";
|
||||
|
||||
// API 응답 인터페이스
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 인터페이스
|
||||
interface ScreenStandardQueryParams {
|
||||
active?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면관리에서 사용할 표준 정보 조회 훅
|
||||
* 관리 페이지와 달리 읽기 전용으로 사용
|
||||
*/
|
||||
export const useScreenStandards = () => {
|
||||
// 활성화된 웹타입 조회
|
||||
const {
|
||||
data: webTypes,
|
||||
isLoading: isLoadingWebTypes,
|
||||
error: webTypesError,
|
||||
} = useQuery({
|
||||
queryKey: ["screenStandards", "webTypes"],
|
||||
queryFn: async (): Promise<WebTypeStandard[]> => {
|
||||
const response = await fetch("/api/screen/web-types?active=Y", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: ApiResponse<WebTypeStandard[]> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to fetch web types");
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10분간 캐시 유지 (관리 페이지보다 길게)
|
||||
cacheTime: 30 * 60 * 1000, // 30분간 메모리 보관
|
||||
});
|
||||
|
||||
// 활성화된 버튼 액션 조회
|
||||
const {
|
||||
data: buttonActions,
|
||||
isLoading: isLoadingButtonActions,
|
||||
error: buttonActionsError,
|
||||
} = useQuery({
|
||||
queryKey: ["screenStandards", "buttonActions"],
|
||||
queryFn: async (): Promise<ButtonActionStandard[]> => {
|
||||
const response = await fetch("/api/screen/button-actions?active=Y", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: ApiResponse<ButtonActionStandard[]> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to fetch button actions");
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10분간 캐시 유지
|
||||
cacheTime: 30 * 60 * 1000, // 30분간 메모리 보관
|
||||
});
|
||||
|
||||
// 웹타입 카테고리별 그룹화
|
||||
const webTypesByCategory =
|
||||
webTypes?.reduce((acc, webType) => {
|
||||
if (!acc[webType.category]) {
|
||||
acc[webType.category] = [];
|
||||
}
|
||||
acc[webType.category].push(webType);
|
||||
return acc;
|
||||
}, {} as Record<string, WebTypeStandard[]>) || {};
|
||||
|
||||
// 버튼 액션 카테고리별 그룹화
|
||||
const buttonActionsByCategory =
|
||||
buttonActions?.reduce((acc, action) => {
|
||||
if (!acc[action.category]) {
|
||||
acc[action.category] = [];
|
||||
}
|
||||
acc[action.category].push(action);
|
||||
return acc;
|
||||
}, {} as Record<string, ButtonActionStandard[]>) || {};
|
||||
|
||||
// 웹타입 드롭다운 옵션 생성
|
||||
const webTypeOptions =
|
||||
webTypes?.map((webType) => ({
|
||||
value: webType.web_type,
|
||||
label: webType.type_name,
|
||||
labelEng: webType.type_name_eng,
|
||||
category: webType.category,
|
||||
description: webType.description,
|
||||
})) || [];
|
||||
|
||||
// 버튼 액션 드롭다운 옵션 생성
|
||||
const buttonActionOptions =
|
||||
buttonActions?.map((action) => ({
|
||||
value: action.action_type,
|
||||
label: action.action_name,
|
||||
labelEng: action.action_name_eng,
|
||||
category: action.category,
|
||||
description: action.description,
|
||||
icon: action.default_icon,
|
||||
color: action.default_color,
|
||||
variant: action.default_variant,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
// 원본 데이터
|
||||
webTypes: webTypes || [],
|
||||
buttonActions: buttonActions || [],
|
||||
|
||||
// 그룹화된 데이터
|
||||
webTypesByCategory,
|
||||
buttonActionsByCategory,
|
||||
|
||||
// 드롭다운 옵션
|
||||
webTypeOptions,
|
||||
buttonActionOptions,
|
||||
|
||||
// 로딩 상태
|
||||
isLoading: isLoadingWebTypes || isLoadingButtonActions,
|
||||
isLoadingWebTypes,
|
||||
isLoadingButtonActions,
|
||||
|
||||
// 에러
|
||||
error: webTypesError || buttonActionsError,
|
||||
webTypesError,
|
||||
buttonActionsError,
|
||||
|
||||
// 유틸리티 메서드
|
||||
getWebType: (webType: string) =>
|
||||
webTypes?.find((w) => w.web_type === webType),
|
||||
getButtonAction: (actionType: string) =>
|
||||
buttonActions?.find((a) => a.action_type === actionType),
|
||||
getWebTypesByCategory: (category: string) =>
|
||||
webTypesByCategory[category] || [],
|
||||
getButtonActionsByCategory: (category: string) =>
|
||||
buttonActionsByCategory[category] || [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
import { WebTypeConfigPanelProps } from "./types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
interface DynamicConfigPanelProps extends WebTypeConfigPanelProps {
|
||||
widgetType: string;
|
||||
showFallback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 설정 패널 렌더러
|
||||
* 등록된 웹타입에 따라 적절한 설정 패널을 동적으로 렌더링
|
||||
*/
|
||||
export const DynamicConfigPanel: React.FC<DynamicConfigPanelProps> = React.memo(
|
||||
({
|
||||
widgetType,
|
||||
component,
|
||||
onUpdateComponent,
|
||||
onUpdateProperty,
|
||||
showFallback = true,
|
||||
}) => {
|
||||
// 웹타입 정의 조회
|
||||
const definition = WebTypeRegistry.get(widgetType);
|
||||
|
||||
// 웹타입이 등록되지 않은 경우
|
||||
if (!definition) {
|
||||
console.warn(`Unknown web type for config panel: ${widgetType}`);
|
||||
|
||||
if (!showFallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="secondary" className="w-full">
|
||||
<Settings className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<code>{widgetType}</code> 타입에 대한 설정 패널이 없습니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// 설정 패널이 정의되지 않은 경우
|
||||
if (!definition.configPanel) {
|
||||
if (!showFallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
{definition.name} 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Alert variant="secondary">
|
||||
<AlertDescription className="text-xs">
|
||||
이 웹타입에는 별도의 설정 옵션이 없습니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 비활성화된 웹타입인 경우
|
||||
if (definition.isActive === false) {
|
||||
return (
|
||||
<Alert variant="secondary" className="w-full">
|
||||
<Settings className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
비활성화된 웹타입입니다: <code>{widgetType}</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// 등록된 설정 패널 컴포넌트 렌더링
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
|
||||
return (
|
||||
<ConfigPanelComponent
|
||||
component={component}
|
||||
onUpdateComponent={onUpdateComponent}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DynamicConfigPanel.displayName = "DynamicConfigPanel";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import React, { Suspense } from "react";
|
||||
import { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
import { WebTypeComponentProps } from "./types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface DynamicWebTypeRendererProps extends WebTypeComponentProps {
|
||||
widgetType: string;
|
||||
fallback?: React.ComponentType<WebTypeComponentProps>;
|
||||
showError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 웹타입 렌더러
|
||||
* 등록된 웹타입에 따라 적절한 컴포넌트를 동적으로 렌더링
|
||||
*/
|
||||
export const DynamicWebTypeRenderer: React.FC<DynamicWebTypeRendererProps> =
|
||||
React.memo(
|
||||
({
|
||||
widgetType,
|
||||
component,
|
||||
fallback: FallbackComponent,
|
||||
showError = true,
|
||||
...props
|
||||
}) => {
|
||||
// 웹타입 정의 조회
|
||||
const definition = WebTypeRegistry.get(widgetType);
|
||||
|
||||
// 웹타입이 등록되지 않은 경우
|
||||
if (!definition) {
|
||||
console.warn(`Unknown web type: ${widgetType}`);
|
||||
|
||||
// Fallback 컴포넌트가 있으면 사용
|
||||
if (FallbackComponent) {
|
||||
return <FallbackComponent component={component} {...props} />;
|
||||
}
|
||||
|
||||
// 에러 표시를 원하지 않으면 빈 div 반환
|
||||
if (!showError) {
|
||||
return <div className="w-full h-full" />;
|
||||
}
|
||||
|
||||
// 에러 메시지 표시
|
||||
return (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertDescription>
|
||||
알 수 없는 웹타입: <code>{widgetType}</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// 비활성화된 웹타입인 경우
|
||||
if (definition.isActive === false) {
|
||||
console.warn(`Inactive web type: ${widgetType}`);
|
||||
|
||||
if (showError) {
|
||||
return (
|
||||
<Alert variant="secondary" className="w-full">
|
||||
<AlertDescription>
|
||||
비활성화된 웹타입: <code>{widgetType}</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="w-full h-full opacity-50" />;
|
||||
}
|
||||
|
||||
// 등록된 컴포넌트 렌더링
|
||||
const Component = definition.component;
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center w-full h-full p-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-xs text-gray-500">로딩 중...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Component component={component} {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// 메모이제이션을 위한 얕은 비교
|
||||
return (
|
||||
prevProps.widgetType === nextProps.widgetType &&
|
||||
prevProps.component.id === nextProps.component.id &&
|
||||
prevProps.value === nextProps.value &&
|
||||
prevProps.readonly === nextProps.readonly &&
|
||||
JSON.stringify(prevProps.component.webTypeConfig) ===
|
||||
JSON.stringify(nextProps.component.webTypeConfig)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DynamicWebTypeRenderer.displayName = "DynamicWebTypeRenderer";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
import {
|
||||
WebTypeDefinition,
|
||||
ButtonActionDefinition,
|
||||
RegistryEvent,
|
||||
RegistryEventListener,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 웹타입 레지스트리 클래스
|
||||
* 웹타입과 버튼 액션의 동적 등록/관리를 담당
|
||||
*/
|
||||
export class WebTypeRegistry {
|
||||
private static webTypes = new Map<string, WebTypeDefinition>();
|
||||
private static buttonActions = new Map<string, ButtonActionDefinition>();
|
||||
private static listeners: RegistryEventListener[] = [];
|
||||
|
||||
// ===== 웹타입 관리 =====
|
||||
|
||||
/**
|
||||
* 웹타입 등록
|
||||
*/
|
||||
static register(definition: WebTypeDefinition): void {
|
||||
this.webTypes.set(definition.webType, definition);
|
||||
this.notifyListeners({
|
||||
type: "webtype_registered",
|
||||
data: definition,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 조회
|
||||
*/
|
||||
static get(webType: string): WebTypeDefinition | undefined {
|
||||
return this.webTypes.get(webType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 웹타입 조회 (활성화된 것만, 정렬됨)
|
||||
*/
|
||||
static getAll(): WebTypeDefinition[] {
|
||||
return Array.from(this.webTypes.values())
|
||||
.filter((type) => type.isActive !== false)
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 웹타입 조회
|
||||
*/
|
||||
static getByCategory(category: string): WebTypeDefinition[] {
|
||||
return this.getAll().filter((type) => type.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 등록 해제
|
||||
*/
|
||||
static unregister(webType: string): boolean {
|
||||
const definition = this.webTypes.get(webType);
|
||||
if (definition) {
|
||||
this.webTypes.delete(webType);
|
||||
this.notifyListeners({
|
||||
type: "webtype_unregistered",
|
||||
data: definition,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 존재 여부 확인
|
||||
*/
|
||||
static has(webType: string): boolean {
|
||||
return this.webTypes.has(webType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 웹타입 카테고리 조회
|
||||
*/
|
||||
static getCategories(): string[] {
|
||||
const categories = new Set<string>();
|
||||
this.getAll().forEach((type) => categories.add(type.category));
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
// ===== 버튼 액션 관리 =====
|
||||
|
||||
/**
|
||||
* 버튼 액션 등록
|
||||
*/
|
||||
static registerAction(definition: ButtonActionDefinition): void {
|
||||
this.buttonActions.set(definition.actionType, definition);
|
||||
this.notifyListeners({
|
||||
type: "action_registered",
|
||||
data: definition,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 조회
|
||||
*/
|
||||
static getAction(actionType: string): ButtonActionDefinition | undefined {
|
||||
return this.buttonActions.get(actionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 버튼 액션 조회 (활성화된 것만, 정렬됨)
|
||||
*/
|
||||
static getAllActions(): ButtonActionDefinition[] {
|
||||
return Array.from(this.buttonActions.values())
|
||||
.filter((action) => action.isActive !== false)
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 버튼 액션 조회
|
||||
*/
|
||||
static getActionsByCategory(category: string): ButtonActionDefinition[] {
|
||||
return this.getAllActions().filter(
|
||||
(action) => action.category === category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 등록 해제
|
||||
*/
|
||||
static unregisterAction(actionType: string): boolean {
|
||||
const definition = this.buttonActions.get(actionType);
|
||||
if (definition) {
|
||||
this.buttonActions.delete(actionType);
|
||||
this.notifyListeners({
|
||||
type: "action_unregistered",
|
||||
data: definition,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 존재 여부 확인
|
||||
*/
|
||||
static hasAction(actionType: string): boolean {
|
||||
return this.buttonActions.has(actionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 버튼 액션 카테고리 조회
|
||||
*/
|
||||
static getActionCategories(): string[] {
|
||||
const categories = new Set<string>();
|
||||
this.getAllActions().forEach((action) => categories.add(action.category));
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
// ===== 이벤트 관리 =====
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
*/
|
||||
static addEventListener(listener: RegistryEventListener): void {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 제거
|
||||
*/
|
||||
static removeEventListener(listener: RegistryEventListener): void {
|
||||
const index = this.listeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 알림
|
||||
*/
|
||||
private static notifyListeners(event: RegistryEvent): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error("Registry event listener error:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 유틸리티 메서드 =====
|
||||
|
||||
/**
|
||||
* 레지스트리 상태 조회
|
||||
*/
|
||||
static getStats() {
|
||||
return {
|
||||
webTypes: {
|
||||
total: this.webTypes.size,
|
||||
active: this.getAll().length,
|
||||
categories: this.getCategories().length,
|
||||
},
|
||||
buttonActions: {
|
||||
total: this.buttonActions.size,
|
||||
active: this.getAllActions().length,
|
||||
categories: this.getActionCategories().length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레지스트리 초기화 (테스트용)
|
||||
*/
|
||||
static clear(): void {
|
||||
this.webTypes.clear();
|
||||
this.buttonActions.clear();
|
||||
this.listeners.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 정의 검증
|
||||
*/
|
||||
static validateWebType(definition: Partial<WebTypeDefinition>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!definition.webType) {
|
||||
errors.push("webType is required");
|
||||
}
|
||||
|
||||
if (!definition.name) {
|
||||
errors.push("name is required");
|
||||
}
|
||||
|
||||
if (!definition.category) {
|
||||
errors.push("category is required");
|
||||
}
|
||||
|
||||
if (!definition.component) {
|
||||
errors.push("component is required");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 정의 검증
|
||||
*/
|
||||
static validateButtonAction(
|
||||
definition: Partial<ButtonActionDefinition>
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!definition.actionType) {
|
||||
errors.push("actionType is required");
|
||||
}
|
||||
|
||||
if (!definition.name) {
|
||||
errors.push("name is required");
|
||||
}
|
||||
|
||||
if (!definition.category) {
|
||||
errors.push("category is required");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// 웹타입 레지스트리 시스템 내보내기
|
||||
|
||||
export { WebTypeRegistry } from "./WebTypeRegistry";
|
||||
export { DynamicWebTypeRenderer } from "./DynamicWebTypeRenderer";
|
||||
export { DynamicConfigPanel } from "./DynamicConfigPanel";
|
||||
|
||||
export type {
|
||||
WebTypeDefinition,
|
||||
ButtonActionDefinition,
|
||||
WebTypeComponentProps,
|
||||
WebTypeConfigPanelProps,
|
||||
RegistryEvent,
|
||||
RegistryEventListener,
|
||||
} from "./types";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { WidgetComponent } from "@/types/screen";
|
||||
|
||||
// 웹타입 정의 인터페이스
|
||||
export interface WebTypeDefinition {
|
||||
webType: string; // 웹타입 식별자
|
||||
name: string; // 표시명
|
||||
nameEng?: string; // 영문명
|
||||
description?: string; // 설명
|
||||
category: string; // 카테고리 (input, select, display, special)
|
||||
defaultConfig?: any; // 기본 설정
|
||||
validationRules?: any; // 유효성 검사 규칙
|
||||
defaultStyle?: any; // 기본 스타일
|
||||
inputProperties?: any; // HTML input 속성
|
||||
component: React.ComponentType<WebTypeComponentProps>; // 렌더링 컴포넌트
|
||||
configPanel?: React.ComponentType<WebTypeConfigPanelProps>; // 설정 패널 컴포넌트
|
||||
icon?: React.ComponentType<{ className?: string }>; // 아이콘 컴포넌트
|
||||
sortOrder?: number; // 정렬 순서
|
||||
isActive?: boolean; // 활성화 여부
|
||||
}
|
||||
|
||||
// 웹타입 컴포넌트 프로퍼티
|
||||
export interface WebTypeComponentProps {
|
||||
component: WidgetComponent;
|
||||
value?: any;
|
||||
onChange?: (value: any) => void;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 웹타입 설정 패널 프로퍼티
|
||||
export interface WebTypeConfigPanelProps {
|
||||
component: WidgetComponent;
|
||||
onUpdateComponent: (updates: Partial<WidgetComponent>) => void;
|
||||
onUpdateProperty?: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
// 버튼 액션 정의 인터페이스
|
||||
export interface ButtonActionDefinition {
|
||||
actionType: string; // 액션 타입 식별자
|
||||
name: string; // 표시명
|
||||
nameEng?: string; // 영문명
|
||||
description?: string; // 설명
|
||||
category: string; // 카테고리 (crud, navigation, utility, custom)
|
||||
defaultText?: string; // 기본 텍스트
|
||||
defaultTextEng?: string; // 기본 영문 텍스트
|
||||
defaultIcon?: string; // 기본 아이콘 (Lucide 아이콘 이름)
|
||||
defaultColor?: string; // 기본 색상
|
||||
defaultVariant?: string; // 기본 변형 (default, destructive, outline, secondary, ghost, link)
|
||||
confirmationRequired?: boolean; // 확인 메시지 필요 여부
|
||||
confirmationMessage?: string; // 기본 확인 메시지
|
||||
validationRules?: any; // 실행 전 검증 규칙
|
||||
actionConfig?: any; // 액션별 추가 설정
|
||||
handler?: (
|
||||
component: WidgetComponent,
|
||||
formData?: any
|
||||
) => Promise<void> | void; // 액션 핸들러
|
||||
sortOrder?: number; // 정렬 순서
|
||||
isActive?: boolean; // 활성화 여부
|
||||
}
|
||||
|
||||
// 레지스트리 이벤트 타입
|
||||
export interface RegistryEvent {
|
||||
type:
|
||||
| "webtype_registered"
|
||||
| "webtype_unregistered"
|
||||
| "action_registered"
|
||||
| "action_unregistered";
|
||||
data: WebTypeDefinition | ButtonActionDefinition;
|
||||
}
|
||||
|
||||
// 레지스트리 이벤트 리스너
|
||||
export type RegistryEventListener = (event: RegistryEvent) => void;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 교체할 패턴들
|
||||
const patterns = [
|
||||
// 기본 Select 패턴
|
||||
{
|
||||
from: /<Select\s+([^>]*?)>\s*<SelectTrigger([^>]*?)>\s*<SelectValue([^>]*?)\/>\s*<\/SelectTrigger>\s*<SelectContent>([\s\S]*?)<\/SelectContent>\s*<\/Select>/g,
|
||||
to: (match, selectProps, triggerProps, valueProps, content) => {
|
||||
// SelectItem들을 option으로 변환
|
||||
const options = content.replace(/<SelectItem\s+([^>]*?)>([\s\S]*?)<\/SelectItem>/g, (itemMatch, itemProps, itemContent) => {
|
||||
const valueMatch = itemProps.match(/value="([^"]*?)"/);
|
||||
const value = valueMatch ? valueMatch[1] : '';
|
||||
return `<option value="${value}">${itemContent.trim()}</option>`;
|
||||
});
|
||||
|
||||
// className 추출
|
||||
const classMatch = triggerProps.match(/className="([^"]*?)"/);
|
||||
const triggerClass = classMatch ? classMatch[1] : '';
|
||||
|
||||
// value와 onValueChange 추출
|
||||
const valueMatch = selectProps.match(/value=\{([^}]*?)\}/);
|
||||
const onChangeMatch = selectProps.match(/onValueChange=\{([^}]*?)\}/);
|
||||
|
||||
const value = valueMatch ? valueMatch[1] : '';
|
||||
const onChange = onChangeMatch ? onChangeMatch[1] : '';
|
||||
|
||||
// onChange를 HTML select 형식으로 변환
|
||||
const htmlOnChange = onChange.replace(/\(([^)]*?)\)\s*=>\s*/, '(e) => ').replace(/value/g, 'e.target.value');
|
||||
|
||||
return `<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none ${triggerClass}"
|
||||
value={${value}}
|
||||
onChange={${htmlOnChange}}
|
||||
>
|
||||
${options}
|
||||
</select>`;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// 파일 처리 함수
|
||||
function processFile(filePath) {
|
||||
try {
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let modified = false;
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
if (pattern.from.test(content)) {
|
||||
content = content.replace(pattern.from, pattern.to);
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
console.log(`✅ 수정됨: ${filePath}`);
|
||||
} else {
|
||||
console.log(`⏭️ 변경사항 없음: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 오류 (${filePath}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리할 파일들
|
||||
const filesToProcess = [
|
||||
'frontend/components/screen/panels/PropertiesPanel.tsx',
|
||||
'frontend/components/screen/panels/DataTableConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/ButtonConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/FileComponentConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/ResolutionPanel.tsx',
|
||||
'frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/webtype-configs/DateTypeConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/webtype-configs/CheckboxTypeConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx',
|
||||
'frontend/components/screen/panels/webtype-configs/CodeTypeConfigPanel.tsx',
|
||||
];
|
||||
|
||||
console.log('🔄 Select 컴포넌트 교체 시작...');
|
||||
filesToProcess.forEach(processFile);
|
||||
console.log('✅ 완료!');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue