feat/dashboard #185
|
|
@ -64,6 +64,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
|||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
|
|
@ -223,6 +224,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
|||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* 채번 규칙 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 규칙 목록 조회
|
||||
router.get("/", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getRuleList(companyCode);
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||
if (!rule) {
|
||||
return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" });
|
||||
}
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 규칙 생성
|
||||
router.post("/", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
try {
|
||||
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) {
|
||||
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
|
||||
}
|
||||
|
||||
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
||||
return res.status(201).json({ success: true, data: newRule });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||
}
|
||||
logger.error("규칙 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 규칙 수정
|
||||
router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 규칙 삭제
|
||||
router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 코드 생성
|
||||
router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode);
|
||||
return res.json({ success: true, data: { code: generatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 시퀀스 초기화
|
||||
router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("시퀀스 초기화 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
/**
|
||||
* 채번 규칙 관리 서비스
|
||||
*/
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface NumberingRulePart {
|
||||
id?: number;
|
||||
order: number;
|
||||
partType: string;
|
||||
generationMethod: string;
|
||||
autoConfig?: any;
|
||||
manualConfig?: any;
|
||||
generatedValue?: string;
|
||||
}
|
||||
|
||||
interface NumberingRuleConfig {
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
description?: string;
|
||||
parts: NumberingRulePart[];
|
||||
separator?: string;
|
||||
resetPeriod?: string;
|
||||
currentSequence?: number;
|
||||
tableName?: string;
|
||||
columnName?: string;
|
||||
companyCode?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
class NumberingRuleService {
|
||||
/**
|
||||
* 규칙 목록 조회
|
||||
*/
|
||||
async getRuleList(companyCode: string): Promise<NumberingRuleConfig[]> {
|
||||
try {
|
||||
logger.info("채번 규칙 목록 조회 시작", { companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE company_code = $1 OR company_code = '*'
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode]);
|
||||
|
||||
// 각 규칙의 파트 정보 조회
|
||||
for (const rule of result.rows) {
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||
rule.parts = partsResult.rows;
|
||||
}
|
||||
|
||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode });
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 규칙 조회
|
||||
*/
|
||||
async getRuleById(ruleId: string, companyCode: string): Promise<NumberingRuleConfig | null> {
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_id AS "menuId",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [ruleId, companyCode]);
|
||||
if (result.rowCount === 0) return null;
|
||||
|
||||
const rule = result.rows[0];
|
||||
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
||||
const partsResult = await pool.query(partsQuery, [ruleId, companyCode]);
|
||||
rule.parts = partsResult.rows;
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 생성
|
||||
*/
|
||||
async createRule(
|
||||
config: NumberingRuleConfig,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<NumberingRuleConfig> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 마스터 삽입
|
||||
const insertRuleQuery = `
|
||||
INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
menu_objid, scope_type, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
`;
|
||||
|
||||
const ruleResult = await client.query(insertRuleQuery, [
|
||||
config.ruleId,
|
||||
config.ruleName,
|
||||
config.description || null,
|
||||
config.separator || "-",
|
||||
config.resetPeriod || "none",
|
||||
config.currentSequence || 1,
|
||||
config.tableName || null,
|
||||
config.columnName || null,
|
||||
companyCode,
|
||||
config.menuObjid || null,
|
||||
config.scopeType || "global",
|
||||
userId,
|
||||
]);
|
||||
|
||||
// 파트 삽입
|
||||
const parts: NumberingRulePart[] = [];
|
||||
for (const part of config.parts) {
|
||||
const insertPartQuery = `
|
||||
INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
`;
|
||||
|
||||
const partResult = await client.query(insertPartQuery, [
|
||||
config.ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
parts.push(partResult.rows[0]);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode });
|
||||
return { ...ruleResult.rows[0], parts };
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("채번 규칙 생성 실패", { error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 수정
|
||||
*/
|
||||
async updateRule(
|
||||
ruleId: string,
|
||||
updates: Partial<NumberingRuleConfig>,
|
||||
companyCode: string
|
||||
): Promise<NumberingRuleConfig> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const updateRuleQuery = `
|
||||
UPDATE numbering_rules
|
||||
SET
|
||||
rule_name = COALESCE($1, rule_name),
|
||||
description = COALESCE($2, description),
|
||||
separator = COALESCE($3, separator),
|
||||
reset_period = COALESCE($4, reset_period),
|
||||
table_name = COALESCE($5, table_name),
|
||||
column_name = COALESCE($6, column_name),
|
||||
menu_objid = COALESCE($7, menu_objid),
|
||||
scope_type = COALESCE($8, scope_type),
|
||||
updated_at = NOW()
|
||||
WHERE rule_id = $9 AND company_code = $10
|
||||
RETURNING
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
`;
|
||||
|
||||
const ruleResult = await client.query(updateRuleQuery, [
|
||||
updates.ruleName,
|
||||
updates.description,
|
||||
updates.separator,
|
||||
updates.resetPeriod,
|
||||
updates.tableName,
|
||||
updates.columnName,
|
||||
updates.menuObjid,
|
||||
updates.scopeType,
|
||||
ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (ruleResult.rowCount === 0) {
|
||||
throw new Error("규칙을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
|
||||
// 파트 업데이트
|
||||
let parts: NumberingRulePart[] = [];
|
||||
if (updates.parts) {
|
||||
await client.query(
|
||||
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
|
||||
for (const part of updates.parts) {
|
||||
const insertPartQuery = `
|
||||
INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
`;
|
||||
|
||||
const partResult = await client.query(insertPartQuery, [
|
||||
ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
parts.push(partResult.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("채번 규칙 수정 완료", { ruleId, companyCode });
|
||||
return { ...ruleResult.rows[0], parts };
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("채번 규칙 수정 실패", { error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 삭제
|
||||
*/
|
||||
async deleteRule(ruleId: string, companyCode: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
DELETE FROM numbering_rules
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [ruleId, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("규칙을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
|
||||
logger.info("채번 규칙 삭제 완료", { ruleId, companyCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 생성
|
||||
*/
|
||||
async generateCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
const parts = rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "prefix":
|
||||
return autoConfig.prefix || "PREFIX";
|
||||
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date":
|
||||
return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD");
|
||||
|
||||
case "year": {
|
||||
const format = autoConfig.dateFormat || "YYYY";
|
||||
const year = new Date().getFullYear();
|
||||
return format === "YY" ? String(year).slice(-2) : String(year);
|
||||
}
|
||||
|
||||
case "month":
|
||||
return String(new Date().getMonth() + 1).padStart(2, "0");
|
||||
|
||||
case "custom":
|
||||
return autoConfig.value || "CUSTOM";
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const generatedCode = parts.join(rule.separator || "");
|
||||
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
if (hasSequence) {
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("코드 생성 완료", { ruleId, generatedCode });
|
||||
return generatedCode;
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
private formatDate(date: Date, format: string): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
async resetSequence(ruleId: string, companyCode: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
await pool.query(
|
||||
"UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
|
||||
}
|
||||
}
|
||||
|
||||
export const numberingRuleService = new NumberingRuleService();
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
# 채번규칙 컴포넌트 구현 완료
|
||||
|
||||
> **작성일**: 2025-11-04
|
||||
> **상태**: 백엔드 및 프론트엔드 핵심 구현 완료 (화면관리 통합 대기)
|
||||
|
||||
---
|
||||
|
||||
## 구현 개요
|
||||
|
||||
채번규칙(Numbering Rule) 컴포넌트는 시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다.
|
||||
|
||||
**생성 코드 예시**:
|
||||
- 제품 코드: `PROD-20251104-0001`
|
||||
- 프로젝트 코드: `PRJ-2025-001`
|
||||
- 거래처 코드: `CUST-A-0001`
|
||||
|
||||
---
|
||||
|
||||
## 완료된 구현 항목
|
||||
|
||||
### 1. 데이터베이스 레이어 ✅
|
||||
|
||||
**파일**: `db/migrations/034_create_numbering_rules.sql`
|
||||
|
||||
- [x] `numbering_rules` 마스터 테이블 생성
|
||||
- [x] `numbering_rule_parts` 파트 테이블 생성
|
||||
- [x] 멀티테넌시 지원 (company_code 필드)
|
||||
- [x] 인덱스 생성 (성능 최적화)
|
||||
- [x] 샘플 데이터 삽입
|
||||
|
||||
**주요 기능**:
|
||||
- 규칙 ID, 규칙명, 구분자, 초기화 주기
|
||||
- 현재 시퀀스 번호 관리
|
||||
- 적용 대상 테이블/컬럼 지정
|
||||
- 최대 6개 파트 지원
|
||||
|
||||
---
|
||||
|
||||
### 2. 백엔드 레이어 ✅
|
||||
|
||||
#### 2.1 서비스 레이어
|
||||
|
||||
**파일**: `backend-node/src/services/numberingRuleService.ts`
|
||||
|
||||
**구현된 메서드**:
|
||||
- [x] `getRuleList(companyCode)` - 규칙 목록 조회
|
||||
- [x] `getRuleById(ruleId, companyCode)` - 특정 규칙 조회
|
||||
- [x] `createRule(config, companyCode, userId)` - 규칙 생성
|
||||
- [x] `updateRule(ruleId, updates, companyCode)` - 규칙 수정
|
||||
- [x] `deleteRule(ruleId, companyCode)` - 규칙 삭제
|
||||
- [x] `generateCode(ruleId, companyCode)` - 코드 생성
|
||||
- [x] `resetSequence(ruleId, companyCode)` - 시퀀스 초기화
|
||||
|
||||
**핵심 로직**:
|
||||
- 트랜잭션 관리 (BEGIN/COMMIT/ROLLBACK)
|
||||
- 멀티테넌시 필터링 (company_code 기반)
|
||||
- JSON 설정 직렬화/역직렬화
|
||||
- 날짜 형식 변환 (YYYY, YYYYMMDD 등)
|
||||
- 순번 자동 증가 및 제로 패딩
|
||||
|
||||
#### 2.2 컨트롤러 레이어
|
||||
|
||||
**파일**: `backend-node/src/controllers/numberingRuleController.ts`
|
||||
|
||||
**구현된 엔드포인트**:
|
||||
- [x] `GET /api/numbering-rules` - 규칙 목록 조회
|
||||
- [x] `GET /api/numbering-rules/:ruleId` - 특정 규칙 조회
|
||||
- [x] `POST /api/numbering-rules` - 규칙 생성
|
||||
- [x] `PUT /api/numbering-rules/:ruleId` - 규칙 수정
|
||||
- [x] `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제
|
||||
- [x] `POST /api/numbering-rules/:ruleId/generate` - 코드 생성
|
||||
- [x] `POST /api/numbering-rules/:ruleId/reset` - 시퀀스 초기화
|
||||
|
||||
**보안 및 검증**:
|
||||
- `authenticateToken` 미들웨어로 인증 확인
|
||||
- 입력값 검증 (필수 필드, 파트 최소 개수)
|
||||
- 에러 핸들링 및 적절한 HTTP 상태 코드 반환
|
||||
|
||||
#### 2.3 라우터 등록
|
||||
|
||||
**파일**: `backend-node/src/app.ts`
|
||||
|
||||
```typescript
|
||||
import numberingRuleController from "./controllers/numberingRuleController";
|
||||
app.use("/api/numbering-rules", numberingRuleController);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 프론트엔드 레이어 ✅
|
||||
|
||||
#### 3.1 타입 정의
|
||||
|
||||
**파일**: `frontend/types/numbering-rule.ts`
|
||||
|
||||
**정의된 타입**:
|
||||
- [x] `CodePartType` - 파트 유형 (prefix/sequence/date/year/month/custom)
|
||||
- [x] `GenerationMethod` - 생성 방식 (auto/manual)
|
||||
- [x] `DateFormat` - 날짜 형식 (YYYY/YYYYMMDD 등)
|
||||
- [x] `NumberingRulePart` - 단일 파트 인터페이스
|
||||
- [x] `NumberingRuleConfig` - 전체 규칙 인터페이스
|
||||
- [x] 상수 옵션 배열 (UI용)
|
||||
|
||||
#### 3.2 API 클라이언트
|
||||
|
||||
**파일**: `frontend/lib/api/numberingRule.ts`
|
||||
|
||||
**구현된 함수**:
|
||||
- [x] `getNumberingRules()` - 규칙 목록 조회
|
||||
- [x] `getNumberingRuleById(ruleId)` - 특정 규칙 조회
|
||||
- [x] `createNumberingRule(config)` - 규칙 생성
|
||||
- [x] `updateNumberingRule(ruleId, config)` - 규칙 수정
|
||||
- [x] `deleteNumberingRule(ruleId)` - 규칙 삭제
|
||||
- [x] `generateCode(ruleId)` - 코드 생성
|
||||
- [x] `resetSequence(ruleId)` - 시퀀스 초기화
|
||||
|
||||
**기술 스택**:
|
||||
- Axios 기반 API 클라이언트
|
||||
- 에러 핸들링 및 응답 타입 정의
|
||||
|
||||
#### 3.3 컴포넌트 구조
|
||||
|
||||
```
|
||||
frontend/components/numbering-rule/
|
||||
├── NumberingRuleDesigner.tsx # 메인 디자이너 (좌우 분할)
|
||||
├── NumberingRulePreview.tsx # 실시간 미리보기
|
||||
├── NumberingRuleCard.tsx # 단일 파트 카드
|
||||
├── AutoConfigPanel.tsx # 자동 생성 설정
|
||||
└── ManualConfigPanel.tsx # 직접 입력 설정
|
||||
```
|
||||
|
||||
#### 3.4 주요 컴포넌트 기능
|
||||
|
||||
**NumberingRuleDesigner** (메인 컴포넌트):
|
||||
- [x] 좌측: 저장된 규칙 목록 (카드 리스트)
|
||||
- [x] 우측: 규칙 편집 영역 (파트 추가/수정/삭제)
|
||||
- [x] 실시간 미리보기
|
||||
- [x] 규칙 저장/불러오기/삭제
|
||||
- [x] 타이틀 편집 기능
|
||||
- [x] 로딩 상태 관리
|
||||
|
||||
**NumberingRulePreview**:
|
||||
- [x] 설정된 규칙에 따라 실시간 코드 생성
|
||||
- [x] 컴팩트 모드 지원
|
||||
- [x] useMemo로 성능 최적화
|
||||
|
||||
**NumberingRuleCard**:
|
||||
- [x] 파트 유형 선택 (Select)
|
||||
- [x] 생성 방식 선택 (자동/수동)
|
||||
- [x] 동적 설정 패널 표시
|
||||
- [x] 삭제 버튼
|
||||
|
||||
**AutoConfigPanel**:
|
||||
- [x] 파트 유형별 설정 UI
|
||||
- [x] 접두사, 순번, 날짜, 연도, 월, 커스텀
|
||||
- [x] 입력값 검증 및 가이드 텍스트
|
||||
|
||||
**ManualConfigPanel**:
|
||||
- [x] 직접 입력값 설정
|
||||
- [x] 플레이스홀더 설정
|
||||
|
||||
---
|
||||
|
||||
## 기술적 특징
|
||||
|
||||
### Shadcn/ui 스타일 가이드 준수
|
||||
|
||||
- 반응형 크기: `h-8 sm:h-10`, `text-xs sm:text-sm`
|
||||
- 색상 토큰: `bg-muted`, `text-muted-foreground`, `border-border`
|
||||
- 간격: `space-y-3 sm:space-y-4`, `gap-4`
|
||||
- 상태: `hover:bg-accent`, `disabled:opacity-50`
|
||||
|
||||
### 실시간 속성 편집 패턴
|
||||
|
||||
```typescript
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
onChange?.(currentRule); // 상위 컴포넌트로 실시간 전파
|
||||
}
|
||||
}, [currentRule, onChange]);
|
||||
|
||||
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 멀티테넌시 지원
|
||||
|
||||
```typescript
|
||||
// 백엔드 쿼리
|
||||
WHERE company_code = $1 OR company_code = '*'
|
||||
|
||||
// 일반 회사는 자신의 데이터만 조회
|
||||
// company_code = "*"는 최고 관리자 전용 데이터
|
||||
```
|
||||
|
||||
### 에러 처리 및 사용자 피드백
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const response = await createNumberingRule(config);
|
||||
if (response.success) {
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
toast.error(response.error || "저장 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`저장 실패: ${error.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 남은 작업
|
||||
|
||||
### 화면관리 시스템 통합 (TODO)
|
||||
|
||||
다음 파일들을 생성하여 화면관리 시스템에 컴포넌트를 등록해야 합니다:
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/numbering-rule/
|
||||
├── index.ts # 컴포넌트 정의 및 등록
|
||||
├── NumberingRuleComponent.tsx # 래퍼 컴포넌트
|
||||
├── NumberingRuleConfigPanel.tsx # 속성 설정 패널
|
||||
└── types.ts # 컴포넌트 설정 타입
|
||||
```
|
||||
|
||||
**등록 예시**:
|
||||
```typescript
|
||||
export const NumberingRuleDefinition = createComponentDefinition({
|
||||
id: "numbering-rule",
|
||||
name: "코드 채번 규칙",
|
||||
category: ComponentCategory.ADMIN,
|
||||
component: NumberingRuleWrapper,
|
||||
configPanel: NumberingRuleConfigPanel,
|
||||
defaultSize: { width: 1200, height: 800 },
|
||||
icon: "Hash",
|
||||
tags: ["코드", "채번", "규칙", "관리자"],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테스트 가이드
|
||||
|
||||
### 백엔드 API 테스트 (Postman/Thunder Client)
|
||||
|
||||
#### 1. 규칙 목록 조회
|
||||
```bash
|
||||
GET http://localhost:8080/api/numbering-rules
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
#### 2. 규칙 생성
|
||||
```bash
|
||||
POST http://localhost:8080/api/numbering-rules
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {token}
|
||||
|
||||
{
|
||||
"ruleId": "PROD_CODE",
|
||||
"ruleName": "제품 코드 규칙",
|
||||
"separator": "-",
|
||||
"parts": [
|
||||
{
|
||||
"order": 1,
|
||||
"partType": "prefix",
|
||||
"generationMethod": "auto",
|
||||
"autoConfig": { "prefix": "PROD" }
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"partType": "date",
|
||||
"generationMethod": "auto",
|
||||
"autoConfig": { "dateFormat": "YYYYMMDD" }
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"partType": "sequence",
|
||||
"generationMethod": "auto",
|
||||
"autoConfig": { "sequenceLength": 4, "startFrom": 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 코드 생성
|
||||
```bash
|
||||
POST http://localhost:8080/api/numbering-rules/PROD_CODE/generate
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"code": "PROD-20251104-0001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 프론트엔드 테스트
|
||||
|
||||
1. **새 규칙 생성**:
|
||||
- "새 규칙 생성" 버튼 클릭
|
||||
- 규칙명 입력
|
||||
- "규칙 추가" 버튼으로 파트 추가
|
||||
- 각 파트의 설정 변경
|
||||
- "저장" 버튼 클릭
|
||||
|
||||
2. **미리보기 확인**:
|
||||
- 파트 추가/수정 시 실시간으로 코드 미리보기 업데이트 확인
|
||||
- 구분자 변경 시 반영 확인
|
||||
|
||||
3. **규칙 편집**:
|
||||
- 좌측 목록에서 규칙 선택
|
||||
- 우측 편집 영역에서 수정
|
||||
- 저장 후 목록에 반영 확인
|
||||
|
||||
4. **규칙 삭제**:
|
||||
- 목록 카드의 삭제 버튼 클릭
|
||||
- 목록에서 제거 확인
|
||||
|
||||
---
|
||||
|
||||
## 파일 목록
|
||||
|
||||
### 백엔드
|
||||
- `db/migrations/034_create_numbering_rules.sql` (마이그레이션)
|
||||
- `backend-node/src/services/numberingRuleService.ts` (서비스)
|
||||
- `backend-node/src/controllers/numberingRuleController.ts` (컨트롤러)
|
||||
- `backend-node/src/app.ts` (라우터 등록)
|
||||
|
||||
### 프론트엔드
|
||||
- `frontend/types/numbering-rule.ts` (타입 정의)
|
||||
- `frontend/lib/api/numberingRule.ts` (API 클라이언트)
|
||||
- `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
|
||||
- `frontend/components/numbering-rule/NumberingRulePreview.tsx`
|
||||
- `frontend/components/numbering-rule/NumberingRuleCard.tsx`
|
||||
- `frontend/components/numbering-rule/AutoConfigPanel.tsx`
|
||||
- `frontend/components/numbering-rule/ManualConfigPanel.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **마이그레이션 실행**:
|
||||
```sql
|
||||
psql -U postgres -d ilshin -f db/migrations/034_create_numbering_rules.sql
|
||||
```
|
||||
|
||||
2. **백엔드 서버 확인** (이미 실행 중이면 자동 반영)
|
||||
|
||||
3. **화면관리 통합**:
|
||||
- 레지스트리 컴포넌트 파일 생성
|
||||
- 컴포넌트 등록 및 화면 디자이너에서 사용 가능하도록 설정
|
||||
|
||||
4. **테스트**:
|
||||
- API 테스트 (Postman)
|
||||
- UI 테스트 (브라우저)
|
||||
- 멀티테넌시 검증
|
||||
|
||||
---
|
||||
|
||||
**작성 완료**: 2025-11-04
|
||||
**문의**: 백엔드 및 프론트엔드 핵심 기능 완료, 화면관리 통합만 남음
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ export default function ScreenViewPage() {
|
|||
|
||||
// 🆕 현재 로그인한 사용자 정보
|
||||
const { user, userName, companyCode } = useAuth();
|
||||
|
||||
|
||||
// 🆕 모바일 환경 감지
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
|
|
@ -61,6 +61,9 @@ export default function ScreenViewPage() {
|
|||
modalDescription?: string;
|
||||
}>({});
|
||||
|
||||
// 레이아웃 준비 완료 상태 (버튼 위치 계산 완료 후 화면 표시)
|
||||
const [layoutReady, setLayoutReady] = useState(true);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
|
@ -106,6 +109,7 @@ export default function ScreenViewPage() {
|
|||
const loadScreen = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLayoutReady(false); // 화면 로드 시 레이아웃 준비 초기화
|
||||
setError(null);
|
||||
|
||||
// 화면 정보 로드
|
||||
|
|
@ -225,6 +229,9 @@ export default function ScreenViewPage() {
|
|||
setScale(newScale);
|
||||
// 컨테이너 너비 업데이트
|
||||
setContainerWidth(containerWidth);
|
||||
|
||||
// 스케일 계산 완료 후 레이아웃 준비 완료 표시
|
||||
setLayoutReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -240,10 +247,10 @@ export default function ScreenViewPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
<div className="rounded-xl border border-border bg-background p-8 text-center shadow-lg">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
||||
<p className="mt-4 font-medium text-foreground">화면을 불러오는 중...</p>
|
||||
<div className="from-muted to-muted/50 flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br">
|
||||
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||||
<Loader2 className="text-primary mx-auto h-10 w-10 animate-spin" />
|
||||
<p className="text-foreground mt-4 font-medium">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -251,13 +258,13 @@ export default function ScreenViewPage() {
|
|||
|
||||
if (error || !screen) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
<div className="max-w-md rounded-xl border border-border bg-background p-8 text-center shadow-lg">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-destructive/20 to-warning/20 shadow-sm">
|
||||
<div className="from-muted to-muted/50 flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br">
|
||||
<div className="border-border bg-background max-w-md rounded-xl border p-8 text-center shadow-lg">
|
||||
<div className="from-destructive/20 to-warning/20 mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br shadow-sm">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-bold text-foreground">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-6 leading-relaxed text-muted-foreground">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<h2 className="text-foreground mb-3 text-xl font-bold">화면을 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||||
이전으로 돌아가기
|
||||
</Button>
|
||||
|
|
@ -273,10 +280,20 @@ export default function ScreenViewPage() {
|
|||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<div ref={containerRef} className="bg-background h-full w-full overflow-hidden">
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-foreground mt-4 text-sm font-medium">화면 준비 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 절대 위치 기반 렌더링 */}
|
||||
{layout && layout.components.length > 0 ? (
|
||||
{layoutReady && layout && layout.components.length > 0 ? (
|
||||
<div
|
||||
className="bg-background relative origin-top-left h-full flex justify-start items-start"
|
||||
className="bg-background relative flex h-full origin-top-left items-start justify-start"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
|
|
@ -289,27 +306,76 @@ export default function ScreenViewPage() {
|
|||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
|
||||
// 버튼은 scale에 맞춰 위치만 조정하면 됨 (scale = 1.0이면 그대로, scale < 1.0이면 왼쪽으로)
|
||||
// 하지만 x=0 컴포넌트는 width: 100%로 확장되므로, 그만큼 버튼을 오른쪽으로 이동
|
||||
const leftmostComponent = topLevelComponents.find((c) => c.position.x === 0);
|
||||
let widthOffset = 0;
|
||||
|
||||
if (leftmostComponent && containerWidth > 0) {
|
||||
const originalWidth = leftmostComponent.size?.width || screenWidth;
|
||||
const actualWidth = containerWidth / scale;
|
||||
widthOffset = Math.max(0, actualWidth - originalWidth);
|
||||
|
||||
console.log("📊 widthOffset 계산:", {
|
||||
containerWidth,
|
||||
scale,
|
||||
screenWidth,
|
||||
originalWidth,
|
||||
actualWidth,
|
||||
widthOffset,
|
||||
leftmostType: leftmostComponent.type,
|
||||
});
|
||||
}
|
||||
|
||||
const buttonGroups: Record<string, any[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
// 🔍 전체 버튼 목록 확인
|
||||
const allButtons = topLevelComponents.filter((component) => {
|
||||
const isButton =
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button");
|
||||
return isButton;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"🔍 메뉴에서 발견된 전체 버튼:",
|
||||
allButtons.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
positionX: b.position.x,
|
||||
positionY: b.position.y,
|
||||
})),
|
||||
);
|
||||
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
component.type === "button" ||
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType));
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button");
|
||||
|
||||
if (isButton) {
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||
| FlowVisibilityConfig
|
||||
| undefined;
|
||||
|
||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
||||
// 🔧 임시: 버튼 그룹 기능 완전 비활성화
|
||||
// TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요
|
||||
const DISABLE_BUTTON_GROUPS = false;
|
||||
|
||||
if (
|
||||
!DISABLE_BUTTON_GROUPS &&
|
||||
flowConfig?.enabled &&
|
||||
flowConfig.layoutBehavior === "auto-compact" &&
|
||||
flowConfig.groupId
|
||||
) {
|
||||
if (!buttonGroups[flowConfig.groupId]) {
|
||||
buttonGroups[flowConfig.groupId] = [];
|
||||
}
|
||||
buttonGroups[flowConfig.groupId].push(component);
|
||||
processedButtonIds.add(component.id);
|
||||
}
|
||||
// else: 모든 버튼을 개별 렌더링
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -318,92 +384,121 @@ export default function ScreenViewPage() {
|
|||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]); // 선택 해제
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
{regularComponents.map((component) => {
|
||||
// 버튼인 경우 위치 조정 (테이블이 늘어난 만큼 오른쪽으로 이동)
|
||||
const isButton =
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button");
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
))}
|
||||
const adjustedComponent =
|
||||
isButton && widthOffset > 0
|
||||
? {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: component.position.x + widthOffset,
|
||||
},
|
||||
}
|
||||
: component;
|
||||
|
||||
// 버튼일 경우 로그 출력
|
||||
if (isButton) {
|
||||
console.log("🔘 버튼 위치 조정:", {
|
||||
label: component.label,
|
||||
originalX: component.position.x,
|
||||
adjustedX: component.position.x + widthOffset,
|
||||
widthOffset,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]); // 선택 해제
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 플로우 버튼 그룹들 */}
|
||||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||||
|
|
@ -413,15 +508,37 @@ export default function ScreenViewPage() {
|
|||
const groupConfig = (firstButton as any).webTypeConfig
|
||||
?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||
|
||||
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
||||
const groupPosition = buttons.reduce(
|
||||
(min, button) => ({
|
||||
x: Math.min(min.x, button.position.x),
|
||||
y: Math.min(min.y, button.position.y),
|
||||
z: min.z,
|
||||
}),
|
||||
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
|
||||
);
|
||||
// 🔍 버튼 그룹 설정 확인
|
||||
console.log("🔍 버튼 그룹 설정:", {
|
||||
groupId,
|
||||
buttonCount: buttons.length,
|
||||
buttons: buttons.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
x: b.position.x,
|
||||
y: b.position.y,
|
||||
})),
|
||||
groupConfig: {
|
||||
layoutBehavior: groupConfig.layoutBehavior,
|
||||
groupDirection: groupConfig.groupDirection,
|
||||
groupAlign: groupConfig.groupAlign,
|
||||
groupGap: groupConfig.groupGap,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되,
|
||||
// 각 버튼의 상대 위치는 원래 위치를 유지
|
||||
const firstButtonPosition = {
|
||||
x: buttons[0].position.x,
|
||||
y: buttons[0].position.y,
|
||||
z: buttons[0].position.z || 2,
|
||||
};
|
||||
|
||||
// 버튼 그룹 위치에도 widthOffset 적용
|
||||
const adjustedGroupPosition = {
|
||||
...firstButtonPosition,
|
||||
x: firstButtonPosition.x + widthOffset,
|
||||
};
|
||||
|
||||
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
|
|
@ -451,9 +568,9 @@ export default function ScreenViewPage() {
|
|||
key={`flow-button-group-${groupId}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${groupPosition.x}px`,
|
||||
top: `${groupPosition.y}px`,
|
||||
zIndex: groupPosition.z,
|
||||
left: `${adjustedGroupPosition.x}px`,
|
||||
top: `${adjustedGroupPosition.y}px`,
|
||||
zIndex: adjustedGroupPosition.z,
|
||||
width: `${groupWidth}px`,
|
||||
height: `${groupHeight}px`,
|
||||
}}
|
||||
|
|
@ -463,9 +580,14 @@ export default function ScreenViewPage() {
|
|||
groupConfig={groupConfig}
|
||||
isDesignMode={false}
|
||||
renderButton={(button) => {
|
||||
// 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치
|
||||
const relativeButton = {
|
||||
...button,
|
||||
position: { x: 0, y: 0, z: button.position.z || 1 },
|
||||
position: {
|
||||
x: button.position.x - firstButtonPosition.x,
|
||||
y: button.position.y - firstButtonPosition.y,
|
||||
z: button.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule";
|
||||
|
||||
interface AutoConfigPanelProps {
|
||||
partType: CodePartType;
|
||||
config?: any;
|
||||
onChange: (config: any) => void;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
||||
partType,
|
||||
config = {},
|
||||
onChange,
|
||||
isPreview = false,
|
||||
}) => {
|
||||
// 1. 순번 (자동 증가)
|
||||
if (partType === "sequence") {
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">순번 자릿수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={config.sequenceLength || 3}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 })
|
||||
}
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
예: 3 → 001, 4 → 0001
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">시작 번호</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.startFrom || 1}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, startFrom: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
순번이 시작될 번호
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 숫자 (고정 자릿수)
|
||||
if (partType === "number") {
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">숫자 자릿수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={config.numberLength || 4}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, numberLength: parseInt(e.target.value) || 4 })
|
||||
}
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
예: 4 → 0001, 5 → 00001
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">숫자 값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={config.numberValue || 0}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, numberValue: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
고정으로 사용할 숫자
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 날짜
|
||||
if (partType === "date") {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">날짜 형식</Label>
|
||||
<Select
|
||||
value={config.dateFormat || "YYYYMMDD"}
|
||||
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
|
||||
disabled={isPreview}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_FORMAT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
||||
{option.label} ({option.example})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
현재 날짜가 자동으로 입력됩니다
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 문자
|
||||
if (partType === "text") {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">텍스트 값</Label>
|
||||
<Input
|
||||
value={config.textValue || ""}
|
||||
onChange={(e) => onChange({ ...config, textValue: e.target.value })}
|
||||
placeholder="예: PRJ, CODE, PROD"
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
고정으로 사용할 텍스트 또는 코드
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ManualConfigPanelProps {
|
||||
config?: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
onChange: (config: any) => void;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
export const ManualConfigPanel: React.FC<ManualConfigPanelProps> = ({
|
||||
config = {},
|
||||
onChange,
|
||||
isPreview = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">입력값</Label>
|
||||
<Input
|
||||
value={config.value || ""}
|
||||
onChange={(e) => onChange({ ...config, value: e.target.value })}
|
||||
placeholder={config.placeholder || "값을 입력하세요"}
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
코드 생성 시 이 값이 그대로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">플레이스홀더 (선택사항)</Label>
|
||||
<Input
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
placeholder="예: 부서코드 입력"
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { NumberingRulePart, CodePartType, GenerationMethod, CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||
import { AutoConfigPanel } from "./AutoConfigPanel";
|
||||
import { ManualConfigPanel } from "./ManualConfigPanel";
|
||||
|
||||
interface NumberingRuleCardProps {
|
||||
part: NumberingRulePart;
|
||||
onUpdate: (updates: Partial<NumberingRulePart>) => void;
|
||||
onDelete: () => void;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||
part,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isPreview = false,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||
규칙 {part.order}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onDelete}
|
||||
className="text-destructive h-7 w-7 sm:h-8 sm:w-8"
|
||||
disabled={isPreview}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
||||
<Select
|
||||
value={part.partType}
|
||||
onValueChange={(value) => onUpdate({ partType: value as CodePartType })}
|
||||
disabled={isPreview}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CODE_PART_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">생성 방식</Label>
|
||||
<Select
|
||||
value={part.generationMethod}
|
||||
onValueChange={(value) => onUpdate({ generationMethod: value as GenerationMethod })}
|
||||
disabled={isPreview}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs sm:text-sm">
|
||||
자동 생성
|
||||
</SelectItem>
|
||||
<SelectItem value="manual" className="text-xs sm:text-sm">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{part.generationMethod === "auto" ? (
|
||||
<AutoConfigPanel
|
||||
partType={part.partType}
|
||||
config={part.autoConfig}
|
||||
onChange={(autoConfig) => onUpdate({ autoConfig })}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
) : (
|
||||
<ManualConfigPanel
|
||||
config={part.manualConfig}
|
||||
onChange={(manualConfig) => onUpdate({ manualConfig })}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||
import {
|
||||
getNumberingRules,
|
||||
createNumberingRule,
|
||||
updateNumberingRule,
|
||||
deleteNumberingRule,
|
||||
} from "@/lib/api/numberingRule";
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
initialConfig?: NumberingRuleConfig;
|
||||
onSave?: (config: NumberingRuleConfig) => void;
|
||||
onChange?: (config: NumberingRuleConfig) => void;
|
||||
maxRules?: number;
|
||||
isPreview?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||
initialConfig,
|
||||
onSave,
|
||||
onChange,
|
||||
maxRules = 6,
|
||||
isPreview = false,
|
||||
className = "",
|
||||
}) => {
|
||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
|
||||
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, []);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setSavedRules(response.data);
|
||||
} else {
|
||||
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`로딩 실패: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
onChange?.(currentRule);
|
||||
}
|
||||
}, [currentRule, onChange]);
|
||||
|
||||
const handleAddPart = useCallback(() => {
|
||||
if (!currentRule) return;
|
||||
|
||||
if (currentRule.parts.length >= maxRules) {
|
||||
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPart: NumberingRulePart = {
|
||||
id: `part-${Date.now()}`,
|
||||
order: currentRule.parts.length + 1,
|
||||
partType: "text",
|
||||
generationMethod: "auto",
|
||||
autoConfig: { textValue: "CODE" },
|
||||
};
|
||||
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return { ...prev, parts: [...prev.parts, newPart] };
|
||||
});
|
||||
|
||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||
}, [currentRule, maxRules]);
|
||||
|
||||
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeletePart = useCallback((partId: string) => {
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts
|
||||
.filter((part) => part.id !== partId)
|
||||
.map((part, index) => ({ ...part, order: index + 1 })),
|
||||
};
|
||||
});
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentRule) {
|
||||
toast.error("저장할 규칙이 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRule.parts.length === 0) {
|
||||
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||
|
||||
let response;
|
||||
if (existing) {
|
||||
response = await updateNumberingRule(currentRule.ruleId, currentRule);
|
||||
} else {
|
||||
response = await createNumberingRule(currentRule);
|
||||
}
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSavedRules((prev) => {
|
||||
if (existing) {
|
||||
return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r));
|
||||
} else {
|
||||
return [...prev, response.data!];
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentRule(response.data);
|
||||
setSelectedRuleId(response.data.ruleId);
|
||||
|
||||
await onSave?.(response.data);
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
toast.error(response.error || "저장 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`저장 실패: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, savedRules, onSave]);
|
||||
|
||||
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
setCurrentRule(rule);
|
||||
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSavedRule = useCallback(async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRule(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
||||
if (selectedRuleId === ruleId) {
|
||||
setSelectedRuleId(null);
|
||||
setCurrentRule(null);
|
||||
}
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`삭제 실패: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedRuleId]);
|
||||
|
||||
const handleNewRule = useCallback(() => {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 채번 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "global",
|
||||
};
|
||||
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(newRule);
|
||||
|
||||
toast.success("새 규칙이 생성되었습니다");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full gap-4 ${className}`}>
|
||||
{/* 좌측: 저장된 규칙 목록 */}
|
||||
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{editingLeftTitle ? (
|
||||
<Input
|
||||
value={leftTitle}
|
||||
onChange={(e) => setLeftTitle(e.target.value)}
|
||||
onBlur={() => setEditingLeftTitle(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
|
||||
className="h-8 text-sm font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingLeftTitle(true)}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 규칙 생성
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
) : savedRules.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground">저장된 규칙이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
savedRules.map((rule) => (
|
||||
<Card
|
||||
key={rule.ruleId}
|
||||
className={`cursor-pointer border-border transition-colors hover:bg-accent ${
|
||||
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||
}`}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
>
|
||||
<CardHeader className="p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
규칙 {rule.parts.length}개
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSavedRule(rule.ruleId);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0">
|
||||
<NumberingRulePreview config={rule} compact />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-full w-px bg-border"></div>
|
||||
|
||||
{/* 우측: 편집 영역 */}
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
{!currentRule ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">
|
||||
규칙을 선택해주세요
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
좌측에서 규칙을 선택하거나 새로 생성하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
{editingRightTitle ? (
|
||||
<Input
|
||||
value={rightTitle}
|
||||
onChange={(e) => setRightTitle(e.target.value)}
|
||||
onBlur={() => setEditingRightTitle(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
|
||||
className="h-8 text-sm font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingRightTitle(true)}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) =>
|
||||
setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))
|
||||
}
|
||||
className="h-9"
|
||||
placeholder="예: 프로젝트 코드"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">적용 범위</Label>
|
||||
<Select
|
||||
value={currentRule.scopeType || "global"}
|
||||
onValueChange={(value: "global" | "menu") =>
|
||||
setCurrentRule((prev) => ({ ...prev!, scopeType: value }))
|
||||
}
|
||||
disabled={isPreview}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">회사 전체</SelectItem>
|
||||
<SelectItem value="menu">메뉴별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
{currentRule.scopeType === "menu"
|
||||
? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다"
|
||||
: "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">미리보기</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NumberingRulePreview config={currentRule} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">코드 구성</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentRule.parts.length}/{maxRules}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentRule.parts.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
규칙을 추가하여 코드를 구성하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{currentRule.parts.map((part) => (
|
||||
<NumberingRuleCard
|
||||
key={part.id}
|
||||
part={part}
|
||||
onUpdate={(updates) => handleUpdatePart(part.id, updates)}
|
||||
onDelete={() => handleDeletePart(part.id)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleAddPart}
|
||||
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
||||
variant="outline"
|
||||
className="h-9 flex-1 text-sm"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isPreview || loading}
|
||||
className="h-9 flex-1 text-sm"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
interface NumberingRulePreviewProps {
|
||||
config: NumberingRuleConfig;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||
config,
|
||||
compact = false
|
||||
}) => {
|
||||
const generatedCode = useMemo(() => {
|
||||
if (!config.parts || config.parts.length === 0) {
|
||||
return "규칙을 추가해주세요";
|
||||
}
|
||||
|
||||
const parts = config.parts
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((part) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "XXX";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
// 1. 순번 (자동 증가)
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
return String(startFrom).padStart(length, "0");
|
||||
}
|
||||
|
||||
// 2. 숫자 (고정 자릿수)
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 0;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
// 3. 날짜
|
||||
case "date": {
|
||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 문자
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
|
||||
default:
|
||||
return "XXX";
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join(config.separator || "");
|
||||
}, [config]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted px-2 py-1">
|
||||
<code className="text-xs font-mono text-foreground">{generatedCode}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">코드 미리보기</p>
|
||||
<div className="rounded-md bg-muted p-3 sm:p-4">
|
||||
<code className="text-sm font-mono text-foreground sm:text-base">{generatedCode}</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1678,24 +1678,19 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
return applyStyles(
|
||||
<Button
|
||||
onClick={handleButtonClick}
|
||||
disabled={readonly}
|
||||
size="sm"
|
||||
variant={config?.variant || "default"}
|
||||
className="w-full"
|
||||
style={{ height: "100%" }}
|
||||
style={{
|
||||
// 컴포넌트 스타일과 설정 스타일 모두 적용
|
||||
...comp.style,
|
||||
// 크기는 className으로 처리하므로 CSS 크기 속성 제거
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// 설정값이 있으면 우선 적용, 없으면 컴포넌트 스타일 사용
|
||||
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
||||
color: config?.textColor || comp.style?.color,
|
||||
borderColor: config?.borderColor || comp.style?.borderColor,
|
||||
// 설정값이 있으면 우선 적용
|
||||
backgroundColor: config?.backgroundColor,
|
||||
color: config?.textColor,
|
||||
borderColor: config?.borderColor,
|
||||
}}
|
||||
>
|
||||
{label || "버튼"}
|
||||
|
|
|
|||
|
|
@ -483,9 +483,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
disabled={config?.disabled}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundColor: config?.backgroundColor,
|
||||
color: config?.textColor,
|
||||
// 컴포넌트 스타일 먼저 적용
|
||||
...comp.style,
|
||||
// 설정값이 있으면 우선 적용
|
||||
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
||||
color: config?.textColor || comp.style?.color,
|
||||
}}
|
||||
>
|
||||
{label || "버튼"}
|
||||
|
|
|
|||
|
|
@ -399,13 +399,26 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
willUse100Percent: positionX === 0,
|
||||
});
|
||||
|
||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||
const getWidth = () => {
|
||||
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||
if (style?.width) {
|
||||
return style.width;
|
||||
}
|
||||
// 2순위: left가 0이면 100%
|
||||
if (positionX === 0) {
|
||||
return "100%";
|
||||
}
|
||||
// 3순위: size.width 픽셀 값
|
||||
return size?.width || 200;
|
||||
};
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...style, // 먼저 적용하고
|
||||
left: positionX,
|
||||
top: position?.y || 0,
|
||||
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
|
||||
width: positionX === 0 ? "100%" : (size?.width || 200),
|
||||
width: getWidth(), // 우선순위에 따른 너비
|
||||
height: finalHeight,
|
||||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
|
|
|
|||
|
|
@ -200,19 +200,58 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
: {};
|
||||
|
||||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||
// 너비 우선순위: style.width > size.width (픽셀값)
|
||||
// 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값)
|
||||
const getWidth = () => {
|
||||
// 1순위: style.width가 있으면 우선 사용
|
||||
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||
if (componentStyle?.width) {
|
||||
console.log("✅ [getWidth] style.width 사용:", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
styleWidth: componentStyle.width,
|
||||
gridColumns: (component as any).gridColumns,
|
||||
componentStyle: componentStyle,
|
||||
baseStyle: {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: componentStyle.width,
|
||||
height: getHeight(),
|
||||
},
|
||||
});
|
||||
return componentStyle.width;
|
||||
}
|
||||
|
||||
// 2순위: size.width (픽셀)
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
return `${Math.max(size?.width || 120, 120)}px`;
|
||||
// 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
|
||||
const isButtonComponent =
|
||||
(component.type === "widget" && (component as WidgetComponent).widgetType === "button") ||
|
||||
(component.type === "component" && (component as any).componentType?.includes("button"));
|
||||
|
||||
if (position.x === 0 && !isButtonComponent) {
|
||||
console.log("⚠️ [getWidth] 100% 사용 (x=0):", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
});
|
||||
return "100%";
|
||||
}
|
||||
|
||||
return `${size?.width || 100}px`;
|
||||
// 3순위: size.width (픽셀)
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
const width = `${Math.max(size?.width || 120, 120)}px`;
|
||||
console.log("📏 [getWidth] 픽셀 사용 (table-list):", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
width,
|
||||
});
|
||||
return width;
|
||||
}
|
||||
|
||||
const width = `${size?.width || 100}px`;
|
||||
console.log("📏 [getWidth] 픽셀 사용 (기본):", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
width,
|
||||
sizeWidth: size?.width,
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
const getHeight = () => {
|
||||
|
|
@ -247,17 +286,51 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
|
||||
width: position.x === 0 ? "100%" : getWidth(),
|
||||
height: getHeight(), // 모든 컴포넌트 고정 높이로 변경
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
|
||||
width: getWidth(), // getWidth()가 모든 우선순위를 처리
|
||||
height: getHeight(),
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
...componentStyle,
|
||||
// style.width가 있어도 position.x === 0이면 100%로 강제
|
||||
...(position.x === 0 && { width: "100%" }),
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
// 🔍 DOM 렌더링 후 실제 크기 측정
|
||||
const innerDivRef = React.useRef<HTMLDivElement>(null);
|
||||
const outerDivRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (outerDivRef.current && innerDivRef.current) {
|
||||
const outerRect = outerDivRef.current.getBoundingClientRect();
|
||||
const innerRect = innerDivRef.current.getBoundingClientRect();
|
||||
const computedOuter = window.getComputedStyle(outerDivRef.current);
|
||||
const computedInner = window.getComputedStyle(innerDivRef.current);
|
||||
|
||||
console.log("📐 [DOM 실제 크기 상세]:", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
gridColumns: (component as any).gridColumns,
|
||||
"1. baseStyle.width": baseStyle.width,
|
||||
"2. 외부 div (파란 테두리)": {
|
||||
width: `${outerRect.width}px`,
|
||||
height: `${outerRect.height}px`,
|
||||
computedWidth: computedOuter.width,
|
||||
computedHeight: computedOuter.height,
|
||||
},
|
||||
"3. 내부 div (컨텐츠 래퍼)": {
|
||||
width: `${innerRect.width}px`,
|
||||
height: `${innerRect.height}px`,
|
||||
computedWidth: computedInner.width,
|
||||
computedHeight: computedInner.height,
|
||||
className: innerDivRef.current.className,
|
||||
inlineStyle: innerDivRef.current.getAttribute("style"),
|
||||
},
|
||||
"4. 너비 비교": {
|
||||
"외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`,
|
||||
"비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
|
|
@ -279,7 +352,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={outerDivRef}
|
||||
id={`component-${id}`}
|
||||
data-component-id={id}
|
||||
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
||||
style={{ ...baseStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
|
|
@ -290,10 +365,15 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
>
|
||||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div
|
||||
ref={
|
||||
component.type === "component" && (component as any).componentType === "flow-widget" ? contentRef : undefined
|
||||
}
|
||||
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} w-full max-w-full overflow-visible`}
|
||||
ref={(node) => {
|
||||
// 멀티 ref 처리
|
||||
innerDivRef.current = node;
|
||||
if (component.type === "component" && (component as any).componentType === "flow-widget") {
|
||||
(contentRef as any).current = node;
|
||||
}
|
||||
}}
|
||||
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
|
||||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
|
|
|
|||
|
|
@ -2012,76 +2012,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const isTableList = component.id === "table-list";
|
||||
|
||||
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
||||
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
|
||||
let gridColumns = 1; // 기본값
|
||||
|
||||
// 특수 컴포넌트
|
||||
if (isCardDisplay) {
|
||||
gridColumns = 8;
|
||||
gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67%
|
||||
} else if (isTableList) {
|
||||
gridColumns = 12; // 테이블은 전체 너비
|
||||
gridColumns = currentGridColumns; // 테이블은 전체 너비
|
||||
} else {
|
||||
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||
const webType = component.webType;
|
||||
const componentId = component.id;
|
||||
|
||||
// 웹타입별 기본 컬럼 수 매핑
|
||||
const gridColumnsMap: Record<string, number> = {
|
||||
// 웹타입별 기본 비율 매핑 (12컬럼 기준 비율)
|
||||
const gridColumnsRatioMap: Record<string, number> = {
|
||||
// 입력 컴포넌트 (INPUT 카테고리)
|
||||
"text-input": 4, // 텍스트 입력 (33%)
|
||||
"number-input": 2, // 숫자 입력 (16.67%)
|
||||
"email-input": 4, // 이메일 입력 (33%)
|
||||
"tel-input": 3, // 전화번호 입력 (25%)
|
||||
"date-input": 3, // 날짜 입력 (25%)
|
||||
"datetime-input": 4, // 날짜시간 입력 (33%)
|
||||
"time-input": 2, // 시간 입력 (16.67%)
|
||||
"textarea-basic": 6, // 텍스트 영역 (50%)
|
||||
"select-basic": 3, // 셀렉트 (25%)
|
||||
"checkbox-basic": 2, // 체크박스 (16.67%)
|
||||
"radio-basic": 3, // 라디오 (25%)
|
||||
"file-basic": 4, // 파일 (33%)
|
||||
"file-upload": 4, // 파일 업로드 (33%)
|
||||
"slider-basic": 3, // 슬라이더 (25%)
|
||||
"toggle-switch": 2, // 토글 스위치 (16.67%)
|
||||
"repeater-field-group": 6, // 반복 필드 그룹 (50%)
|
||||
"text-input": 4 / 12, // 텍스트 입력 (33%)
|
||||
"number-input": 2 / 12, // 숫자 입력 (16.67%)
|
||||
"email-input": 4 / 12, // 이메일 입력 (33%)
|
||||
"tel-input": 3 / 12, // 전화번호 입력 (25%)
|
||||
"date-input": 3 / 12, // 날짜 입력 (25%)
|
||||
"datetime-input": 4 / 12, // 날짜시간 입력 (33%)
|
||||
"time-input": 2 / 12, // 시간 입력 (16.67%)
|
||||
"textarea-basic": 6 / 12, // 텍스트 영역 (50%)
|
||||
"select-basic": 3 / 12, // 셀렉트 (25%)
|
||||
"checkbox-basic": 2 / 12, // 체크박스 (16.67%)
|
||||
"radio-basic": 3 / 12, // 라디오 (25%)
|
||||
"file-basic": 4 / 12, // 파일 (33%)
|
||||
"file-upload": 4 / 12, // 파일 업로드 (33%)
|
||||
"slider-basic": 3 / 12, // 슬라이더 (25%)
|
||||
"toggle-switch": 2 / 12, // 토글 스위치 (16.67%)
|
||||
"repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%)
|
||||
|
||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||
"label-basic": 2, // 라벨 (16.67%)
|
||||
"text-display": 3, // 텍스트 표시 (25%)
|
||||
"card-display": 8, // 카드 (66.67%)
|
||||
"badge-basic": 1, // 배지 (8.33%)
|
||||
"alert-basic": 6, // 알림 (50%)
|
||||
"divider-basic": 12, // 구분선 (100%)
|
||||
"divider-line": 12, // 구분선 (100%)
|
||||
"accordion-basic": 12, // 아코디언 (100%)
|
||||
"table-list": 12, // 테이블 리스트 (100%)
|
||||
"image-display": 4, // 이미지 표시 (33%)
|
||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
||||
"flow-widget": 12, // 플로우 위젯 (100%)
|
||||
"label-basic": 2 / 12, // 라벨 (16.67%)
|
||||
"text-display": 3 / 12, // 텍스트 표시 (25%)
|
||||
"card-display": 8 / 12, // 카드 (66.67%)
|
||||
"badge-basic": 1 / 12, // 배지 (8.33%)
|
||||
"alert-basic": 6 / 12, // 알림 (50%)
|
||||
"divider-basic": 1, // 구분선 (100%)
|
||||
"divider-line": 1, // 구분선 (100%)
|
||||
"accordion-basic": 1, // 아코디언 (100%)
|
||||
"table-list": 1, // 테이블 리스트 (100%)
|
||||
"image-display": 4 / 12, // 이미지 표시 (33%)
|
||||
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
|
||||
"flow-widget": 1, // 플로우 위젯 (100%)
|
||||
|
||||
// 액션 컴포넌트 (ACTION 카테고리)
|
||||
"button-basic": 1, // 버튼 (8.33%)
|
||||
"button-primary": 1, // 프라이머리 버튼 (8.33%)
|
||||
"button-secondary": 1, // 세컨더리 버튼 (8.33%)
|
||||
"icon-button": 1, // 아이콘 버튼 (8.33%)
|
||||
"button-basic": 1 / 12, // 버튼 (8.33%)
|
||||
"button-primary": 1 / 12, // 프라이머리 버튼 (8.33%)
|
||||
"button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%)
|
||||
"icon-button": 1 / 12, // 아이콘 버튼 (8.33%)
|
||||
|
||||
// 레이아웃 컴포넌트
|
||||
"container-basic": 6, // 컨테이너 (50%)
|
||||
"section-basic": 12, // 섹션 (100%)
|
||||
"panel-basic": 6, // 패널 (50%)
|
||||
"container-basic": 6 / 12, // 컨테이너 (50%)
|
||||
"section-basic": 1, // 섹션 (100%)
|
||||
"panel-basic": 6 / 12, // 패널 (50%)
|
||||
|
||||
// 기타
|
||||
"image-basic": 4, // 이미지 (33%)
|
||||
"icon-basic": 1, // 아이콘 (8.33%)
|
||||
"progress-bar": 4, // 프로그레스 바 (33%)
|
||||
"chart-basic": 6, // 차트 (50%)
|
||||
"image-basic": 4 / 12, // 이미지 (33%)
|
||||
"icon-basic": 1 / 12, // 아이콘 (8.33%)
|
||||
"progress-bar": 4 / 12, // 프로그레스 바 (33%)
|
||||
"chart-basic": 6 / 12, // 차트 (50%)
|
||||
};
|
||||
|
||||
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
|
||||
// defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용
|
||||
if (component.defaultSize?.gridColumnSpan === "full") {
|
||||
gridColumns = 12;
|
||||
gridColumns = currentGridColumns;
|
||||
} else {
|
||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||
// componentId 또는 webType으로 비율 찾기, 없으면 기본값 25%
|
||||
const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25;
|
||||
// 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns)
|
||||
gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns)));
|
||||
}
|
||||
|
||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
||||
|
|
@ -2141,6 +2144,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
}
|
||||
|
||||
// gridColumns에 맞춰 width를 퍼센트로 계산
|
||||
const widthPercent = (gridColumns / currentGridColumns) * 100;
|
||||
|
||||
console.log("🎨 [컴포넌트 생성] 너비 계산:", {
|
||||
componentName: component.name,
|
||||
componentId: component.id,
|
||||
currentGridColumns,
|
||||
gridColumns,
|
||||
widthPercent: `${widthPercent}%`,
|
||||
calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`,
|
||||
});
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
|
|
@ -2162,6 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
labelMarginBottom: "4px",
|
||||
width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -4238,7 +4254,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<UnifiedPropertiesPanel
|
||||
selectedComponent={selectedComponent || undefined}
|
||||
tables={tables}
|
||||
gridSettings={layout.gridSettings}
|
||||
onUpdateProperty={updateComponentProperty}
|
||||
onGridSettingsChange={(newSettings) => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
gridSettings: newSettings,
|
||||
}));
|
||||
}}
|
||||
onDeleteComponent={deleteComponent}
|
||||
onCopyComponent={copyComponent}
|
||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||
|
|
|
|||
|
|
@ -1250,14 +1250,33 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
|
||||
{/* 실제 컴포넌트 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
style={(() => {
|
||||
const style = {
|
||||
position: "absolute" as const,
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
};
|
||||
|
||||
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
|
||||
if (
|
||||
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||
(component.type === "component" && (component as any).componentType?.includes("button"))
|
||||
) {
|
||||
console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
|
||||
id: component.id,
|
||||
label: component.label,
|
||||
position: component.position,
|
||||
size: component.size,
|
||||
componentStyle: component.style,
|
||||
appliedStyle: style,
|
||||
});
|
||||
}
|
||||
|
||||
return style;
|
||||
})()}
|
||||
>
|
||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
|
|
|
|||
|
|
@ -127,10 +127,27 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수: <span className="text-primary">{gridSettings.columns}</span>
|
||||
컬럼 수
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="columns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={gridSettings.columns}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 24) {
|
||||
updateSetting("columns", value);
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">/ 24</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="columns"
|
||||
id="columns-slider"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
|
|
@ -139,8 +156,8 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
className="w-full"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>1</span>
|
||||
<span>24</span>
|
||||
<span>1열</span>
|
||||
<span>24열</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ interface PropertiesPanelProps {
|
|||
draggedComponent: ComponentData | null;
|
||||
currentPosition: { x: number; y: number; z: number };
|
||||
};
|
||||
gridSettings?: {
|
||||
columns: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
snapToGrid: boolean;
|
||||
};
|
||||
onUpdateProperty: (path: string, value: unknown) => void;
|
||||
onDeleteComponent: () => void;
|
||||
onCopyComponent: () => void;
|
||||
|
|
@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
selectedComponent,
|
||||
tables = [],
|
||||
dragState,
|
||||
gridSettings,
|
||||
onUpdateProperty,
|
||||
onDeleteComponent,
|
||||
onCopyComponent,
|
||||
|
|
@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
||||
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
||||
<>
|
||||
{/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
|
||||
{/* 🆕 그리드 컬럼 수 직접 입력 */}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-sm font-medium">컴포넌트 너비</Label>
|
||||
<Label htmlFor="gridColumns" className="text-sm font-medium">
|
||||
차지 컬럼 수
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Input
|
||||
id="gridColumns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={gridSettings?.columns || 12}
|
||||
value={(selectedComponent as any)?.gridColumns || 1}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
const maxColumns = gridSettings?.columns || 12;
|
||||
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
||||
// gridColumns 업데이트
|
||||
onUpdateProperty("gridColumns", value);
|
||||
|
||||
// width를 퍼센트로 계산하여 업데이트
|
||||
const widthPercent = (value / maxColumns) * 100;
|
||||
onUpdateProperty("style.width", `${widthPercent}%`);
|
||||
|
||||
// localWidthSpan도 업데이트
|
||||
setLocalWidthSpan(calculateWidthSpan(`${widthPercent}%`, value));
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
/ {gridSettings?.columns || 12}열
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
이 컴포넌트가 차지할 그리드 컬럼 수 (1-{gridSettings?.columns || 12})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-sm font-medium">미리 정의된 너비</Label>
|
||||
<Select
|
||||
value={localWidthSpan}
|
||||
onValueChange={(value) => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ import {
|
|||
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
||||
|
||||
// 컬럼 스팬 숫자 배열 (1~12)
|
||||
const COLUMN_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
// 동적으로 컬럼 수 배열 생성 (gridSettings.columns 기반)
|
||||
const generateColumnNumbers = (maxColumns: number) => {
|
||||
return Array.from({ length: maxColumns }, (_, i) => i + 1);
|
||||
};
|
||||
import { cn } from "@/lib/utils";
|
||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||
|
|
@ -52,11 +55,23 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
|||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
import StyleEditor from "../StyleEditor";
|
||||
import ResolutionPanel from "./ResolutionPanel";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
|
||||
|
||||
interface UnifiedPropertiesPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
tables: TableInfo[];
|
||||
gridSettings?: {
|
||||
columns: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
snapToGrid: boolean;
|
||||
showGrid: boolean;
|
||||
gridColor?: string;
|
||||
gridOpacity?: number;
|
||||
};
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
onGridSettingsChange?: (settings: any) => void;
|
||||
onDeleteComponent?: (componentId: string) => void;
|
||||
onCopyComponent?: (componentId: string) => void;
|
||||
currentTable?: TableInfo;
|
||||
|
|
@ -74,7 +89,9 @@ interface UnifiedPropertiesPanelProps {
|
|||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||
selectedComponent,
|
||||
tables,
|
||||
gridSettings,
|
||||
onUpdateProperty,
|
||||
onGridSettingsChange,
|
||||
onDeleteComponent,
|
||||
onCopyComponent,
|
||||
currentTable,
|
||||
|
|
@ -98,23 +115,148 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||
|
||||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정은 표시
|
||||
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||
const updateGridSetting = (key: string, value: any) => {
|
||||
if (onGridSettingsChange && gridSettings) {
|
||||
onGridSettingsChange({
|
||||
...gridSettings,
|
||||
[key]: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 격자 설정 렌더링 (early return 이전에 정의)
|
||||
const renderGridSettings = () => {
|
||||
if (!gridSettings || !onGridSettingsChange) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Grid3X3 className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">격자 설정</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 토글들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{gridSettings.showGrid ? (
|
||||
<Eye className="text-primary h-3 w-3" />
|
||||
) : (
|
||||
<EyeOff className="text-muted-foreground h-3 w-3" />
|
||||
)}
|
||||
<Label htmlFor="showGrid" className="text-xs font-medium">
|
||||
격자 표시
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={gridSettings.showGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="text-primary h-3 w-3" />
|
||||
<Label htmlFor="snapToGrid" className="text-xs font-medium">
|
||||
격자 스냅
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="snapToGrid"
|
||||
checked={gridSettings.snapToGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 수 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="columns"
|
||||
type="number"
|
||||
min={1}
|
||||
value={gridSettings.columns}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
updateGridSetting("columns", value);
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder="1 이상의 숫자"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
1 이상의 숫자를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 간격 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="gap" className="text-xs font-medium">
|
||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="gap"
|
||||
min={0}
|
||||
max={40}
|
||||
step={2}
|
||||
value={[gridSettings.gap]}
|
||||
onValueChange={([value]) => updateGridSetting("gap", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 여백 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="padding" className="text-xs font-medium">
|
||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="padding"
|
||||
min={0}
|
||||
max={60}
|
||||
step={4}
|
||||
value={[gridSettings.padding]}
|
||||
onValueChange={([value]) => updateGridSetting("padding", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 해상도 설정만 표시 */}
|
||||
{/* 해상도 설정과 격자 설정 표시 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Monitor className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Monitor className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||
</div>
|
||||
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||
</div>
|
||||
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 격자 설정 */}
|
||||
{renderGridSettings()}
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<Separator className="my-4" />
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
|
|
@ -283,22 +425,31 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Grid</Label>
|
||||
<Select
|
||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_NUMBERS.map((span) => (
|
||||
<SelectItem key={span} value={span.toString()} style={{ fontSize: "12px" }}>
|
||||
{span}열
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-xs">차지 컬럼 수</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={gridSettings?.columns || 12}
|
||||
value={(selectedComponent as any).gridColumns || 1}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
const maxColumns = gridSettings?.columns || 12;
|
||||
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
||||
handleUpdate("gridColumns", value);
|
||||
|
||||
// width를 퍼센트로 계산하여 업데이트
|
||||
const widthPercent = (value / maxColumns) * 100;
|
||||
handleUpdate("style.width", `${widthPercent}%`);
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
|
||||
/{gridSettings?.columns || 12}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
|
|
@ -896,6 +1047,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 격자 설정 - 해상도 설정 아래 표시 */}
|
||||
{renderGridSettings()}
|
||||
{gridSettings && onGridSettingsChange && <Separator className="my-2" />}
|
||||
|
||||
{/* 기본 설정 */}
|
||||
{renderBasicTab()}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* 채번 규칙 템플릿
|
||||
* 화면관리 시스템에 등록하여 드래그앤드롭으로 사용
|
||||
*/
|
||||
|
||||
import { Hash } from "lucide-react";
|
||||
|
||||
export const getDefaultNumberingRuleConfig = () => ({
|
||||
template_code: "numbering-rule-designer",
|
||||
template_name: "코드 채번 규칙",
|
||||
template_name_eng: "Numbering Rule Designer",
|
||||
description: "코드 자동 채번 규칙을 설정하는 컴포넌트",
|
||||
category: "admin" as const,
|
||||
icon_name: "hash",
|
||||
default_size: {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "numbering-rule" as const,
|
||||
label: "채번 규칙 설정",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 1200, height: 800 },
|
||||
ruleConfig: {
|
||||
ruleId: "new-rule",
|
||||
ruleName: "새 채번 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
},
|
||||
maxRules: 6,
|
||||
style: {
|
||||
padding: "16px",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 템플릿 패널에서 사용할 컴포넌트 정보
|
||||
*/
|
||||
export const numberingRuleTemplate = {
|
||||
id: "numbering-rule",
|
||||
name: "채번 규칙",
|
||||
description: "코드 자동 채번 규칙 설정",
|
||||
category: "admin" as const,
|
||||
icon: Hash,
|
||||
defaultSize: { width: 1200, height: 800 },
|
||||
components: [
|
||||
{
|
||||
type: "numbering-rule" as const,
|
||||
widgetType: undefined,
|
||||
label: "채번 규칙 설정",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 1200, height: 800 },
|
||||
style: {
|
||||
padding: "16px",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
ruleConfig: {
|
||||
ruleId: "new-rule",
|
||||
ruleName: "새 채번 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
},
|
||||
maxRules: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { TabsComponent, TabItem, ScreenDefinition } from "@/types";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, FileQuestion } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
||||
interface TabsWidgetProps {
|
||||
component: TabsComponent;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 위젯 컴포넌트
|
||||
* 각 탭에 다른 화면을 표시할 수 있습니다
|
||||
*/
|
||||
export const TabsWidget: React.FC<TabsWidgetProps> = ({ component, isPreview = false }) => {
|
||||
// componentConfig에서 설정 읽기 (새 컴포넌트 시스템)
|
||||
const config = (component as any).componentConfig || component;
|
||||
const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config;
|
||||
|
||||
// console.log("🔍 TabsWidget 렌더링:", {
|
||||
// component,
|
||||
// componentConfig: (component as any).componentConfig,
|
||||
// tabs,
|
||||
// tabsLength: tabs.length
|
||||
// });
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.id || "");
|
||||
const [loadedScreens, setLoadedScreens] = useState<Record<string, any>>({});
|
||||
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 탭 변경 시 화면 로드
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
|
||||
const currentTab = tabs.find((tab) => tab.id === activeTab);
|
||||
if (!currentTab || !currentTab.screenId) return;
|
||||
|
||||
// 이미 로드된 화면이면 스킵
|
||||
if (loadedScreens[activeTab]) return;
|
||||
|
||||
// 이미 로딩 중이면 스킵
|
||||
if (loadingScreens[activeTab]) return;
|
||||
|
||||
// 화면 로드 시작
|
||||
loadScreen(activeTab, currentTab.screenId);
|
||||
}, [activeTab, tabs]);
|
||||
|
||||
const loadScreen = async (tabId: string, screenId: number) => {
|
||||
setLoadingScreens((prev) => ({ ...prev, [tabId]: true }));
|
||||
setScreenErrors((prev) => ({ ...prev, [tabId]: "" }));
|
||||
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(screenId);
|
||||
|
||||
if (layoutData) {
|
||||
setLoadedScreens((prev) => ({
|
||||
...prev,
|
||||
[tabId]: {
|
||||
screenId,
|
||||
layout: layoutData,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setScreenErrors((prev) => ({
|
||||
...prev,
|
||||
[tabId]: "화면을 불러올 수 없습니다",
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
setScreenErrors((prev) => ({
|
||||
...prev,
|
||||
[tabId]: error.message || "화면 로드 중 오류가 발생했습니다",
|
||||
}));
|
||||
} finally {
|
||||
setLoadingScreens((prev) => ({ ...prev, [tabId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 콘텐츠 렌더링
|
||||
const renderTabContent = (tab: TabItem) => {
|
||||
const isLoading = loadingScreens[tab.id];
|
||||
const error = screenErrors[tab.id];
|
||||
const screenData = loadedScreens[tab.id];
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground text-sm">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 발생
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<FileQuestion className="h-12 w-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<p className="mb-2 font-medium text-destructive">화면 로드 실패</p>
|
||||
<p className="text-muted-foreground text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 ID가 없는 경우
|
||||
if (!tab.screenId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2 text-sm">화면이 할당되지 않았습니다</p>
|
||||
<p className="text-xs text-gray-400">상세설정에서 화면을 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링
|
||||
if (screenData && screenData.layout && screenData.layout.components) {
|
||||
const components = screenData.layout.components;
|
||||
const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 };
|
||||
|
||||
return (
|
||||
<div className="bg-white" style={{ width: `${screenResolution.width}px`, height: '100%' }}>
|
||||
<div className="relative h-full">
|
||||
{components.map((comp) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
allComponents={components}
|
||||
screenInfo={{ id: tab.screenId }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">화면 데이터를 불러올 수 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 빈 탭 목록
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<Card className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||
<p className="text-xs text-gray-400">상세설정에서 탭을 추가하세요</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
orientation={orientation}
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
<TabsList className={orientation === "horizontal" ? "justify-start shrink-0" : "flex-col shrink-0"}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
disabled={tab.disabled}
|
||||
className={orientation === "horizontal" ? "" : "w-full justify-start"}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.screenName && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
{tab.screenName}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="flex-1 mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -31,7 +31,11 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
|||
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}
|
||||
style={{
|
||||
...style,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
title={config?.tooltip || placeholder}
|
||||
>
|
||||
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* 채번 규칙 관리 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "규칙 목록 조회 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/${ruleId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "규칙 조회 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNumberingRule(
|
||||
config: NumberingRuleConfig
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.post("/numbering-rules", config);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "규칙 생성 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNumberingRule(
|
||||
ruleId: string,
|
||||
config: Partial<NumberingRuleConfig>
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/numbering-rules/${ruleId}`, config);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "규칙 수정 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/numbering-rules/${ruleId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "규칙 삭제 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateCode(ruleId: string): Promise<ApiResponse<{ code: string }>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "코드 생성 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetSequence(ruleId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "시퀀스 초기화 실패" };
|
||||
}
|
||||
}
|
||||
|
|
@ -21,12 +21,16 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
||||
// 디버깅: 전달받은 웹타입과 props 정보 로깅
|
||||
console.log("🔍 DynamicWebTypeRenderer 호출:", {
|
||||
webType,
|
||||
propsKeys: Object.keys(props),
|
||||
component: props.component,
|
||||
isFileComponent: props.component?.type === "file" || webType === "file",
|
||||
});
|
||||
if (webType === "button") {
|
||||
console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
|
||||
webType,
|
||||
component: props.component,
|
||||
position: props.component?.position,
|
||||
size: props.component?.size,
|
||||
style: props.component?.style,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
const webTypeDefinition = useMemo(() => {
|
||||
return WebTypeRegistry.getWebType(webType);
|
||||
|
|
|
|||
|
|
@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
position: "relative",
|
||||
backgroundColor: "transparent",
|
||||
};
|
||||
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
// 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정
|
||||
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed hsl(var(--border))";
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -204,6 +204,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import "./split-panel-layout/SplitPanelLayoutRenderer";
|
|||
import "./map/MapRenderer";
|
||||
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||
import "./flow-widget/FlowWidgetRenderer";
|
||||
import "./numbering-rule/NumberingRuleRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
interface NumberingRuleWrapperProps {
|
||||
config: NumberingRuleComponentConfig;
|
||||
onChange?: (config: NumberingRuleComponentConfig) => void;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
isPreview = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<NumberingRuleDesigner
|
||||
maxRules={config.maxRules || 6}
|
||||
isPreview={isPreview}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NumberingRuleComponent = NumberingRuleWrapper;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"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";
|
||||
import { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
interface NumberingRuleConfigPanelProps {
|
||||
config: NumberingRuleComponentConfig;
|
||||
onChange: (config: NumberingRuleComponentConfig) => void;
|
||||
}
|
||||
|
||||
export const NumberingRuleConfigPanel: React.FC<NumberingRuleConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">최대 규칙 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={config.maxRules || 6}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, maxRules: parseInt(e.target.value) || 6 })
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
한 규칙에 추가할 수 있는 최대 파트 개수 (1-10)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">읽기 전용 모드</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
편집 기능을 비활성화합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, readonly: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">미리보기 표시</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
코드 미리보기를 항상 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showPreview !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, showPreview: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">규칙 목록 표시</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
저장된 규칙 목록을 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showRuleList !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, showRuleList: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">카드 레이아웃</Label>
|
||||
<Select
|
||||
value={config.cardLayout || "vertical"}
|
||||
onValueChange={(value: "vertical" | "horizontal") =>
|
||||
onChange({ ...config, cardLayout: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
규칙 파트 카드의 배치 방향
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { NumberingRuleDefinition } from "./index";
|
||||
import { NumberingRuleComponent } from "./NumberingRuleComponent";
|
||||
|
||||
/**
|
||||
* 채번 규칙 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = NumberingRuleDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <NumberingRuleComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 컴포넌트 특화 메서드
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
NumberingRuleRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
NumberingRuleRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# 코드 채번 규칙 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집
|
||||
- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합
|
||||
- **실시간 미리보기**: 설정 즉시 생성될 코드 확인
|
||||
- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀
|
||||
|
||||
## 생성 코드 예시
|
||||
|
||||
- 제품 코드: `PROD-20251104-0001`
|
||||
- 프로젝트 코드: `PRJ-2025-001`
|
||||
- 거래처 코드: `CUST-A-0001`
|
||||
|
||||
## 파트 유형
|
||||
|
||||
### 1. 접두사 (prefix)
|
||||
고정된 문자열을 코드 앞에 추가합니다.
|
||||
- 예: `PROD`, `PRJ`, `CUST`
|
||||
|
||||
### 2. 순번 (sequence)
|
||||
자동으로 증가하는 번호를 생성합니다.
|
||||
- 자릿수 설정 가능 (1-10)
|
||||
- 시작 번호 설정 가능
|
||||
- 예: `0001`, `00001`
|
||||
|
||||
### 3. 날짜 (date)
|
||||
현재 날짜를 다양한 형식으로 추가합니다.
|
||||
- YYYY: 2025
|
||||
- YYYYMMDD: 20251104
|
||||
- YYMMDD: 251104
|
||||
|
||||
### 4. 연도 (year)
|
||||
현재 연도를 추가합니다.
|
||||
- YYYY: 2025
|
||||
- YY: 25
|
||||
|
||||
### 5. 월 (month)
|
||||
현재 월을 2자리로 추가합니다.
|
||||
- 예: 01, 02, ..., 12
|
||||
|
||||
### 6. 사용자 정의 (custom)
|
||||
원하는 값을 직접 입력합니다.
|
||||
|
||||
## 생성 방식
|
||||
|
||||
### 자동 생성 (auto)
|
||||
시스템이 자동으로 값을 생성합니다.
|
||||
|
||||
### 직접 입력 (manual)
|
||||
사용자가 값을 직접 입력합니다.
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `maxRules` | number | 6 | 최대 파트 개수 |
|
||||
| `readonly` | boolean | false | 읽기 전용 모드 |
|
||||
| `showPreview` | boolean | true | 미리보기 표시 |
|
||||
| `showRuleList` | boolean | true | 규칙 목록 표시 |
|
||||
| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 |
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```typescript
|
||||
<NumberingRuleDesigner
|
||||
maxRules={6}
|
||||
isPreview={false}
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
## 데이터베이스 구조
|
||||
|
||||
### numbering_rules (마스터 테이블)
|
||||
- 규칙 ID, 규칙명, 구분자
|
||||
- 초기화 주기, 현재 시퀀스
|
||||
- 적용 대상 테이블/컬럼
|
||||
|
||||
### numbering_rule_parts (파트 테이블)
|
||||
- 파트 순서, 파트 유형
|
||||
- 생성 방식, 설정 (JSONB)
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
- `GET /api/numbering-rules` - 규칙 목록 조회
|
||||
- `POST /api/numbering-rules` - 규칙 생성
|
||||
- `PUT /api/numbering-rules/:ruleId` - 규칙 수정
|
||||
- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제
|
||||
- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성
|
||||
|
||||
## 버전 정보
|
||||
|
||||
- **버전**: 1.0.0
|
||||
- **작성일**: 2025-11-04
|
||||
- **작성자**: 개발팀
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 채번 규칙 컴포넌트 기본 설정
|
||||
*/
|
||||
|
||||
import { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
export const defaultConfig: NumberingRuleComponentConfig = {
|
||||
maxRules: 6,
|
||||
readonly: false,
|
||||
showPreview: true,
|
||||
showRuleList: true,
|
||||
enableReorder: false,
|
||||
cardLayout: "vertical",
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { NumberingRuleWrapper } from "./NumberingRuleComponent";
|
||||
import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel";
|
||||
import { defaultConfig } from "./config";
|
||||
|
||||
/**
|
||||
* 채번 규칙 컴포넌트 정의
|
||||
* 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트
|
||||
*/
|
||||
export const NumberingRuleDefinition = createComponentDefinition({
|
||||
id: "numbering-rule",
|
||||
name: "코드 채번 규칙",
|
||||
nameEng: "Numbering Rule Component",
|
||||
description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "component",
|
||||
component: NumberingRuleWrapper,
|
||||
defaultConfig: defaultConfig,
|
||||
defaultSize: {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
gridColumnSpan: "12",
|
||||
},
|
||||
configPanel: NumberingRuleConfigPanel,
|
||||
icon: "Hash",
|
||||
tags: ["코드", "채번", "규칙", "표시", "자동생성"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { NumberingRuleComponentConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { NumberingRuleComponent } from "./NumberingRuleComponent";
|
||||
export { NumberingRuleRenderer } from "./NumberingRuleRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 채번 규칙 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface NumberingRuleComponentConfig {
|
||||
ruleConfig?: NumberingRuleConfig;
|
||||
maxRules?: number;
|
||||
readonly?: boolean;
|
||||
showPreview?: boolean;
|
||||
showRuleList?: boolean;
|
||||
enableReorder?: boolean;
|
||||
cardLayout?: "vertical" | "horizontal";
|
||||
}
|
||||
|
|
@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -234,6 +234,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
backgroundColor: "hsl(var(--background))",
|
||||
overflow: "hidden",
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
@ -1167,7 +1169,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<div className="mt-10" style={{ flex: 1, overflow: "hidden" }}>
|
||||
<SingleTableWithSticky
|
||||
data={data}
|
||||
columns={visibleColumns}
|
||||
|
|
@ -1261,7 +1263,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
)}
|
||||
|
||||
{/* 테이블 컨테이너 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full">
|
||||
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full mt-10">
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
||||
|
|
|
|||
|
|
@ -117,7 +117,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||
...(isHidden &&
|
||||
isDesignMode && {
|
||||
opacity: 0.4,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* 코드 채번 규칙 컴포넌트 타입 정의
|
||||
* Shadcn/ui 가이드라인 기반
|
||||
*/
|
||||
|
||||
/**
|
||||
* 코드 파트 유형 (4가지)
|
||||
*/
|
||||
export type CodePartType =
|
||||
| "sequence" // 순번 (자동 증가 숫자)
|
||||
| "number" // 숫자 (고정 자릿수)
|
||||
| "date" // 날짜 (다양한 날짜 형식)
|
||||
| "text"; // 문자 (텍스트)
|
||||
|
||||
/**
|
||||
* 생성 방식
|
||||
*/
|
||||
export type GenerationMethod =
|
||||
| "auto" // 자동 생성
|
||||
| "manual"; // 직접 입력
|
||||
|
||||
/**
|
||||
* 날짜 형식
|
||||
*/
|
||||
export type DateFormat =
|
||||
| "YYYY" // 2025
|
||||
| "YY" // 25
|
||||
| "YYYYMM" // 202511
|
||||
| "YYMM" // 2511
|
||||
| "YYYYMMDD" // 20251104
|
||||
| "YYMMDD"; // 251104
|
||||
|
||||
/**
|
||||
* 단일 규칙 파트
|
||||
*/
|
||||
export interface NumberingRulePart {
|
||||
id: string; // 고유 ID
|
||||
order: number; // 순서 (1-6)
|
||||
partType: CodePartType; // 파트 유형
|
||||
generationMethod: GenerationMethod; // 생성 방식
|
||||
|
||||
// 자동 생성 설정
|
||||
autoConfig?: {
|
||||
// 순번용
|
||||
sequenceLength?: number; // 순번 자릿수 (예: 3 → 001)
|
||||
startFrom?: number; // 시작 번호 (기본: 1)
|
||||
|
||||
// 숫자용
|
||||
numberLength?: number; // 숫자 자릿수 (예: 4 → 0001)
|
||||
numberValue?: number; // 숫자 값
|
||||
|
||||
// 날짜용
|
||||
dateFormat?: DateFormat; // 날짜 형식
|
||||
|
||||
// 문자용
|
||||
textValue?: string; // 텍스트 값 (예: "PRJ", "CODE")
|
||||
};
|
||||
|
||||
// 직접 입력 설정
|
||||
manualConfig?: {
|
||||
value: string; // 입력값
|
||||
placeholder?: string; // 플레이스홀더
|
||||
};
|
||||
|
||||
// 생성된 값 (미리보기용)
|
||||
generatedValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 채번 규칙
|
||||
*/
|
||||
export interface NumberingRuleConfig {
|
||||
ruleId: string; // 규칙 ID
|
||||
ruleName: string; // 규칙명
|
||||
description?: string; // 설명
|
||||
parts: NumberingRulePart[]; // 규칙 파트 배열
|
||||
|
||||
// 설정
|
||||
separator?: string; // 구분자 (기본: "-")
|
||||
resetPeriod?: "none" | "daily" | "monthly" | "yearly";
|
||||
currentSequence?: number; // 현재 시퀀스
|
||||
|
||||
// 적용 범위
|
||||
scopeType?: "global" | "menu"; // 적용 범위 (전역/메뉴별)
|
||||
menuObjid?: number; // 적용할 메뉴 OBJID (상위 메뉴 기준)
|
||||
|
||||
// 적용 대상
|
||||
tableName?: string; // 적용할 테이블명
|
||||
columnName?: string; // 적용할 컬럼명
|
||||
|
||||
// 메타 정보
|
||||
companyCode?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 옵션 상수
|
||||
*/
|
||||
export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; description: string }> = [
|
||||
{ value: "sequence", label: "순번", description: "자동 증가 순번 (1, 2, 3...)" },
|
||||
{ value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" },
|
||||
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
|
||||
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
|
||||
];
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [
|
||||
{ value: "YYYY", label: "연도 (4자리)", example: "2025" },
|
||||
{ value: "YY", label: "연도 (2자리)", example: "25" },
|
||||
{ value: "YYYYMM", label: "연도+월", example: "202511" },
|
||||
{ value: "YYMM", label: "연도(2)+월", example: "2511" },
|
||||
{ value: "YYYYMMDD", label: "연월일", example: "20251104" },
|
||||
{ value: "YYMMDD", label: "연(2)+월일", example: "251104" },
|
||||
];
|
||||
|
||||
export const RESET_PERIOD_OPTIONS: Array<{
|
||||
value: "none" | "daily" | "monthly" | "yearly";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "none", label: "초기화 안함" },
|
||||
{ value: "daily", label: "일별 초기화" },
|
||||
{ value: "monthly", label: "월별 초기화" },
|
||||
{ value: "yearly", label: "연별 초기화" },
|
||||
];
|
||||
|
|
@ -0,0 +1,494 @@
|
|||
# 코드 채번 규칙 컴포넌트 구현 계획서
|
||||
|
||||
## 문서 정보
|
||||
- **작성일**: 2025-11-03
|
||||
- **목적**: Shadcn/ui 가이드라인 기반 코드 채번 규칙 컴포넌트 구현
|
||||
- **우선순위**: 중간
|
||||
- **디자인 원칙**: 심플하고 깔끔한 UI, 중첩 박스 금지, 일관된 컬러 시스템
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 요구사항
|
||||
|
||||
### 1.1 핵심 기능
|
||||
- 코드 채번 규칙 생성/수정/삭제
|
||||
- 동적 규칙 파트 추가/삭제 (최대 6개)
|
||||
- 실시간 코드 미리보기
|
||||
- 규칙 순서 조정
|
||||
- 데이터베이스 저장 및 불러오기
|
||||
|
||||
### 1.2 UI 요구사항
|
||||
- 좌측: 코드 목록 (선택적)
|
||||
- 우측: 규칙 설정 영역
|
||||
- 상단: 코드 미리보기 + 규칙명
|
||||
- 중앙: 규칙 카드 리스트
|
||||
- 하단: 규칙 추가 + 저장 버튼
|
||||
|
||||
---
|
||||
|
||||
## 2. 디자인 시스템 (Shadcn/ui 기반)
|
||||
|
||||
### 2.1 색상 사용 규칙
|
||||
|
||||
```tsx
|
||||
// 배경
|
||||
bg-background // 페이지 배경
|
||||
bg-card // 카드 배경
|
||||
bg-muted // 약한 배경 (미리보기 등)
|
||||
|
||||
// 텍스트
|
||||
text-foreground // 기본 텍스트
|
||||
text-muted-foreground // 보조 텍스트
|
||||
text-primary // 강조 텍스트
|
||||
|
||||
// 테두리
|
||||
border-border // 기본 테두리
|
||||
border-input // 입력 필드 테두리
|
||||
|
||||
// 버튼
|
||||
bg-primary // 주요 버튼 (저장, 추가)
|
||||
bg-destructive // 삭제 버튼
|
||||
variant="outline" // 보조 버튼 (취소)
|
||||
variant="ghost" // 아이콘 버튼
|
||||
```
|
||||
|
||||
### 2.2 간격 시스템
|
||||
|
||||
```tsx
|
||||
// 카드 간 간격
|
||||
gap-6 // 24px (카드 사이)
|
||||
|
||||
// 카드 내부 패딩
|
||||
p-6 // 24px (CardContent)
|
||||
|
||||
// 폼 필드 간격
|
||||
space-y-4 // 16px (입력 필드들)
|
||||
space-y-3 // 12px (모바일)
|
||||
|
||||
// 섹션 간격
|
||||
space-y-6 // 24px (큰 섹션)
|
||||
```
|
||||
|
||||
### 2.3 타이포그래피
|
||||
|
||||
```tsx
|
||||
// 페이지 제목
|
||||
text-2xl font-semibold
|
||||
|
||||
// 섹션 제목
|
||||
text-lg font-semibold
|
||||
|
||||
// 카드 제목
|
||||
text-base font-semibold
|
||||
|
||||
// 라벨
|
||||
text-sm font-medium
|
||||
|
||||
// 본문 텍스트
|
||||
text-sm text-muted-foreground
|
||||
|
||||
// 작은 텍스트
|
||||
text-xs text-muted-foreground
|
||||
```
|
||||
|
||||
### 2.4 반응형 설정
|
||||
|
||||
```tsx
|
||||
// 모바일 우선 + 데스크톱 최적화
|
||||
className="text-xs sm:text-sm" // 폰트 크기
|
||||
className="h-8 sm:h-10" // 입력 필드 높이
|
||||
className="flex-col md:flex-row" // 레이아웃
|
||||
className="gap-2 sm:gap-4" // 간격
|
||||
```
|
||||
|
||||
### 2.5 중첩 박스 금지 원칙
|
||||
|
||||
**❌ 잘못된 예시**:
|
||||
```tsx
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg p-4"> {/* 중첩 박스! */}
|
||||
<div className="border rounded p-2"> {/* 또 중첩! */}
|
||||
내용
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**✅ 올바른 예시**:
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>제목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 직접 컨텐츠 배치 */}
|
||||
<div>내용 1</div>
|
||||
<div>내용 2</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 구조
|
||||
|
||||
### 3.1 타입 정의
|
||||
|
||||
```typescript
|
||||
// frontend/types/numbering-rule.ts
|
||||
|
||||
import { BaseComponent } from "./screen-management";
|
||||
|
||||
/**
|
||||
* 코드 파트 유형
|
||||
*/
|
||||
export type CodePartType =
|
||||
| "prefix" // 접두사 (고정 문자열)
|
||||
| "sequence" // 순번 (자동 증가)
|
||||
| "date" // 날짜 (YYYYMMDD 등)
|
||||
| "year" // 연도 (YYYY)
|
||||
| "month" // 월 (MM)
|
||||
| "custom"; // 사용자 정의
|
||||
|
||||
/**
|
||||
* 생성 방식
|
||||
*/
|
||||
export type GenerationMethod =
|
||||
| "auto" // 자동 생성
|
||||
| "manual"; // 직접 입력
|
||||
|
||||
/**
|
||||
* 날짜 형식
|
||||
*/
|
||||
export type DateFormat =
|
||||
| "YYYY" // 2025
|
||||
| "YY" // 25
|
||||
| "YYYYMM" // 202511
|
||||
| "YYMM" // 2511
|
||||
| "YYYYMMDD" // 20251103
|
||||
| "YYMMDD"; // 251103
|
||||
|
||||
/**
|
||||
* 단일 규칙 파트
|
||||
*/
|
||||
export interface NumberingRulePart {
|
||||
id: string; // 고유 ID
|
||||
order: number; // 순서 (1-6)
|
||||
partType: CodePartType; // 파트 유형
|
||||
generationMethod: GenerationMethod; // 생성 방식
|
||||
|
||||
// 자동 생성 설정
|
||||
autoConfig?: {
|
||||
// 접두사 설정
|
||||
prefix?: string; // 예: "ITM"
|
||||
|
||||
// 순번 설정
|
||||
sequenceLength?: number; // 자릿수 (예: 4 → 0001)
|
||||
startFrom?: number; // 시작 번호 (기본: 1)
|
||||
|
||||
// 날짜 설정
|
||||
dateFormat?: DateFormat; // 날짜 형식
|
||||
};
|
||||
|
||||
// 직접 입력 설정
|
||||
manualConfig?: {
|
||||
value: string; // 입력값
|
||||
placeholder?: string; // 플레이스홀더
|
||||
};
|
||||
|
||||
// 생성된 값 (미리보기용)
|
||||
generatedValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 채번 규칙
|
||||
*/
|
||||
export interface NumberingRuleConfig {
|
||||
ruleId: string; // 규칙 ID
|
||||
ruleName: string; // 규칙명
|
||||
description?: string; // 설명
|
||||
parts: NumberingRulePart[]; // 규칙 파트 배열 (최대 6개)
|
||||
|
||||
// 설정
|
||||
separator?: string; // 구분자 (기본: "-")
|
||||
resetPeriod?: "none" | "daily" | "monthly" | "yearly"; // 초기화 주기
|
||||
currentSequence?: number; // 현재 시퀀스
|
||||
|
||||
// 적용 대상
|
||||
tableName?: string; // 적용할 테이블명
|
||||
columnName?: string; // 적용할 컬럼명
|
||||
|
||||
// 메타 정보
|
||||
companyCode?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면관리 컴포넌트 인터페이스
|
||||
*/
|
||||
export interface NumberingRuleComponent extends BaseComponent {
|
||||
type: "numbering-rule";
|
||||
|
||||
// 채번 규칙 설정
|
||||
ruleConfig: NumberingRuleConfig;
|
||||
|
||||
// UI 설정
|
||||
showRuleList?: boolean; // 좌측 목록 표시 여부
|
||||
maxRules?: number; // 최대 규칙 개수 (기본: 6)
|
||||
enableReorder?: boolean; // 순서 변경 허용 여부
|
||||
|
||||
// 스타일
|
||||
cardLayout?: "vertical" | "horizontal"; // 카드 레이아웃
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 데이터베이스 스키마
|
||||
|
||||
```sql
|
||||
-- db/migrations/034_create_numbering_rules.sql
|
||||
|
||||
-- 채번 규칙 마스터 테이블
|
||||
CREATE TABLE IF NOT EXISTS numbering_rules (
|
||||
rule_id VARCHAR(50) PRIMARY KEY,
|
||||
rule_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
separator VARCHAR(10) DEFAULT '-',
|
||||
reset_period VARCHAR(20) DEFAULT 'none',
|
||||
current_sequence INTEGER DEFAULT 1,
|
||||
table_name VARCHAR(100),
|
||||
column_name VARCHAR(100),
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by VARCHAR(50),
|
||||
|
||||
CONSTRAINT fk_company FOREIGN KEY (company_code)
|
||||
REFERENCES company_info(company_code)
|
||||
);
|
||||
|
||||
-- 채번 규칙 상세 테이블
|
||||
CREATE TABLE IF NOT EXISTS numbering_rule_parts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rule_id VARCHAR(50) NOT NULL,
|
||||
part_order INTEGER NOT NULL,
|
||||
part_type VARCHAR(50) NOT NULL,
|
||||
generation_method VARCHAR(20) NOT NULL,
|
||||
auto_config JSONB,
|
||||
manual_config JSONB,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT fk_numbering_rule FOREIGN KEY (rule_id)
|
||||
REFERENCES numbering_rules(rule_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_company FOREIGN KEY (company_code)
|
||||
REFERENCES company_info(company_code),
|
||||
CONSTRAINT unique_rule_order UNIQUE (rule_id, part_order, company_code)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_numbering_rules_company ON numbering_rules(company_code);
|
||||
CREATE INDEX idx_numbering_rule_parts_rule ON numbering_rule_parts(rule_id);
|
||||
CREATE INDEX idx_numbering_rules_table ON numbering_rules(table_name, column_name);
|
||||
|
||||
-- 샘플 데이터
|
||||
INSERT INTO numbering_rules (rule_id, rule_name, description, company_code, created_by)
|
||||
VALUES ('SAMPLE_RULE', '샘플 채번 규칙', '제품 코드 자동 생성', '*', 'system')
|
||||
ON CONFLICT (rule_id) DO NOTHING;
|
||||
|
||||
INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, company_code)
|
||||
VALUES
|
||||
('SAMPLE_RULE', 1, 'prefix', 'auto', '{"prefix": "PROD"}', '*'),
|
||||
('SAMPLE_RULE', 2, 'date', 'auto', '{"dateFormat": "YYYYMMDD"}', '*'),
|
||||
('SAMPLE_RULE', 3, 'sequence', 'auto', '{"sequenceLength": 4, "startFrom": 1}', '*')
|
||||
ON CONFLICT (rule_id, part_order, company_code) DO NOTHING;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 순서
|
||||
|
||||
### Phase 1: 타입 정의 및 스키마 생성 ✅
|
||||
1. 타입 정의 파일 생성
|
||||
2. 데이터베이스 마이그레이션 실행
|
||||
3. 샘플 데이터 삽입
|
||||
|
||||
### Phase 2: 백엔드 API 구현
|
||||
1. Controller 생성
|
||||
2. Service 레이어 구현
|
||||
3. API 테스트
|
||||
|
||||
### Phase 3: 프론트엔드 기본 컴포넌트
|
||||
1. NumberingRuleDesigner (메인)
|
||||
2. NumberingRulePreview (미리보기)
|
||||
3. NumberingRuleCard (단일 규칙 카드)
|
||||
|
||||
### Phase 4: 상세 설정 패널
|
||||
1. PartTypeSelector (파트 유형 선택)
|
||||
2. AutoConfigPanel (자동 생성 설정)
|
||||
3. ManualConfigPanel (직접 입력 설정)
|
||||
|
||||
### Phase 5: 화면관리 통합
|
||||
1. ComponentType에 "numbering-rule" 추가
|
||||
2. RealtimePreview 렌더링 추가
|
||||
3. 템플릿 등록
|
||||
4. 속성 패널 구현
|
||||
|
||||
### Phase 6: 테스트 및 최적화
|
||||
1. 기능 테스트
|
||||
2. 반응형 테스트
|
||||
3. 성능 최적화
|
||||
4. 문서화
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 완료 ✅
|
||||
|
||||
### Phase 1: 타입 정의 및 스키마 생성 ✅
|
||||
- ✅ `frontend/types/numbering-rule.ts` 생성
|
||||
- ✅ `db/migrations/034_create_numbering_rules.sql` 생성 및 실행
|
||||
- ✅ 샘플 데이터 삽입 완료
|
||||
|
||||
### Phase 2: 백엔드 API 구현 ✅
|
||||
- ✅ `backend-node/src/services/numberingRuleService.ts` 생성
|
||||
- ✅ `backend-node/src/controllers/numberingRuleController.ts` 생성
|
||||
- ✅ `app.ts`에 라우터 등록 (`/api/numbering-rules`)
|
||||
- ✅ 백엔드 재시작 완료
|
||||
|
||||
### Phase 3: 프론트엔드 기본 컴포넌트 ✅
|
||||
- ✅ `NumberingRulePreview.tsx` - 코드 미리보기
|
||||
- ✅ `NumberingRuleCard.tsx` - 단일 규칙 카드
|
||||
- ✅ `AutoConfigPanel.tsx` - 자동 생성 설정
|
||||
- ✅ `ManualConfigPanel.tsx` - 직접 입력 설정
|
||||
- ✅ `NumberingRuleDesigner.tsx` - 메인 디자이너
|
||||
|
||||
### Phase 4: 상세 설정 패널 ✅
|
||||
- ✅ 파트 유형별 설정 UI (접두사, 순번, 날짜, 연도, 월, 커스텀)
|
||||
- ✅ 자동 생성 / 직접 입력 모드 전환
|
||||
- ✅ 실시간 미리보기 업데이트
|
||||
|
||||
### Phase 5: 화면관리 시스템 통합 ✅
|
||||
- ✅ `unified-core.ts`에 "numbering-rule" ComponentType 추가
|
||||
- ✅ `screen-management.ts`에 ComponentData 유니온 타입 추가
|
||||
- ✅ `RealtimePreview.tsx`에 렌더링 로직 추가
|
||||
- ✅ `TemplatesPanel.tsx`에 "관리자" 카테고리 및 템플릿 추가
|
||||
- ✅ `NumberingRuleTemplate.ts` 생성
|
||||
|
||||
### Phase 6: 완료 ✅
|
||||
모든 단계가 성공적으로 완료되었습니다!
|
||||
|
||||
---
|
||||
|
||||
## 6. 사용 방법
|
||||
|
||||
### 6.1 화면관리에서 사용하기
|
||||
|
||||
1. **화면관리** 페이지로 이동
|
||||
2. 좌측 **템플릿 패널**에서 **관리자** 카테고리 선택
|
||||
3. **코드 채번 규칙** 템플릿을 캔버스로 드래그
|
||||
4. 규칙 파트 추가 및 설정
|
||||
5. 저장
|
||||
|
||||
### 6.2 API 사용하기
|
||||
|
||||
#### 규칙 목록 조회
|
||||
```bash
|
||||
GET /api/numbering-rules
|
||||
```
|
||||
|
||||
#### 규칙 생성
|
||||
```bash
|
||||
POST /api/numbering-rules
|
||||
{
|
||||
"ruleId": "PROD_CODE",
|
||||
"ruleName": "제품 코드 규칙",
|
||||
"parts": [
|
||||
{
|
||||
"id": "part-1",
|
||||
"order": 1,
|
||||
"partType": "prefix",
|
||||
"generationMethod": "auto",
|
||||
"autoConfig": { "prefix": "PROD" }
|
||||
},
|
||||
{
|
||||
"id": "part-2",
|
||||
"order": 2,
|
||||
"partType": "date",
|
||||
"generationMethod": "auto",
|
||||
"autoConfig": { "dateFormat": "YYYYMMDD" }
|
||||
},
|
||||
{
|
||||
"id": "part-3",
|
||||
"order": 3,
|
||||
"partType": "sequence",
|
||||
"generationMethod": "auto",
|
||||
"autoConfig": { "sequenceLength": 4, "startFrom": 1 }
|
||||
}
|
||||
],
|
||||
"separator": "-"
|
||||
}
|
||||
```
|
||||
|
||||
#### 코드 생성
|
||||
```bash
|
||||
POST /api/numbering-rules/PROD_CODE/generate
|
||||
|
||||
응답: { "success": true, "data": { "code": "PROD-20251103-0001" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현된 파일 목록
|
||||
|
||||
### 프론트엔드
|
||||
```
|
||||
frontend/
|
||||
├── types/
|
||||
│ └── numbering-rule.ts ✅
|
||||
├── components/
|
||||
│ └── numbering-rule/
|
||||
│ ├── NumberingRuleDesigner.tsx ✅
|
||||
│ ├── NumberingRuleCard.tsx ✅
|
||||
│ ├── NumberingRulePreview.tsx ✅
|
||||
│ ├── AutoConfigPanel.tsx ✅
|
||||
│ └── ManualConfigPanel.tsx ✅
|
||||
└── components/screen/
|
||||
├── RealtimePreview.tsx ✅ (수정됨)
|
||||
├── panels/
|
||||
│ └── TemplatesPanel.tsx ✅ (수정됨)
|
||||
└── templates/
|
||||
└── NumberingRuleTemplate.ts ✅
|
||||
```
|
||||
|
||||
### 백엔드
|
||||
```
|
||||
backend-node/
|
||||
├── src/
|
||||
│ ├── services/
|
||||
│ │ └── numberingRuleService.ts ✅
|
||||
│ ├── controllers/
|
||||
│ │ └── numberingRuleController.ts ✅
|
||||
│ └── app.ts ✅ (수정됨)
|
||||
```
|
||||
|
||||
### 데이터베이스
|
||||
```
|
||||
db/
|
||||
└── migrations/
|
||||
└── 034_create_numbering_rules.sql ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 다음 개선 사항 (선택사항)
|
||||
|
||||
- [ ] 규칙 순서 드래그앤드롭으로 변경
|
||||
- [ ] 규칙 복제 기능
|
||||
- [ ] 규칙 템플릿 제공 (자주 사용하는 패턴)
|
||||
- [ ] 코드 검증 로직
|
||||
- [ ] 테이블 생성 시 자동 채번 컬럼 추가 통합
|
||||
- [ ] 화면관리에서 입력 폼에 자동 코드 생성 버튼 추가
|
||||
|
||||
Loading…
Reference in New Issue