웹타입 컴포넌트 분리작업

This commit is contained in:
kjs 2025-09-09 14:29:04 +09:00
parent 540d82e7e4
commit a17602c643
76 changed files with 16660 additions and 1735 deletions

View File

@ -234,7 +234,7 @@ model assembly_wbs_task {
}
model attach_file_info {
objid Decimal @default(0) @db.Decimal
objid Decimal @id @default(0) @db.Decimal
target_objid String? @db.VarChar
saved_file_name String? @default("NULL::character varying") @db.VarChar(128)
real_file_name String? @default("NULL::character varying") @db.VarChar(128)
@ -243,18 +243,17 @@ model attach_file_info {
file_size Decimal? @db.Decimal
file_ext String? @default("NULL::character varying") @db.VarChar(32)
file_path String? @default("NULL::character varying") @db.VarChar(512)
company_code String? @default("default") @db.VarChar(32)
writer String? @default("NULL::character varying") @db.VarChar(32)
regdate DateTime? @db.Timestamp(6)
status String? @default("NULL::character varying") @db.VarChar(32)
parent_target_objid String? @db.VarChar
company_code String? @default("default") @db.VarChar(32)
@@index([doc_type, objid], map: "attach_file_info_doc_type_idx")
@@index([target_objid])
@@index([company_code], map: "attach_file_info_company_code_idx")
@@index([company_code])
@@index([company_code, doc_type], map: "attach_file_info_company_doc_type_idx")
@@index([company_code, target_objid], map: "attach_file_info_company_target_idx")
@@id([objid])
}
model authority_master {
@ -1478,23 +1477,22 @@ model material_release {
}
model menu_info {
objid Decimal @id @default(0) @db.Decimal
menu_type Decimal? @db.Decimal
parent_obj_id Decimal? @db.Decimal
menu_name_kor String? @db.VarChar(64)
menu_name_eng String? @db.VarChar(64)
seq Decimal? @db.Decimal
menu_url String? @db.VarChar(256)
menu_desc String? @db.VarChar(1024)
writer String? @db.VarChar(32)
regdate DateTime? @db.Timestamp(6)
status String? @db.VarChar(32)
system_name String? @db.VarChar(32)
company_code String? @default("*") @db.VarChar(50)
lang_key String? @db.VarChar(100)
lang_key_desc String? @db.VarChar(100)
company company_mng? @relation(fields: [company_code], references: [company_code])
screen_assignments screen_menu_assignments[]
objid Decimal @id @default(0) @db.Decimal
menu_type Decimal? @db.Decimal
parent_obj_id Decimal? @db.Decimal
menu_name_kor String? @db.VarChar(64)
menu_name_eng String? @db.VarChar(64)
seq Decimal? @db.Decimal
menu_url String? @db.VarChar(256)
menu_desc String? @db.VarChar(1024)
writer String? @db.VarChar(32)
regdate DateTime? @db.Timestamp(6)
status String? @db.VarChar(32)
system_name String? @db.VarChar(32)
company_code String? @default("*") @db.VarChar(50)
lang_key String? @db.VarChar(100)
lang_key_desc String? @db.VarChar(100)
company company_mng? @relation(fields: [company_code], references: [company_code])
@@index([parent_obj_id])
@@index([company_code])
@ -4994,21 +4992,20 @@ model screen_definitions {
table_name String @db.VarChar(100)
company_code String @db.VarChar(50)
description String?
is_active String @default("Y") @db.Char(1) // Y=활성, N=비활성, D=삭제됨(휴지통)
is_active String @default("Y") @db.Char(1)
layout_metadata Json?
created_date DateTime @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
deleted_date DateTime? @db.Timestamp(6) // 삭제 일시 (휴지통 이동 시점)
deleted_by String? @db.VarChar(50) // 삭제한 사용자
delete_reason String? // 삭제 사유 (선택사항)
deleted_date DateTime? @db.Timestamp(6)
deleted_by String? @db.VarChar(50)
delete_reason String?
layouts screen_layouts[]
menu_assignments screen_menu_assignments[]
@@index([company_code])
@@index([is_active, company_code])
@@index([deleted_date], map: "idx_screen_definitions_deleted")
@@index([is_active, company_code], map: "idx_screen_definitions_status")
}
model screen_layouts {
@ -5072,7 +5069,6 @@ model screen_menu_assignments {
created_date DateTime @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
menu_info menu_info @relation(fields: [menu_objid], references: [objid])
@@unique([screen_id, menu_objid, company_code])
@@index([company_code])
@ -5111,3 +5107,149 @@ model code_info {
@@id([code_category, code_value], map: "pk_code_info")
@@index([code_category, sort_order], map: "idx_code_info_sort")
}
model web_type_standards {
web_type String @id @db.VarChar(50)
type_name String @db.VarChar(100)
type_name_eng String? @db.VarChar(100)
description String?
category String? @default("input") @db.VarChar(50)
component_name String? @default("TextWidget") @db.VarChar(100)
default_config Json?
validation_rules Json?
default_style Json?
input_properties Json?
sort_order Int? @default(0)
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
@@index([is_active], map: "idx_web_type_standards_active")
@@index([category], map: "idx_web_type_standards_category")
@@index([sort_order], map: "idx_web_type_standards_sort")
}
model style_templates {
template_id Int @id @default(autoincrement())
template_name String @db.VarChar(100)
template_name_eng String? @db.VarChar(100)
template_type String @db.VarChar(50)
category String? @db.VarChar(50)
style_config Json
preview_config Json?
company_code String? @default("*") @db.VarChar(50)
is_default Boolean? @default(false)
is_public Boolean? @default(true)
sort_order Int? @default(0)
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
@@index([is_active], map: "idx_style_templates_active")
@@index([category], map: "idx_style_templates_category")
@@index([company_code], map: "idx_style_templates_company")
@@index([template_type], map: "idx_style_templates_type")
}
model button_action_standards {
action_type String @id @db.VarChar(50)
action_name String @db.VarChar(100)
action_name_eng String? @db.VarChar(100)
description String?
category String? @default("general") @db.VarChar(50)
default_text String? @db.VarChar(100)
default_text_eng String? @db.VarChar(100)
default_icon String? @db.VarChar(50)
default_color String? @db.VarChar(50)
default_variant String? @db.VarChar(50)
confirmation_required Boolean? @default(false)
confirmation_message String?
validation_rules Json?
action_config Json?
sort_order Int? @default(0)
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
@@index([is_active], map: "idx_button_action_standards_active")
@@index([category], map: "idx_button_action_standards_category")
@@index([sort_order], map: "idx_button_action_standards_sort")
}
model grid_standards {
grid_id Int @id @default(autoincrement())
grid_name String @db.VarChar(100)
grid_name_eng String? @db.VarChar(100)
description String?
grid_size Int
grid_color String? @default("#e5e7eb") @db.VarChar(50)
grid_opacity Decimal? @default(0.5) @db.Decimal(3, 2)
snap_enabled Boolean? @default(true)
snap_threshold Int? @default(5)
grid_config Json?
company_code String? @default("*") @db.VarChar(50)
is_default Boolean? @default(false)
sort_order Int? @default(0)
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
@@index([is_active], map: "idx_grid_standards_active")
@@index([company_code], map: "idx_grid_standards_company")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model data_relationship_bridge {
bridge_id Int @id @default(autoincrement())
relationship_id Int?
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
from_key_value String? @db.VarChar(500)
from_record_id String? @db.VarChar(100)
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
to_key_value String? @db.VarChar(500)
to_record_id String? @db.VarChar(100)
connection_type String @db.VarChar(20)
company_code String @db.VarChar(50)
created_at DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_at DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
is_active String? @default("Y") @db.Char(1)
bridge_data Json?
table_relationships table_relationships? @relation(fields: [relationship_id], references: [relationship_id], onDelete: NoAction, onUpdate: NoAction)
@@index([company_code, is_active], map: "idx_data_bridge_company_active")
@@index([connection_type], map: "idx_data_bridge_connection_type")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model table_relationships {
relationship_id Int @id @default(autoincrement())
relationship_name String @db.VarChar(200)
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
relationship_type String @db.VarChar(20)
connection_type String @db.VarChar(20)
company_code String @db.VarChar(50)
settings Json?
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
data_relationship_bridge data_relationship_bridge[]
@@index([to_table_name], map: "idx_table_relationships_to_table")
}

View File

@ -19,6 +19,9 @@ import commonCodeRoutes from "./routes/commonCodeRoutes";
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
import fileRoutes from "./routes/fileRoutes";
import companyManagementRoutes from "./routes/companyManagementRoutes";
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@ -101,6 +104,9 @@ app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
app.use("/api/company-management", companyManagementRoutes);
app.use("/api/admin/web-types", webTypeStandardRoutes);
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/screen", screenStandardRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@ -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 : "알 수 없는 오류",
});
}
}
}

View File

@ -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 : "알 수 없는 오류",
});
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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

