웹타입 컴포넌트 분리작업
This commit is contained in:
parent
540d82e7e4
commit
a17602c643
|
|
@ -234,7 +234,7 @@ model assembly_wbs_task {
|
||||||
}
|
}
|
||||||
|
|
||||||
model attach_file_info {
|
model attach_file_info {
|
||||||
objid Decimal @default(0) @db.Decimal
|
objid Decimal @id @default(0) @db.Decimal
|
||||||
target_objid String? @db.VarChar
|
target_objid String? @db.VarChar
|
||||||
saved_file_name String? @default("NULL::character varying") @db.VarChar(128)
|
saved_file_name String? @default("NULL::character varying") @db.VarChar(128)
|
||||||
real_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_size Decimal? @db.Decimal
|
||||||
file_ext String? @default("NULL::character varying") @db.VarChar(32)
|
file_ext String? @default("NULL::character varying") @db.VarChar(32)
|
||||||
file_path String? @default("NULL::character varying") @db.VarChar(512)
|
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)
|
writer String? @default("NULL::character varying") @db.VarChar(32)
|
||||||
regdate DateTime? @db.Timestamp(6)
|
regdate DateTime? @db.Timestamp(6)
|
||||||
status String? @default("NULL::character varying") @db.VarChar(32)
|
status String? @default("NULL::character varying") @db.VarChar(32)
|
||||||
parent_target_objid String? @db.VarChar
|
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([doc_type, objid], map: "attach_file_info_doc_type_idx")
|
||||||
@@index([target_objid])
|
@@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, doc_type], map: "attach_file_info_company_doc_type_idx")
|
||||||
@@index([company_code, target_objid], map: "attach_file_info_company_target_idx")
|
@@index([company_code, target_objid], map: "attach_file_info_company_target_idx")
|
||||||
@@id([objid])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model authority_master {
|
model authority_master {
|
||||||
|
|
@ -1478,23 +1477,22 @@ model material_release {
|
||||||
}
|
}
|
||||||
|
|
||||||
model menu_info {
|
model menu_info {
|
||||||
objid Decimal @id @default(0) @db.Decimal
|
objid Decimal @id @default(0) @db.Decimal
|
||||||
menu_type Decimal? @db.Decimal
|
menu_type Decimal? @db.Decimal
|
||||||
parent_obj_id Decimal? @db.Decimal
|
parent_obj_id Decimal? @db.Decimal
|
||||||
menu_name_kor String? @db.VarChar(64)
|
menu_name_kor String? @db.VarChar(64)
|
||||||
menu_name_eng String? @db.VarChar(64)
|
menu_name_eng String? @db.VarChar(64)
|
||||||
seq Decimal? @db.Decimal
|
seq Decimal? @db.Decimal
|
||||||
menu_url String? @db.VarChar(256)
|
menu_url String? @db.VarChar(256)
|
||||||
menu_desc String? @db.VarChar(1024)
|
menu_desc String? @db.VarChar(1024)
|
||||||
writer String? @db.VarChar(32)
|
writer String? @db.VarChar(32)
|
||||||
regdate DateTime? @db.Timestamp(6)
|
regdate DateTime? @db.Timestamp(6)
|
||||||
status String? @db.VarChar(32)
|
status String? @db.VarChar(32)
|
||||||
system_name String? @db.VarChar(32)
|
system_name String? @db.VarChar(32)
|
||||||
company_code String? @default("*") @db.VarChar(50)
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
lang_key String? @db.VarChar(100)
|
lang_key String? @db.VarChar(100)
|
||||||
lang_key_desc String? @db.VarChar(100)
|
lang_key_desc String? @db.VarChar(100)
|
||||||
company company_mng? @relation(fields: [company_code], references: [company_code])
|
company company_mng? @relation(fields: [company_code], references: [company_code])
|
||||||
screen_assignments screen_menu_assignments[]
|
|
||||||
|
|
||||||
@@index([parent_obj_id])
|
@@index([parent_obj_id])
|
||||||
@@index([company_code])
|
@@index([company_code])
|
||||||
|
|
@ -4994,21 +4992,20 @@ model screen_definitions {
|
||||||
table_name String @db.VarChar(100)
|
table_name String @db.VarChar(100)
|
||||||
company_code String @db.VarChar(50)
|
company_code String @db.VarChar(50)
|
||||||
description String?
|
description String?
|
||||||
is_active String @default("Y") @db.Char(1) // Y=활성, N=비활성, D=삭제됨(휴지통)
|
is_active String @default("Y") @db.Char(1)
|
||||||
layout_metadata Json?
|
layout_metadata Json?
|
||||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
updated_date DateTime @default(now()) @db.Timestamp(6)
|
updated_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
updated_by String? @db.VarChar(50)
|
updated_by String? @db.VarChar(50)
|
||||||
deleted_date DateTime? @db.Timestamp(6) // 삭제 일시 (휴지통 이동 시점)
|
deleted_date DateTime? @db.Timestamp(6)
|
||||||
deleted_by String? @db.VarChar(50) // 삭제한 사용자
|
deleted_by String? @db.VarChar(50)
|
||||||
delete_reason String? // 삭제 사유 (선택사항)
|
delete_reason String?
|
||||||
layouts screen_layouts[]
|
layouts screen_layouts[]
|
||||||
menu_assignments screen_menu_assignments[]
|
menu_assignments screen_menu_assignments[]
|
||||||
|
|
||||||
@@index([company_code])
|
@@index([company_code])
|
||||||
@@index([is_active, company_code])
|
@@index([is_active, company_code], map: "idx_screen_definitions_status")
|
||||||
@@index([deleted_date], map: "idx_screen_definitions_deleted")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model screen_layouts {
|
model screen_layouts {
|
||||||
|
|
@ -5072,7 +5069,6 @@ model screen_menu_assignments {
|
||||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
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])
|
@@unique([screen_id, menu_objid, company_code])
|
||||||
@@index([company_code])
|
@@index([company_code])
|
||||||
|
|
@ -5111,3 +5107,149 @@ model code_info {
|
||||||
@@id([code_category, code_value], map: "pk_code_info")
|
@@id([code_category, code_value], map: "pk_code_info")
|
||||||
@@index([code_category, sort_order], map: "idx_code_info_sort")
|
@@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 dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||||
import fileRoutes from "./routes/fileRoutes";
|
import fileRoutes from "./routes/fileRoutes";
|
||||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
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 userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -101,6 +104,9 @@ app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
app.use("/api/company-management", companyManagementRoutes);
|
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/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// 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 { Loader2 } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition, LayoutData } from "@/types/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 { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { QueryProvider } from "@/providers/QueryProvider";
|
import { QueryProvider } from "@/providers/QueryProvider";
|
||||||
|
import { RegistryProvider } from "./registry-provider";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|
@ -41,7 +42,7 @@ export default function RootLayout({
|
||||||
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
|
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
|
||||||
<div id="root" className="h-full">
|
<div id="root" className="h-full">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
{children}
|
<RegistryProvider>{children}</RegistryProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</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 { Table, Plus, Trash2, Settings, Filter, Columns, ChevronDown } from "lucide-react";
|
||||||
import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen";
|
import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen";
|
||||||
import { generateComponentId } from "@/lib/utils/generateId";
|
import { generateComponentId } from "@/lib/utils/generateId";
|
||||||
|
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||||
|
|
||||||
interface DataTableConfigPanelProps {
|
interface DataTableConfigPanelProps {
|
||||||
component: DataTableComponent;
|
component: DataTableComponent;
|
||||||
|
|
@ -22,25 +23,20 @@ interface DataTableConfigPanelProps {
|
||||||
onUpdateComponent: (updates: Partial<DataTableComponent>) => void;
|
onUpdateComponent: (updates: Partial<DataTableComponent>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webTypeOptions: { value: WebType; label: string }[] = [
|
const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
{ 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> = ({
|
|
||||||
component,
|
component,
|
||||||
tables,
|
tables,
|
||||||
activeTab: externalActiveTab,
|
activeTab: externalActiveTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
onUpdateComponent,
|
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);
|
const [selectedTable, setSelectedTable] = useState<TableInfo | null>(null);
|
||||||
|
|
||||||
// 로컬 입력 상태 (실시간 타이핑용)
|
// 로컬 입력 상태 (실시간 타이핑용)
|
||||||
|
|
@ -58,6 +54,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
deleteButtonText: component.deleteButtonText || "삭제",
|
deleteButtonText: component.deleteButtonText || "삭제",
|
||||||
// 모달 설정
|
// 모달 설정
|
||||||
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
||||||
|
// 테이블명도 로컬 상태로 관리
|
||||||
|
tableName: component.tableName || "",
|
||||||
modalDescription: component.addModalConfig?.description || "",
|
modalDescription: component.addModalConfig?.description || "",
|
||||||
modalWidth: component.addModalConfig?.width || "lg",
|
modalWidth: component.addModalConfig?.width || "lg",
|
||||||
modalLayout: component.addModalConfig?.layout || "two-column",
|
modalLayout: component.addModalConfig?.layout || "two-column",
|
||||||
|
|
@ -176,6 +174,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||||
showFirstLast: component.pagination?.showFirstLast ?? true,
|
showFirstLast: component.pagination?.showFirstLast ?? true,
|
||||||
gridColumns: component.gridColumns || 6,
|
gridColumns: component.gridColumns || 6,
|
||||||
|
// 테이블명 동기화
|
||||||
|
tableName: component.tableName || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컬럼 라벨 로컬 상태 초기화 (기존 값이 없는 경우만)
|
// 컬럼 라벨 로컬 상태 초기화 (기존 값이 없는 경우만)
|
||||||
|
|
@ -283,14 +283,12 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
component.id,
|
component.id,
|
||||||
component.title,
|
component.title,
|
||||||
component.searchButtonText,
|
component.searchButtonText,
|
||||||
component.columns,
|
|
||||||
component.filters,
|
|
||||||
component.showSearchButton,
|
component.showSearchButton,
|
||||||
component.enableExport,
|
component.enableExport,
|
||||||
component.enableRefresh,
|
component.enableRefresh,
|
||||||
component.pagination,
|
component.pagination,
|
||||||
component.columns.length, // 컬럼 개수 변경 감지
|
component.columns.length, // 컬럼 개수만 감지
|
||||||
component.filters.length, // 필터 개수 변경 감지
|
component.filters.length, // 필터 개수만 감지
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 선택된 테이블 정보 로드
|
// 선택된 테이블 정보 로드
|
||||||
|
|
@ -304,18 +302,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
// 테이블 변경 시 컬럼 자동 설정
|
// 테이블 변경 시 컬럼 자동 설정
|
||||||
const handleTableChange = useCallback(
|
const handleTableChange = useCallback(
|
||||||
(tableName: string) => {
|
(tableName: string) => {
|
||||||
|
// 이미 같은 테이블이 선택되어 있으면 무시
|
||||||
|
if (localValues.tableName === tableName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로컬 상태 먼저 업데이트
|
||||||
|
setLocalValues((prev) => ({ ...prev, tableName }));
|
||||||
|
|
||||||
const table = tables.find((t) => t.tableName === tableName);
|
const table = tables.find((t) => t.tableName === tableName);
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|
||||||
console.log("🔄 테이블 변경:", {
|
console.log("🔄 테이블 변경:", {
|
||||||
tableName,
|
tableName,
|
||||||
|
currentTableName: localValues.tableName,
|
||||||
table,
|
table,
|
||||||
columnsCount: table.columns.length,
|
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)),
|
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 필터는 사용자가 수동으로 추가
|
|
||||||
|
|
||||||
console.log("✅ 생성된 컬럼 설정:", {
|
console.log("✅ 생성된 컬럼 설정:", {
|
||||||
defaultColumnsCount: defaultColumns.length,
|
defaultColumnsCount: defaultColumns.length,
|
||||||
visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
onUpdateComponent({
|
// 상태 업데이트를 한 번에 처리
|
||||||
tableName,
|
setTimeout(() => {
|
||||||
columns: defaultColumns,
|
onUpdateComponent({
|
||||||
filters: [], // 빈 필터 배열
|
tableName,
|
||||||
});
|
columns: defaultColumns,
|
||||||
|
filters: [], // 빈 필터 배열
|
||||||
setSelectedTable(table);
|
});
|
||||||
|
setSelectedTable(table);
|
||||||
|
}, 0);
|
||||||
},
|
},
|
||||||
[tables, onUpdateComponent],
|
[tables, onUpdateComponent, localValues.tableName],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컬럼 타입 추론
|
// 컬럼 타입 추론
|
||||||
|
|
@ -556,38 +558,36 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
{webType === "radio" ? (
|
{webType === "radio" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">기본값 설정</Label>
|
<Label className="text-xs font-medium">기본값 설정</Label>
|
||||||
<Select
|
<select
|
||||||
value={localSettings.defaultValue || "__NONE__"}
|
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">
|
<option value="__NONE__">선택 안함</option>
|
||||||
<SelectValue placeholder="기본값 선택..." />
|
{(localSettings.options || []).map((option: any, index: number) => {
|
||||||
</SelectTrigger>
|
// 안전한 문자열 변환
|
||||||
<SelectContent>
|
const getStringValue = (val: any): string => {
|
||||||
<SelectItem value="__NONE__">선택 안함</SelectItem>
|
if (typeof val === "string") return val;
|
||||||
{(localSettings.options || []).map((option: any, index: number) => {
|
if (typeof val === "number") return String(val);
|
||||||
// 안전한 문자열 변환
|
if (typeof val === "object" && val !== null) {
|
||||||
const getStringValue = (val: any): string => {
|
return val.label || val.value || val.name || JSON.stringify(val);
|
||||||
if (typeof val === "string") return val;
|
}
|
||||||
if (typeof val === "number") return String(val);
|
return String(val || "");
|
||||||
if (typeof val === "object" && val !== null) {
|
};
|
||||||
return val.label || val.value || val.name || JSON.stringify(val);
|
|
||||||
}
|
|
||||||
return String(val || "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionValue = getStringValue(option.value || option.label || option) || `option-${index}`;
|
const optionValue = getStringValue(option.value || option.label || option) || `option-${index}`;
|
||||||
const optionLabel =
|
const optionLabel = getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
|
||||||
getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectItem key={index} value={optionValue}>
|
<option key={index} value={optionValue}>
|
||||||
{optionLabel}
|
{optionLabel}
|
||||||
</SelectItem>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SelectContent>
|
</select>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -943,8 +943,9 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
|
|
||||||
// 웹 타입별 필터 가능 여부 확인
|
// 웹 타입별 필터 가능 여부 확인
|
||||||
const isFilterableWebType = (webType: WebType): boolean => {
|
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">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="table-select">연결 테이블</Label>
|
<Label htmlFor="table-select">연결 테이블</Label>
|
||||||
<Select value={component.tableName} onValueChange={handleTableChange}>
|
<select
|
||||||
<SelectTrigger>
|
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"
|
||||||
<SelectValue placeholder="테이블을 선택하세요" />
|
value={localValues.tableName}
|
||||||
</SelectTrigger>
|
onChange={(e) => handleTableChange(e.target.value)}
|
||||||
<SelectContent>
|
>
|
||||||
{tables.map((table) => (
|
<option value="">테이블을 선택하세요</option>
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
{tables.map((table) => (
|
||||||
{table.tableLabel || table.tableName}
|
<option key={table.tableName} value={table.tableName}>
|
||||||
</SelectItem>
|
{table.tableLabel || table.tableName}
|
||||||
))}
|
</option>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -1314,27 +1315,24 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
<Label htmlFor="modal-width" className="text-sm">
|
<Label htmlFor="modal-width" className="text-sm">
|
||||||
모달 크기
|
모달 크기
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<select
|
||||||
value={localValues.modalWidth}
|
value={localValues.modalWidth}
|
||||||
onValueChange={(value) => {
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
setLocalValues((prev) => ({ ...prev, modalWidth: value as any }));
|
setLocalValues((prev) => ({ ...prev, modalWidth: value as any }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
addModalConfig: { ...component.addModalConfig, width: value as any },
|
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">
|
<option value="sm">작음 (384px)</option>
|
||||||
<SelectValue />
|
<option value="md">보통 (448px)</option>
|
||||||
</SelectTrigger>
|
<option value="lg">큼 (512px)</option>
|
||||||
<SelectContent>
|
<option value="xl">매우 큼 (576px)</option>
|
||||||
<SelectItem value="sm">작음 (384px)</SelectItem>
|
<option value="2xl">특대 (672px)</option>
|
||||||
<SelectItem value="md">보통 (448px)</SelectItem>
|
<option value="full">전체 너비</option>
|
||||||
<SelectItem value="lg">큼 (512px)</SelectItem>
|
</select>
|
||||||
<SelectItem value="xl">매우 큼 (576px)</SelectItem>
|
|
||||||
<SelectItem value="2xl">특대 (672px)</SelectItem>
|
|
||||||
<SelectItem value="full">전체 너비</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1362,24 +1360,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
<Label htmlFor="modal-layout" className="text-sm">
|
<Label htmlFor="modal-layout" className="text-sm">
|
||||||
레이아웃
|
레이아웃
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<select
|
||||||
value={localValues.modalLayout}
|
value={localValues.modalLayout}
|
||||||
onValueChange={(value) => {
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
setLocalValues((prev) => ({ ...prev, modalLayout: value as any }));
|
setLocalValues((prev) => ({ ...prev, modalLayout: value as any }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
addModalConfig: { ...component.addModalConfig, layout: value as any },
|
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">
|
<option value="single">단일 컬럼</option>
|
||||||
<SelectValue />
|
<option value="two-column">2컬럼</option>
|
||||||
</SelectTrigger>
|
<option value="grid">그리드</option>
|
||||||
<SelectContent>
|
</select>
|
||||||
<SelectItem value="single">단일 컬럼</SelectItem>
|
|
||||||
<SelectItem value="two-column">2컬럼</SelectItem>
|
|
||||||
<SelectItem value="grid">그리드</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{localValues.modalLayout === "grid" && (
|
{localValues.modalLayout === "grid" && (
|
||||||
|
|
@ -1387,25 +1382,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
<Label htmlFor="modal-grid-columns" className="text-sm">
|
<Label htmlFor="modal-grid-columns" className="text-sm">
|
||||||
그리드 컬럼 수
|
그리드 컬럼 수
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<select
|
||||||
value={localValues.modalGridColumns.toString()}
|
value={localValues.modalGridColumns.toString()}
|
||||||
onValueChange={(value) => {
|
onChange={(e) => {
|
||||||
const gridColumns = parseInt(value);
|
const gridColumns = parseInt(e.target.value);
|
||||||
setLocalValues((prev) => ({ ...prev, modalGridColumns: gridColumns }));
|
setLocalValues((prev) => ({ ...prev, modalGridColumns: gridColumns }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
addModalConfig: { ...component.addModalConfig, gridColumns },
|
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">
|
<option value="2">2컬럼</option>
|
||||||
<SelectValue />
|
<option value="3">3컬럼</option>
|
||||||
</SelectTrigger>
|
<option value="4">4컬럼</option>
|
||||||
<SelectContent>
|
</select>
|
||||||
<SelectItem value="2">2컬럼</SelectItem>
|
|
||||||
<SelectItem value="3">3컬럼</SelectItem>
|
|
||||||
<SelectItem value="4">4컬럼</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1454,26 +1445,23 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="grid-columns">그리드 컬럼 수</Label>
|
<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()}
|
value={localValues.gridColumns.toString()}
|
||||||
onValueChange={(value) => {
|
onChange={(e) => {
|
||||||
const gridColumns = parseInt(value, 10);
|
const gridColumns = parseInt(e.target.value, 10);
|
||||||
console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
|
console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
|
||||||
setLocalValues((prev) => ({ ...prev, gridColumns }));
|
setLocalValues((prev) => ({ ...prev, gridColumns }));
|
||||||
onUpdateComponent({ gridColumns });
|
onUpdateComponent({ gridColumns });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<option value="">그리드 컬럼 수 선택</option>
|
||||||
<SelectValue placeholder="그리드 컬럼 수 선택" />
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => (
|
||||||
</SelectTrigger>
|
<option key={num} value={num.toString()}>
|
||||||
<SelectContent>
|
{num}컬럼 ({Math.round((num / 12) * 100)}%)
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => (
|
</option>
|
||||||
<SelectItem key={num} value={num.toString()}>
|
))}
|
||||||
{num}컬럼 ({Math.round((num / 12) * 100)}%)
|
</select>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -1541,18 +1529,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
return availableColumns.length > 0 ? (
|
return availableColumns.length > 0 ? (
|
||||||
<Select onValueChange={(value) => addColumn(value)}>
|
<select
|
||||||
<SelectTrigger className="h-8 w-32 text-xs">
|
onChange={(e) => {
|
||||||
<SelectValue placeholder="DB 컬럼" />
|
if (e.target.value) {
|
||||||
</SelectTrigger>
|
addColumn(e.target.value);
|
||||||
<SelectContent>
|
e.target.value = ""; // 선택 후 초기화
|
||||||
{availableColumns.map((col) => (
|
}
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
}}
|
||||||
{col.columnLabel || col.columnName}
|
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"
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
<option value="">DB 컬럼</option>
|
||||||
</SelectContent>
|
{availableColumns.map((col) => (
|
||||||
</Select>
|
<option key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" disabled>
|
<Button size="sm" disabled>
|
||||||
<Plus className="h-4 w-4" />
|
<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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>페이지당 행 수</Label>
|
<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()}
|
value={component.pagination.pageSize.toString()}
|
||||||
onValueChange={(value) =>
|
onChange={(e) =>
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
pagination: {
|
pagination: {
|
||||||
...component.pagination,
|
...component.pagination,
|
||||||
pageSize: parseInt(value),
|
pageSize: parseInt(e.target.value),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
{[5, 10, 20, 50, 100].map((size) => (
|
||||||
<SelectValue />
|
<option key={size} value={size.toString()}>
|
||||||
</SelectTrigger>
|
{size}개
|
||||||
<SelectContent>
|
</option>
|
||||||
{[5, 10, 20, 50, 100].map((size) => (
|
))}
|
||||||
<SelectItem key={size} value={size.toString()}>
|
</select>
|
||||||
{size}개
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</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;
|
export default DataTableConfigPanel;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||||
import {
|
import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
|
|
@ -30,6 +31,7 @@ import { CheckboxTypeConfigPanel } from "./webtype-configs/CheckboxTypeConfigPan
|
||||||
import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
|
import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
|
||||||
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
|
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
|
||||||
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
|
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
|
||||||
|
import { RatingTypeConfigPanel, RatingTypeConfig } from "./webtype-configs/RatingTypeConfigPanel";
|
||||||
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
|
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
|
||||||
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
||||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||||
|
|
@ -47,24 +49,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
currentTable,
|
currentTable,
|
||||||
currentTableName,
|
currentTableName,
|
||||||
}) => {
|
}) => {
|
||||||
// 입력 가능한 웹타입들 정의
|
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||||
const inputableWebTypes = [
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
"text",
|
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
|
||||||
"number",
|
|
||||||
"decimal",
|
|
||||||
"date",
|
|
||||||
"datetime",
|
|
||||||
"select",
|
|
||||||
"dropdown",
|
|
||||||
"textarea",
|
|
||||||
"email",
|
|
||||||
"tel",
|
|
||||||
"code",
|
|
||||||
"entity",
|
|
||||||
"file",
|
|
||||||
"checkbox",
|
|
||||||
"radio",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 웹타입별 상세 설정 렌더링 함수
|
// 웹타입별 상세 설정 렌더링 함수
|
||||||
const renderWebTypeConfig = React.useCallback(
|
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":
|
case "entity":
|
||||||
return (
|
return (
|
||||||
<EntityTypeConfigPanel
|
<EntityTypeConfigPanel
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // 임시 비활성화
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
// import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
|
|
@ -20,6 +20,77 @@ import {
|
||||||
TableInfo,
|
TableInfo,
|
||||||
} from "@/types/screen";
|
} from "@/types/screen";
|
||||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
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 {
|
interface PropertiesPanelProps {
|
||||||
selectedComponent?: ComponentData;
|
selectedComponent?: ComponentData;
|
||||||
|
|
@ -33,27 +104,9 @@ interface PropertiesPanelProps {
|
||||||
canUngroup?: boolean;
|
canUngroup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webTypeOptions: { value: WebType; label: string }[] = [
|
// 동적 웹타입 옵션은 컴포넌트 내부에서 useWebTypes 훅으로 가져옵니다
|
||||||
{ 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: "버튼" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
tables = [],
|
tables = [],
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
|
|
@ -64,6 +117,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
canGroup = false,
|
canGroup = false,
|
||||||
canUngroup = 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");
|
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,
|
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
|
||||||
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
|
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
|
||||||
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
|
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
|
||||||
|
// widgetType도 로컬 상태로 관리
|
||||||
|
widgetType:
|
||||||
|
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -136,14 +201,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
required: widget?.required || false,
|
required: widget?.required || false,
|
||||||
readonly: widget?.readonly || false,
|
readonly: widget?.readonly || false,
|
||||||
labelDisplay: selectedComponent.style?.labelDisplay !== false,
|
labelDisplay: selectedComponent.style?.labelDisplay !== false,
|
||||||
|
// widgetType 동기화
|
||||||
|
widgetType: widget?.widgetType || "text",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
selectedComponent,
|
selectedComponent?.id, // ID만 감지하여 컴포넌트 변경 시에만 업데이트
|
||||||
selectedComponent?.position,
|
|
||||||
selectedComponent?.size,
|
|
||||||
selectedComponent?.style,
|
|
||||||
selectedComponent?.label,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!selectedComponent) {
|
if (!selectedComponent) {
|
||||||
|
|
@ -187,30 +250,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
|
|
||||||
{/* 데이터 테이블 설정 패널 */}
|
{/* 데이터 테이블 설정 패널 */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<DataTableConfigPanel
|
<DataTableConfigPanelWrapper
|
||||||
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))}`}
|
selectedComponent={selectedComponent as DataTableComponent}
|
||||||
component={selectedComponent as DataTableComponent}
|
|
||||||
tables={tables}
|
tables={tables}
|
||||||
activeTab={dataTableActiveTab}
|
activeTab={dataTableActiveTab}
|
||||||
onTabChange={setDataTableActiveTab}
|
onTabChange={setDataTableActiveTab}
|
||||||
onUpdateComponent={(updates) => {
|
onUpdateProperty={onUpdateProperty}
|
||||||
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 컴포넌트 업데이트 완료");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,23 +334,56 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
<Label htmlFor="widgetType" className="text-sm font-medium">
|
<Label htmlFor="widgetType" className="text-sm font-medium">
|
||||||
위젯 타입
|
위젯 타입
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<select
|
||||||
value={selectedComponent.widgetType || "text"}
|
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"
|
||||||
onValueChange={(value) => onUpdateProperty("widgetType", value)}
|
value={localInputs.widgetType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value as WebType;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, widgetType: value }));
|
||||||
|
onUpdateProperty("widgetType", value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
{webTypeOptions.map((option) => (
|
||||||
<SelectValue />
|
<option key={option.value} value={option.value}>
|
||||||
</SelectTrigger>
|
{option.label}
|
||||||
<SelectContent>
|
</option>
|
||||||
{webTypeOptions.map((option) => (
|
))}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
</select>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label htmlFor="placeholder" className="text-sm font-medium">
|
<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-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<input
|
||||||
|
type="checkbox"
|
||||||
id="required"
|
id="required"
|
||||||
checked={localInputs.required}
|
checked={localInputs.required}
|
||||||
onCheckedChange={(checked) => {
|
onChange={(e) => {
|
||||||
setLocalInputs((prev) => ({ ...prev, required: !!checked }));
|
setLocalInputs((prev) => ({ ...prev, required: e.target.checked }));
|
||||||
onUpdateProperty("required", 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">
|
<Label htmlFor="required" className="text-sm">
|
||||||
필수 입력
|
필수 입력
|
||||||
|
|
@ -340,13 +420,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<input
|
||||||
|
type="checkbox"
|
||||||
id="readonly"
|
id="readonly"
|
||||||
checked={localInputs.readonly}
|
checked={localInputs.readonly}
|
||||||
onCheckedChange={(checked) => {
|
onChange={(e) => {
|
||||||
setLocalInputs((prev) => ({ ...prev, readonly: !!checked }));
|
setLocalInputs((prev) => ({ ...prev, readonly: e.target.checked }));
|
||||||
onUpdateProperty("readonly", 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">
|
<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 htmlFor="labelDisplay" className="text-sm font-medium">
|
||||||
라벨 표시
|
라벨 표시
|
||||||
</Label>
|
</Label>
|
||||||
<Checkbox
|
<input
|
||||||
|
type="checkbox"
|
||||||
id="labelDisplay"
|
id="labelDisplay"
|
||||||
checked={localInputs.labelDisplay}
|
checked={localInputs.labelDisplay}
|
||||||
onCheckedChange={(checked) => {
|
onChange={(e) => {
|
||||||
console.log("🔄 라벨 표시 변경:", checked);
|
console.log("🔄 라벨 표시 변경:", e.target.checked);
|
||||||
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
|
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
|
||||||
onUpdateProperty("style.labelDisplay", 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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -570,46 +654,38 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
|
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
|
||||||
폰트 굵기
|
폰트 굵기
|
||||||
</Label>
|
</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"}
|
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||||
onValueChange={(value) => onUpdateProperty("style.labelFontWeight", value)}
|
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<option value="normal">Normal</option>
|
||||||
<SelectValue />
|
<option value="bold">Bold</option>
|
||||||
</SelectTrigger>
|
<option value="100">100</option>
|
||||||
<SelectContent>
|
<option value="200">200</option>
|
||||||
<SelectItem value="normal">Normal</SelectItem>
|
<option value="300">300</option>
|
||||||
<SelectItem value="bold">Bold</SelectItem>
|
<option value="400">400</option>
|
||||||
<SelectItem value="100">100</SelectItem>
|
<option value="500">500</option>
|
||||||
<SelectItem value="200">200</SelectItem>
|
<option value="600">600</option>
|
||||||
<SelectItem value="300">300</SelectItem>
|
<option value="700">700</option>
|
||||||
<SelectItem value="400">400</SelectItem>
|
<option value="800">800</option>
|
||||||
<SelectItem value="500">500</SelectItem>
|
<option value="900">900</option>
|
||||||
<SelectItem value="600">600</SelectItem>
|
</select>
|
||||||
<SelectItem value="700">700</SelectItem>
|
|
||||||
<SelectItem value="800">800</SelectItem>
|
|
||||||
<SelectItem value="900">900</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
|
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
|
||||||
텍스트 정렬
|
텍스트 정렬
|
||||||
</Label>
|
</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"}
|
value={selectedComponent.style?.labelTextAlign || "left"}
|
||||||
onValueChange={(value) => onUpdateProperty("style.labelTextAlign", value)}
|
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<option value="left">왼쪽</option>
|
||||||
<SelectValue />
|
<option value="center">가운데</option>
|
||||||
</SelectTrigger>
|
<option value="right">오른쪽</option>
|
||||||
<SelectContent>
|
</select>
|
||||||
<SelectItem value="left">왼쪽</SelectItem>
|
|
||||||
<SelectItem value="center">가운데</SelectItem>
|
|
||||||
<SelectItem value="right">오른쪽</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -712,27 +788,23 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
<Label htmlFor="layoutType" className="text-sm font-medium">
|
<Label htmlFor="layoutType" className="text-sm font-medium">
|
||||||
레이아웃 타입
|
레이아웃 타입
|
||||||
</Label>
|
</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}
|
value={(selectedComponent as AreaComponent).layoutType}
|
||||||
onValueChange={(value: AreaLayoutType) => onUpdateProperty("layoutType", value)}
|
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<option value="box">기본 박스</option>
|
||||||
<SelectValue />
|
<option value="card">카드</option>
|
||||||
</SelectTrigger>
|
<option value="panel">패널 (헤더 포함)</option>
|
||||||
<SelectContent>
|
<option value="section">섹션</option>
|
||||||
<SelectItem value="box">기본 박스</SelectItem>
|
<option value="grid">그리드</option>
|
||||||
<SelectItem value="card">카드</SelectItem>
|
<option value="flex-row">가로 플렉스</option>
|
||||||
<SelectItem value="panel">패널 (헤더 포함)</SelectItem>
|
<option value="flex-column">세로 플렉스</option>
|
||||||
<SelectItem value="section">섹션</SelectItem>
|
<option value="sidebar">사이드바</option>
|
||||||
<SelectItem value="grid">그리드</SelectItem>
|
<option value="header-content">헤더-컨텐츠</option>
|
||||||
<SelectItem value="flex-row">가로 플렉스</SelectItem>
|
<option value="tabs">탭</option>
|
||||||
<SelectItem value="flex-column">세로 플렉스</SelectItem>
|
<option value="accordion">아코디언</option>
|
||||||
<SelectItem value="sidebar">사이드바</SelectItem>
|
</select>
|
||||||
<SelectItem value="header-content">헤더-컨텐츠</SelectItem>
|
|
||||||
<SelectItem value="tabs">탭</SelectItem>
|
|
||||||
<SelectItem value="accordion">아코디언</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 레이아웃별 상세 설정 */}
|
{/* 레이아웃별 상세 설정 */}
|
||||||
|
|
@ -777,22 +849,18 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
<h5 className="text-sm font-medium">플렉스 설정</h5>
|
<h5 className="text-sm font-medium">플렉스 설정</h5>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">정렬 방식</Label>
|
<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"}
|
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">
|
<option value="flex-start">시작</option>
|
||||||
<SelectValue />
|
<option value="flex-end">끝</option>
|
||||||
</SelectTrigger>
|
<option value="center">가운데</option>
|
||||||
<SelectContent>
|
<option value="space-between">양끝 정렬</option>
|
||||||
<SelectItem value="flex-start">시작</SelectItem>
|
<option value="space-around">균등 분배</option>
|
||||||
<SelectItem value="flex-end">끝</SelectItem>
|
<option value="space-evenly">균등 간격</option>
|
||||||
<SelectItem value="center">가운데</SelectItem>
|
</select>
|
||||||
<SelectItem value="space-between">양끝 정렬</SelectItem>
|
|
||||||
<SelectItem value="space-around">균등 분배</SelectItem>
|
|
||||||
<SelectItem value="space-evenly">균등 간격</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">간격 (px)</Label>
|
<Label className="text-xs">간격 (px)</Label>
|
||||||
|
|
@ -815,18 +883,14 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
<h5 className="text-sm font-medium">사이드바 설정</h5>
|
<h5 className="text-sm font-medium">사이드바 설정</h5>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">사이드바 위치</Label>
|
<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"}
|
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">
|
<option value="left">왼쪽</option>
|
||||||
<SelectValue />
|
<option value="right">오른쪽</option>
|
||||||
</SelectTrigger>
|
</select>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="left">왼쪽</SelectItem>
|
|
||||||
<SelectItem value="right">오른쪽</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">사이드바 너비 (px)</Label>
|
<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;
|
export default PropertiesPanel;
|
||||||
|
|
|
||||||
|
|
@ -149,16 +149,16 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
||||||
<Label htmlFor="layout" className="text-sm font-medium">
|
<Label htmlFor="layout" className="text-sm font-medium">
|
||||||
레이아웃 방향
|
레이아웃 방향
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={localValues.layout} onValueChange={(value) => updateConfig("layout", value)}>
|
<select
|
||||||
<SelectTrigger className="mt-1">
|
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"
|
||||||
<SelectValue placeholder="레이아웃 선택" />
|
value={localValues.layout}
|
||||||
</SelectTrigger>
|
onChange={(e) => updateConfig("layout", e.target.value)}
|
||||||
<SelectContent>
|
>
|
||||||
<SelectItem value="vertical">세로</SelectItem>
|
<option value="">레이아웃 선택</option>
|
||||||
<SelectItem value="horizontal">가로</SelectItem>
|
<option value="vertical">세로</option>
|
||||||
<SelectItem value="grid">격자 (2열)</SelectItem>
|
<option value="horizontal">가로</option>
|
||||||
</SelectContent>
|
<option value="grid">격자 (2열)</option>
|
||||||
</Select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기본값 */}
|
{/* 기본값 */}
|
||||||
|
|
@ -166,22 +166,18 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
||||||
<Label htmlFor="defaultValue" className="text-sm font-medium">
|
<Label htmlFor="defaultValue" className="text-sm font-medium">
|
||||||
기본 선택값
|
기본 선택값
|
||||||
</Label>
|
</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__"}
|
value={localValues.defaultValue || "__none__"}
|
||||||
onValueChange={(value) => updateConfig("defaultValue", value)}
|
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<option value="__none__">선택 안함</option>
|
||||||
<SelectValue placeholder="기본값 선택" />
|
{(safeConfig.options || []).map((option) => (
|
||||||
</SelectTrigger>
|
<option key={option.value} value={option.value}>
|
||||||
<SelectContent>
|
{option.label}
|
||||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
</option>
|
||||||
{(safeConfig.options || []).map((option) => (
|
))}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
</select>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</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 { useRouter } from "next/navigation";
|
||||||
import { apiCall, API_BASE_URL } from "@/lib/api/client";
|
import { apiCall, API_BASE_URL } from "@/lib/api/client";
|
||||||
|
|
||||||
|
|
@ -98,6 +98,7 @@ export const useAuth = () => {
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
// API 기본 URL 설정 (동적으로 결정)
|
// API 기본 URL 설정 (동적으로 결정)
|
||||||
|
|
||||||
|
|
@ -443,6 +444,13 @@ export const useAuth = () => {
|
||||||
* 초기 인증 상태 확인
|
* 초기 인증 상태 확인
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 이미 초기화되었으면 실행하지 않음
|
||||||
|
if (initializedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
console.log("=== useAuth 초기 인증 상태 확인 ===");
|
console.log("=== useAuth 초기 인증 상태 확인 ===");
|
||||||
console.log("현재 경로:", window.location.pathname);
|
console.log("현재 경로:", window.location.pathname);
|
||||||
|
|
||||||
|
|
@ -479,7 +487,7 @@ export const useAuth = () => {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}, 3000);
|
}, 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