34
fix-selects.sh Normal file
View File

@ -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 "✅ 완료!"

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen";
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer";
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

View File

@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider";
const inter = Inter({
subsets: ["latin"],
@ -41,7 +42,7 @@ export default function RootLayout({
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
<div id="root" className="h-full">
<QueryProvider>
{children}
<RegistryProvider>{children}</RegistryProvider>
</QueryProvider>
</div>
</body>

View File

@ -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 };
}

View File

@ -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

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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>
);
};

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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="한 줄당 하나씩 입력하세요.&#10;라벨만 입력하면 값과 동일하게 설정됩니다.&#10;라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다.&#10;&#10;예시:&#10;서울&#10;부산&#10;대구시|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";

View File

@ -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="한 줄당 하나씩 입력하세요.&#10;라벨만 입력하면 값과 동일하게 설정됩니다.&#10;라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다.&#10;&#10;예시:&#10;서울&#10;부산&#10;대구시|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";

View File

@ -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";

View File

@ -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";

View File

@ -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];

View File

@ -13,6 +13,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Table, Plus, Trash2, Settings, Filter, Columns, ChevronDown } from "lucide-react";
import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
interface DataTableConfigPanelProps {
component: DataTableComponent;
@ -22,25 +23,20 @@ interface DataTableConfigPanelProps {
onUpdateComponent: (updates: Partial<DataTableComponent>) => void;
}
const webTypeOptions: { value: WebType; label: string }[] = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "decimal", label: "소수" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택박스" },
{ value: "checkbox", label: "체크박스" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
];
export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
component,
tables,
activeTab: externalActiveTab,
onTabChange,
onUpdateComponent,
}) => {
// 동적 웹타입 옵션 가져오기
const { webTypes } = useWebTypes({ active: "Y" });
const webTypeOptions = webTypes.map((wt) => ({
value: wt.web_type as WebType,
label: wt.type_name,
}));
const [selectedTable, setSelectedTable] = useState<TableInfo | null>(null);
// 로컬 입력 상태 (실시간 타이핑용)
@ -58,6 +54,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
deleteButtonText: component.deleteButtonText || "삭제",
// 모달 설정
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
// 테이블명도 로컬 상태로 관리
tableName: component.tableName || "",
modalDescription: component.addModalConfig?.description || "",
modalWidth: component.addModalConfig?.width || "lg",
modalLayout: component.addModalConfig?.layout || "two-column",
@ -176,6 +174,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
showPageInfo: component.pagination?.showPageInfo ?? true,
showFirstLast: component.pagination?.showFirstLast ?? true,
gridColumns: component.gridColumns || 6,
// 테이블명 동기화
tableName: component.tableName || "",
});
// 컬럼 라벨 로컬 상태 초기화 (기존 값이 없는 경우만)
@ -283,14 +283,12 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
component.id,
component.title,
component.searchButtonText,
component.columns,
component.filters,
component.showSearchButton,
component.enableExport,
component.enableRefresh,
component.pagination,
component.columns.length, // 컬럼 개수 변경 감지
component.filters.length, // 필터 개수 변경 감지
component.columns.length, // 컬럼 개수 감지
component.filters.length, // 필터 개수 감지
]);
// 선택된 테이블 정보 로드
@ -304,18 +302,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
// 테이블 변경 시 컬럼 자동 설정
const handleTableChange = useCallback(
(tableName: string) => {
// 이미 같은 테이블이 선택되어 있으면 무시
if (localValues.tableName === tableName) {
return;
}
// 로컬 상태 먼저 업데이트
setLocalValues((prev) => ({ ...prev, tableName }));
const table = tables.find((t) => t.tableName === tableName);
if (!table) return;
console.log("🔄 테이블 변경:", {
tableName,
currentTableName: localValues.tableName,
table,
columnsCount: table.columns.length,
columns: table.columns.map((col) => ({
name: col.columnName,
label: col.columnLabel,
type: col.dataType,
})),
});
// 테이블의 모든 컬럼을 기본 설정으로 추가
@ -331,22 +333,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
}));
// 필터는 사용자가 수동으로 추가
console.log("✅ 생성된 컬럼 설정:", {
defaultColumnsCount: defaultColumns.length,
visibleColumns: defaultColumns.filter((col) => col.visible).length,
});
onUpdateComponent({
tableName,
columns: defaultColumns,
filters: [], // 빈 필터 배열
});
setSelectedTable(table);
// 상태 업데이트를 한 번에 처리
setTimeout(() => {
onUpdateComponent({
tableName,
columns: defaultColumns,
filters: [], // 빈 필터 배열
});
setSelectedTable(table);
}, 0);
},
[tables, onUpdateComponent],
[tables, onUpdateComponent, localValues.tableName],
);
// 컬럼 타입 추론
@ -556,38 +558,36 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
{webType === "radio" ? (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
<select
value={localSettings.defaultValue || "__NONE__"}
onValueChange={(value) => updateSettings({ defaultValue: value === "__NONE__" ? "" : value })}
onChange={(e) => {
const value = e.target.value;
updateSettings({ defaultValue: value === "__NONE__" ? "" : value });
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-full items-center justify-between rounded-md border px-3 py-1 text-xs focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="기본값 선택..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__"> </SelectItem>
{(localSettings.options || []).map((option: any, index: number) => {
// 안전한 문자열 변환
const getStringValue = (val: any): string => {
if (typeof val === "string") return val;
if (typeof val === "number") return String(val);
if (typeof val === "object" && val !== null) {
return val.label || val.value || val.name || JSON.stringify(val);
}
return String(val || "");
};
<option value="__NONE__"> </option>
{(localSettings.options || []).map((option: any, index: number) => {
// 안전한 문자열 변환
const getStringValue = (val: any): string => {
if (typeof val === "string") return val;
if (typeof val === "number") 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 optionLabel =
getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
const optionValue = getStringValue(option.value || option.label || option) || `option-${index}`;
const optionLabel = getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
return (
<SelectItem key={index} value={optionValue}>
{optionLabel}
</SelectItem>
);
})}
</SelectContent>
</Select>
return (
<option key={index} value={optionValue}>
{optionLabel}
</option>
);
})}
</select>
</div>
) : (
<div className="flex items-center space-x-2">
@ -943,8 +943,9 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
// 웹 타입별 필터 가능 여부 확인
const isFilterableWebType = (webType: WebType): boolean => {
const filterableTypes: WebType[] = ["text", "number", "decimal", "date", "datetime", "select", "email", "tel"];
return filterableTypes.includes(webType);
// 대부분의 웹타입은 필터링 가능 (파일, 버튼 등만 제외)
const nonFilterableTypes = ["file", "button", "image"];
return !nonFilterableTypes.includes(webType);
};
// 컬럼 추가 (테이블에서 선택)
@ -1148,18 +1149,18 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="table-select"> </Label>
<Select value={component.tableName} onValueChange={handleTableChange}>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.tableLabel || table.tableName}
</SelectItem>
))}
</SelectContent>
</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.tableName}
onChange={(e) => handleTableChange(e.target.value)}
>
<option value=""> </option>
{tables.map((table) => (
<option key={table.tableName} value={table.tableName}>
{table.tableLabel || table.tableName}
</option>
))}
</select>
</div>
<div className="space-y-2">
@ -1314,27 +1315,24 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
<Label htmlFor="modal-width" className="text-sm">
</Label>
<Select
<select
value={localValues.modalWidth}
onValueChange={(value) => {
onChange={(e) => {
const value = e.target.value;
setLocalValues((prev) => ({ ...prev, modalWidth: value as any }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, width: value as any },
});
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (384px)</SelectItem>
<SelectItem value="md"> (448px)</SelectItem>
<SelectItem value="lg"> (512px)</SelectItem>
<SelectItem value="xl"> (576px)</SelectItem>
<SelectItem value="2xl"> (672px)</SelectItem>
<SelectItem value="full"> </SelectItem>
</SelectContent>
</Select>
<option value="sm"> (384px)</option>
<option value="md"> (448px)</option>
<option value="lg"> (512px)</option>
<option value="xl"> (576px)</option>
<option value="2xl"> (672px)</option>
<option value="full"> </option>
</select>
</div>
</div>
@ -1362,24 +1360,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
<Label htmlFor="modal-layout" className="text-sm">
</Label>
<Select
<select
value={localValues.modalLayout}
onValueChange={(value) => {
onChange={(e) => {
const value = e.target.value;
setLocalValues((prev) => ({ ...prev, modalLayout: value as any }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, layout: value as any },
});
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single"> </SelectItem>
<SelectItem value="two-column">2</SelectItem>
<SelectItem value="grid"></SelectItem>
</SelectContent>
</Select>
<option value="single"> </option>
<option value="two-column">2</option>
<option value="grid"></option>
</select>
</div>
{localValues.modalLayout === "grid" && (
@ -1387,25 +1382,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
<Label htmlFor="modal-grid-columns" className="text-sm">
</Label>
<Select
<select
value={localValues.modalGridColumns.toString()}
onValueChange={(value) => {
const gridColumns = parseInt(value);
onChange={(e) => {
const gridColumns = parseInt(e.target.value);
setLocalValues((prev) => ({ ...prev, modalGridColumns: gridColumns }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, gridColumns },
});
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</div>
)}
</div>
@ -1454,26 +1445,23 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
<div className="space-y-2">
<Label htmlFor="grid-columns"> </Label>
<Select
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localValues.gridColumns.toString()}
onValueChange={(value) => {
const gridColumns = parseInt(value, 10);
onChange={(e) => {
const gridColumns = parseInt(e.target.value, 10);
console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
setLocalValues((prev) => ({ ...prev, gridColumns }));
onUpdateComponent({ gridColumns });
}}
>
<SelectTrigger>
<SelectValue placeholder="그리드 컬럼 수 선택" />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num} ({Math.round((num / 12) * 100)}%)
</SelectItem>
))}
</SelectContent>
</Select>
<option value=""> </option>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => (
<option key={num} value={num.toString()}>
{num} ({Math.round((num / 12) * 100)}%)
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
@ -1541,18 +1529,22 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
);
return availableColumns.length > 0 ? (
<Select onValueChange={(value) => addColumn(value)}>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="DB 컬럼" />
</SelectTrigger>
<SelectContent>
{availableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<select
onChange={(e) => {
if (e.target.value) {
addColumn(e.target.value);
e.target.value = ""; // 선택 후 초기화
}
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-32 items-center justify-between rounded-md border px-3 py-1 text-xs focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">DB </option>
{availableColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</option>
))}
</select>
) : (
<Button size="sm" disabled>
<Plus className="h-4 w-4" />
@ -2196,28 +2188,24 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={component.pagination.pageSize.toString()}
onValueChange={(value) =>
onChange={(e) =>
onUpdateComponent({
pagination: {
...component.pagination,
pageSize: parseInt(value),
pageSize: parseInt(e.target.value),
},
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[5, 10, 20, 50, 100].map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
{[5, 10, 20, 50, 100].map((size) => (
<option key={size} value={size.toString()}>
{size}
</option>
))}
</select>
</div>
</div>
@ -2299,4 +2287,42 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
);
};
// React.memo로 감싸서 불필요한 리렌더링 방지
export const DataTableConfigPanel = React.memo(DataTableConfigPanelComponent, (prevProps, nextProps) => {
// 컴포넌트 ID가 다르면 리렌더링
if (prevProps.component.id !== nextProps.component.id) {
return false;
}
// 테이블 목록이 변경되면 리렌더링
if (prevProps.tables.length !== nextProps.tables.length) {
return false;
}
// 활성 탭이 변경되면 리렌더링
if (prevProps.activeTab !== nextProps.activeTab) {
return false;
}
// 컬럼 개수나 필터 개수가 변경되면 리렌더링
if (
prevProps.component.columns?.length !== nextProps.component.columns?.length ||
prevProps.component.filters?.length !== nextProps.component.filters?.length
) {
return false;
}
// 기본 속성들이 변경되면 리렌더링
if (
prevProps.component.title !== nextProps.component.title ||
prevProps.component.tableName !== nextProps.component.tableName ||
prevProps.component.searchButtonText !== nextProps.component.searchButtonText
) {
return false;
}
// 그 외의 경우는 리렌더링하지 않음
return true;
});
export default DataTableConfigPanel;

View File

@ -3,6 +3,7 @@
import React from "react";
import { Settings } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import {
ComponentData,
WidgetComponent,
@ -30,6 +31,7 @@ import { CheckboxTypeConfigPanel } from "./webtype-configs/CheckboxTypeConfigPan
import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
import { RatingTypeConfigPanel, RatingTypeConfig } from "./webtype-configs/RatingTypeConfigPanel";
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
import { ButtonConfigPanel } from "./ButtonConfigPanel";
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
@ -47,24 +49,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
currentTable,
currentTableName,
}) => {
// 입력 가능한 웹타입들 정의
const inputableWebTypes = [
"text",
"number",
"decimal",
"date",
"datetime",
"select",
"dropdown",
"textarea",
"email",
"tel",
"code",
"entity",
"file",
"checkbox",
"radio",
];
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
const { webTypes } = useWebTypes({ active: "Y" });
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
// 웹타입별 상세 설정 렌더링 함수
const renderWebTypeConfig = React.useCallback(
@ -185,6 +172,17 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
/>
);
case "rating":
case "star":
case "score":
return (
<RatingTypeConfigPanel
key={`${widget.id}-rating`}
config={currentConfig as RatingTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "entity":
return (
<EntityTypeConfigPanel

View File

@ -1,13 +1,13 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // 임시 비활성화
// import { Checkbox } from "@/components/ui/checkbox";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import {
ComponentData,
@ -20,6 +20,77 @@ import {
TableInfo,
} from "@/types/screen";
import DataTableConfigPanel from "./DataTableConfigPanel";
import { DynamicConfigPanel } from "@/lib/registry";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
const DataTableConfigPanelWrapper: React.FC<{
selectedComponent: DataTableComponent;
tables: TableInfo[];
activeTab: string;
onTabChange: (tab: string) => void;
onUpdateProperty: (property: string, value: any) => void;
}> = React.memo(
({ selectedComponent, tables, activeTab, onTabChange, onUpdateProperty }) => {
// 안정화된 업데이트 핸들러
const handleUpdateComponent = React.useCallback(
(updates: Partial<DataTableComponent>) => {
console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates);
// 변경사항이 있는지 확인 (간단한 비교로 성능 향상)
const hasChanges = Object.entries(updates).some(([key, value]) => {
const currentValue = (selectedComponent as any)[key];
// 배열의 경우 길이만 비교
if (Array.isArray(currentValue) && Array.isArray(value)) {
return currentValue.length !== value.length;
}
// 기본값 비교
return currentValue !== value;
});
if (!hasChanges) {
console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵");
return;
}
// 각 속성을 개별적으로 업데이트
Object.entries(updates).forEach(([key, value]) => {
onUpdateProperty(key, value);
});
},
[selectedComponent.id, onUpdateProperty],
); // ID만 의존성으로 사용
return (
<DataTableConfigPanel
component={selectedComponent}
tables={tables}
activeTab={activeTab}
onTabChange={onTabChange}
onUpdateComponent={handleUpdateComponent}
/>
);
},
(prevProps, nextProps) => {
// 컴포넌트 ID가 다르면 리렌더링
if (prevProps.selectedComponent.id !== nextProps.selectedComponent.id) {
return false;
}
// 테이블 목록이 변경되면 리렌더링
if (prevProps.tables.length !== nextProps.tables.length) {
return false;
}
// 활성 탭이 변경되면 리렌더링
if (prevProps.activeTab !== nextProps.activeTab) {
return false;
}
// 그 외의 경우는 리렌더링하지 않음
return true;
},
);
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
@ -33,27 +104,9 @@ interface PropertiesPanelProps {
canUngroup?: boolean;
}
const webTypeOptions: { value: WebType; label: string }[] = [
{ value: "text", label: "텍스트" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
{ value: "number", label: "숫자" },
{ value: "decimal", label: "소수" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택박스" },
{ value: "dropdown", label: "드롭다운" },
{ value: "textarea", label: "텍스트영역" },
{ value: "boolean", label: "불린" },
{ value: "checkbox", label: "체크박스" },
{ value: "radio", label: "라디오" },
{ value: "code", label: "코드" },
{ value: "entity", label: "엔티티" },
{ value: "file", label: "파일" },
{ value: "button", label: "버튼" },
];
// 동적 웹타입 옵션은 컴포넌트 내부에서 useWebTypes 훅으로 가져옵니다
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
selectedComponent,
tables = [],
onUpdateProperty,
@ -64,6 +117,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
canGroup = false,
canUngroup = false,
}) => {
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" });
// 웹타입 옵션 생성 - 데이터베이스 기반
const webTypeOptions = webTypes.map((webType) => ({
value: webType.web_type as WebType,
label: webType.type_name,
}));
// 데이터테이블 설정 탭 상태를 여기서 관리
const [dataTableActiveTab, setDataTableActiveTab] = useState("basic");
// 최신 값들의 참조를 유지
@ -93,6 +155,9 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
// widgetType도 로컬 상태로 관리
widgetType:
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
});
useEffect(() => {
@ -136,14 +201,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
required: widget?.required || false,
readonly: widget?.readonly || false,
labelDisplay: selectedComponent.style?.labelDisplay !== false,
// widgetType 동기화
widgetType: widget?.widgetType || "text",
});
}
}, [
selectedComponent,
selectedComponent?.position,
selectedComponent?.size,
selectedComponent?.style,
selectedComponent?.label,
selectedComponent?.id, // ID만 감지하여 컴포넌트 변경 시에만 업데이트
]);
if (!selectedComponent) {
@ -187,30 +250,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
{/* 데이터 테이블 설정 패널 */}
<div className="flex-1 overflow-y-auto">
<DataTableConfigPanel
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`}
component={selectedComponent as DataTableComponent}
<DataTableConfigPanelWrapper
selectedComponent={selectedComponent as DataTableComponent}
tables={tables}
activeTab={dataTableActiveTab}
onTabChange={setDataTableActiveTab}
onUpdateComponent={(updates) => {
console.log("🔄 DataTable 컴포넌트 업데이트:", updates);
console.log("🔄 업데이트 항목들:", Object.keys(updates));
// 각 속성을 개별적으로 업데이트
Object.entries(updates).forEach(([key, value]) => {
console.log(` - ${key}:`, value);
if (key === "columns") {
console.log(` 컬럼 개수: ${Array.isArray(value) ? value.length : 0}`);
}
if (key === "filters") {
console.log(` 필터 개수: ${Array.isArray(value) ? value.length : 0}`);
}
onUpdateProperty(key, value);
});
console.log("✅ DataTable 컴포넌트 업데이트 완료");
}}
onUpdateProperty={onUpdateProperty}
/>
</div>
</div>
@ -289,23 +334,56 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<Label htmlFor="widgetType" className="text-sm font-medium">
</Label>
<Select
value={selectedComponent.widgetType || "text"}
onValueChange={(value) => onUpdateProperty("widgetType", value)}
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localInputs.widgetType}
onChange={(e) => {
const value = e.target.value as WebType;
setLocalInputs((prev) => ({ ...prev, widgetType: value }));
onUpdateProperty("widgetType", value);
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{webTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* 동적 웹타입 설정 패널 */}
{selectedComponent.widgetType && (
<div className="space-y-3">
<Separator />
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2">
<DynamicConfigPanel
webType={selectedComponent.widgetType}
component={selectedComponent}
onUpdateComponent={(updatedComponent) => {
// 컴포넌트 전체 업데이트
Object.keys(updatedComponent).forEach((key) => {
if (key !== "id") {
onUpdateProperty(key, updatedComponent[key]);
}
});
}}
onUpdateProperty={(property, value) => {
// 웹타입 설정 업데이트
if (property === "webTypeConfig") {
onUpdateProperty("webTypeConfig", value);
} else {
onUpdateProperty(property, value);
}
}}
/>
</div>
</div>
<Separator />
</div>
)}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
@ -326,13 +404,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Checkbox
<input
type="checkbox"
id="required"
checked={localInputs.required}
onCheckedChange={(checked) => {
setLocalInputs((prev) => ({ ...prev, required: !!checked }));
onUpdateProperty("required", checked);
onChange={(e) => {
setLocalInputs((prev) => ({ ...prev, required: e.target.checked }));
onUpdateProperty("required", e.target.checked);
}}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<Label htmlFor="required" className="text-sm">
@ -340,13 +420,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
</div>
<div className="flex items-center space-x-2">
<Checkbox
<input
type="checkbox"
id="readonly"
checked={localInputs.readonly}
onCheckedChange={(checked) => {
setLocalInputs((prev) => ({ ...prev, readonly: !!checked }));
onUpdateProperty("readonly", checked);
onChange={(e) => {
setLocalInputs((prev) => ({ ...prev, readonly: e.target.checked }));
onUpdateProperty("readonly", e.target.checked);
}}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<Label htmlFor="readonly" className="text-sm">
@ -498,14 +580,16 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<Label htmlFor="labelDisplay" className="text-sm font-medium">
</Label>
<Checkbox
<input
type="checkbox"
id="labelDisplay"
checked={localInputs.labelDisplay}
onCheckedChange={(checked) => {
console.log("🔄 라벨 표시 변경:", checked);
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
onUpdateProperty("style.labelDisplay", checked);
onChange={(e) => {
console.log("🔄 라벨 표시 변경:", e.target.checked);
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
onUpdateProperty("style.labelDisplay", e.target.checked);
}}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
</div>
@ -570,46 +654,38 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
</Label>
<Select
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => onUpdateProperty("style.labelFontWeight", value)}
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bold">Bold</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="300">300</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="500">500</SelectItem>
<SelectItem value="600">600</SelectItem>
<SelectItem value="700">700</SelectItem>
<SelectItem value="800">800</SelectItem>
<SelectItem value="900">900</SelectItem>
</SelectContent>
</Select>
<option value="normal">Normal</option>
<option value="bold">Bold</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
<div>
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
</Label>
<Select
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={selectedComponent.style?.labelTextAlign || "left"}
onValueChange={(value) => onUpdateProperty("style.labelTextAlign", value)}
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</div>
</div>
@ -712,27 +788,23 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<Label htmlFor="layoutType" className="text-sm font-medium">
</Label>
<Select
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutType}
onValueChange={(value: AreaLayoutType) => onUpdateProperty("layoutType", value)}
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="box"> </SelectItem>
<SelectItem value="card"></SelectItem>
<SelectItem value="panel"> ( )</SelectItem>
<SelectItem value="section"></SelectItem>
<SelectItem value="grid"></SelectItem>
<SelectItem value="flex-row"> </SelectItem>
<SelectItem value="flex-column"> </SelectItem>
<SelectItem value="sidebar"></SelectItem>
<SelectItem value="header-content">-</SelectItem>
<SelectItem value="tabs"></SelectItem>
<SelectItem value="accordion"></SelectItem>
</SelectContent>
</Select>
<option value="box"> </option>
<option value="card"></option>
<option value="panel"> ( )</option>
<option value="section"></option>
<option value="grid"></option>
<option value="flex-row"> </option>
<option value="flex-column"> </option>
<option value="sidebar"></option>
<option value="header-content">-</option>
<option value="tabs"></option>
<option value="accordion"></option>
</select>
</div>
{/* 레이아웃별 상세 설정 */}
@ -777,22 +849,18 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<h5 className="text-sm font-medium"> </h5>
<div>
<Label className="text-xs"> </Label>
<Select
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
onValueChange={(value) => onUpdateProperty("layoutConfig.justifyContent", value)}
onChange={(e) => onUpdateProperty("layoutConfig.justifyContent", e.target.value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="space-between"> </SelectItem>
<SelectItem value="space-around"> </SelectItem>
<SelectItem value="space-evenly"> </SelectItem>
</SelectContent>
</Select>
<option value="flex-start"></option>
<option value="flex-end"></option>
<option value="center"></option>
<option value="space-between"> </option>
<option value="space-around"> </option>
<option value="space-evenly"> </option>
</select>
</div>
<div>
<Label className="text-xs"> (px)</Label>
@ -815,18 +883,14 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<h5 className="text-sm font-medium"> </h5>
<div>
<Label className="text-xs"> </Label>
<Select
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
onValueChange={(value) => onUpdateProperty("layoutConfig.sidebarPosition", value)}
onChange={(e) => onUpdateProperty("layoutConfig.sidebarPosition", e.target.value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
<option value="left"></option>
<option value="right"></option>
</select>
</div>
<div>
<Label className="text-xs"> (px)</Label>
@ -851,4 +915,30 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
);
};
// React.memo로 감싸서 불필요한 리렌더링 방지
export const PropertiesPanel = React.memo(PropertiesPanelComponent, (prevProps, nextProps) => {
// 선택된 컴포넌트 ID가 다르면 리렌더링
if (prevProps.selectedComponent?.id !== nextProps.selectedComponent?.id) {
return false;
}
// 선택된 컴포넌트가 없는 상태에서 있는 상태로 변경되거나 그 반대인 경우
if (!prevProps.selectedComponent !== !nextProps.selectedComponent) {
return false;
}
// 테이블 목록이 변경되면 리렌더링
if (prevProps.tables.length !== nextProps.tables.length) {
return false;
}
// 그룹 관련 props가 변경되면 리렌더링
if (prevProps.canGroup !== nextProps.canGroup || prevProps.canUngroup !== nextProps.canUngroup) {
return false;
}
// 그 외의 경우는 리렌더링하지 않음
return true;
});
export default PropertiesPanel;

View File

@ -149,16 +149,16 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
<Label htmlFor="layout" className="text-sm font-medium">
</Label>
<Select value={localValues.layout} onValueChange={(value) => updateConfig("layout", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="레이아웃 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vertical"></SelectItem>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="grid"> (2)</SelectItem>
</SelectContent>
</Select>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localValues.layout}
onChange={(e) => updateConfig("layout", e.target.value)}
>
<option value=""> </option>
<option value="vertical"></option>
<option value="horizontal"></option>
<option value="grid"> (2)</option>
</select>
</div>
{/* 기본값 */}
@ -166,22 +166,18 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Select
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localValues.defaultValue || "__none__"}
onValueChange={(value) => updateConfig("defaultValue", value)}
onChange={(e) => updateConfig("defaultValue", e.target.value)}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(safeConfig.options || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<option value="__none__"> </option>
{(safeConfig.options || []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* 선택 안함 허용 */}

View File

@ -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";

View File

@ -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>
);
};

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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,
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { apiCall, API_BASE_URL } from "@/lib/api/client";
@ -98,6 +98,7 @@ export const useAuth = () => {
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const initializedRef = useRef(false);
// API 기본 URL 설정 (동적으로 결정)
@ -443,6 +444,13 @@ export const useAuth = () => {
*
*/
useEffect(() => {
// 이미 초기화되었으면 실행하지 않음
if (initializedRef.current) {
return;
}
initializedRef.current = true;
console.log("=== useAuth 초기 인증 상태 확인 ===");
console.log("현재 경로:", window.location.pathname);
@ -479,7 +487,7 @@ export const useAuth = () => {
router.push("/login");
}, 3000);
}
}, [refreshUserData, router]); // refreshUserData 의존성 추가
}, []); // 초기 마운트 시에만 실행
/**
*

View File

@ -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;
}

View File

@ -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";

View File

@ -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(),
};
}
}

View File

@ -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";

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
};

166
hooks/useScreenStandards.ts Normal file
View File

@ -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] || [],
};
};

View File

@ -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";

View File

@ -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";

View File

@ -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;
}
}

16
lib/registry/index.ts Normal file
View File

@ -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";

77
lib/registry/types.ts Normal file
View File

@ -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;

87
replace-selects.js Normal file
View File

@ -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('✅ 완료!');