jskim-node #402
|
|
@ -163,6 +163,12 @@ uploads/
|
|||
# ===== 기타 =====
|
||||
claude.md
|
||||
|
||||
# Agent Pipeline 로컬 파일
|
||||
_local/
|
||||
.agent-pipeline/
|
||||
.codeguard-baseline.json
|
||||
scripts/browser-test-*.js
|
||||
|
||||
# AI 에이전트 테스트 산출물
|
||||
*-test-screenshots/
|
||||
*-screenshots/
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
|
|||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
|
|
@ -318,6 +319,7 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작
|
|||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -351,11 +353,13 @@ app.listen(PORT, HOST, async () => {
|
|||
runDashboardMigration,
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
runApprovalSystemMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
await runDtgManagementLogMigration();
|
||||
await runApprovalSystemMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3690,6 +3690,8 @@ export async function copyMenu(
|
|||
? {
|
||||
removeText: req.body.screenNameConfig.removeText,
|
||||
addPrefix: req.body.screenNameConfig.addPrefix,
|
||||
replaceFrom: req.body.screenNameConfig.replaceFrom,
|
||||
replaceTo: req.body.screenNameConfig.replaceTo,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,892 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
|
||||
// ============================================================
|
||||
// 결재 정의 (Approval Definitions) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalDefinitionController {
|
||||
// 결재 유형 목록 조회
|
||||
static async getDefinitions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { is_active, search } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (is_active) {
|
||||
conditions.push(`is_active = $${idx}`);
|
||||
params.push(is_active);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 상세 조회
|
||||
static async getDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const row = await queryOne<any>(
|
||||
"SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: row });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 생성
|
||||
static async createDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const {
|
||||
definition_name,
|
||||
definition_name_eng,
|
||||
description,
|
||||
default_template_id,
|
||||
max_steps = 5,
|
||||
allow_self_approval = false,
|
||||
allow_cancel = true,
|
||||
is_active = "Y",
|
||||
} = req.body;
|
||||
|
||||
if (!definition_name) {
|
||||
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
|
||||
}
|
||||
|
||||
const userId = req.user?.userId || "system";
|
||||
const [row] = await query<any>(
|
||||
`INSERT INTO approval_definitions (
|
||||
definition_name, definition_name_eng, description, default_template_id,
|
||||
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||
company_code, created_by, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
definition_name, definition_name_eng, description, default_template_id,
|
||||
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||
companyCode, userId,
|
||||
]
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 수정
|
||||
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const {
|
||||
definition_name, definition_name_eng, description, default_template_id,
|
||||
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||
} = req.body;
|
||||
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
|
||||
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
|
||||
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
|
||||
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
|
||||
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
|
||||
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
|
||||
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
|
||||
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||
|
||||
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
|
||||
params.push(req.user?.userId || "system");
|
||||
params.push(id, companyCode);
|
||||
|
||||
const [row] = await query<any>(
|
||||
`UPDATE approval_definitions SET ${fields.join(", ")}
|
||||
WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: row, message: "결재 유형이 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 삭제
|
||||
static async deleteDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재선 템플릿 (Approval Line Templates) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalTemplateController {
|
||||
// 템플릿 목록 조회
|
||||
static async getTemplates(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { definition_id, is_active } = req.query;
|
||||
|
||||
const conditions: string[] = ["t.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (definition_id) {
|
||||
conditions.push(`t.definition_id = $${idx++}`);
|
||||
params.push(definition_id);
|
||||
}
|
||||
if (is_active) {
|
||||
conditions.push(`t.is_active = $${idx++}`);
|
||||
params.push(is_active);
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT t.*, d.definition_name
|
||||
FROM approval_line_templates t
|
||||
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY t.template_id ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 상세 조회 (단계 포함)
|
||||
static async getTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const template = await queryOne<any>(
|
||||
`SELECT t.*, d.definition_name
|
||||
FROM approval_line_templates t
|
||||
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
|
||||
WHERE t.template_id = $1 AND t.company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const steps = await query<any>(
|
||||
"SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: { ...template, steps } });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 생성 (단계 포함 트랜잭션)
|
||||
static async createTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body;
|
||||
|
||||
if (!template_name) {
|
||||
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||
}
|
||||
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
let result: any;
|
||||
await transaction(async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`,
|
||||
[template_name, description, definition_id, is_active, companyCode, userId]
|
||||
);
|
||||
result = rows[0];
|
||||
|
||||
// 단계 일괄 삽입
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
for (const step of steps) {
|
||||
await client.query(
|
||||
`INSERT INTO approval_line_template_steps
|
||||
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
result.template_id,
|
||||
step.step_order,
|
||||
step.approver_type || "user",
|
||||
step.approver_user_id || null,
|
||||
step.approver_position || null,
|
||||
step.approver_dept_code || null,
|
||||
step.approver_label || null,
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 수정
|
||||
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const { template_name, description, definition_id, is_active, steps } = req.body;
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
let result: any;
|
||||
await transaction(async (client) => {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); }
|
||||
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
|
||||
if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); }
|
||||
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
|
||||
params.push(userId, id, companyCode);
|
||||
|
||||
const { rows } = await client.query(
|
||||
`UPDATE approval_line_templates SET ${fields.join(", ")}
|
||||
WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
|
||||
params
|
||||
);
|
||||
result = rows[0];
|
||||
|
||||
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
|
||||
if (Array.isArray(steps)) {
|
||||
await client.query(
|
||||
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
for (const step of steps) {
|
||||
await client.query(
|
||||
`INSERT INTO approval_line_template_steps
|
||||
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
|
||||
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 삭제
|
||||
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 요청 (Approval Requests) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalRequestController {
|
||||
// 결재 요청 목록 조회
|
||||
static async getRequests(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
|
||||
|
||||
const conditions: string[] = ["r.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`r.status = $${idx++}`);
|
||||
params.push(status);
|
||||
}
|
||||
if (target_table) {
|
||||
conditions.push(`r.target_table = $${idx++}`);
|
||||
params.push(target_table);
|
||||
}
|
||||
if (target_record_id) {
|
||||
conditions.push(`r.target_record_id = $${idx++}`);
|
||||
params.push(target_record_id);
|
||||
}
|
||||
if (requester_id) {
|
||||
conditions.push(`r.requester_id = $${idx++}`);
|
||||
params.push(requester_id);
|
||||
}
|
||||
|
||||
// 내 결재 대기 목록: 현재 사용자가 결재자인 라인만 조회
|
||||
if (my_approvals === "true") {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM approval_lines l WHERE l.request_id = r.request_id AND l.approver_id = $${idx++} AND l.status = 'pending' AND l.company_code = r.company_code)`
|
||||
);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
params.push(parseInt(limit as string), offset);
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT r.*, d.definition_name
|
||||
FROM approval_requests r
|
||||
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||
params
|
||||
);
|
||||
|
||||
// 전체 건수 조회
|
||||
const countParams = params.slice(0, params.length - 2);
|
||||
const [countRow] = await query<any>(
|
||||
`SELECT COUNT(*) as total FROM approval_requests r
|
||||
WHERE ${conditions.join(" AND ")}`,
|
||||
countParams
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
total: parseInt(countRow?.total || "0"),
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("결재 요청 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 요청 상세 조회 (라인 포함)
|
||||
static async getRequest(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const request = await queryOne<any>(
|
||||
`SELECT r.*, d.definition_name
|
||||
FROM approval_requests r
|
||||
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
|
||||
WHERE r.request_id = $1 AND r.company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const lines = await query<any>(
|
||||
"SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: { ...request, lines } });
|
||||
} catch (error) {
|
||||
console.error("결재 요청 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 요청 생성 (결재 라인 자동 생성)
|
||||
static async createRequest(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const {
|
||||
title, description, definition_id, target_table, target_record_id,
|
||||
target_record_data, screen_id, button_component_id,
|
||||
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
|
||||
approval_mode, // "sequential" | "parallel"
|
||||
} = req.body;
|
||||
|
||||
if (!title || !target_table) {
|
||||
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
|
||||
}
|
||||
|
||||
if (!Array.isArray(approvers) || approvers.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." });
|
||||
}
|
||||
|
||||
const userId = req.user?.userId || "system";
|
||||
const userName = req.user?.userName || "";
|
||||
const deptName = req.user?.deptName || "";
|
||||
|
||||
const isParallel = approval_mode === "parallel";
|
||||
const totalSteps = approvers.length;
|
||||
|
||||
// approval_mode를 target_record_data에 병합 저장
|
||||
const mergedRecordData = {
|
||||
...(target_record_data || {}),
|
||||
approval_mode: approval_mode || "sequential",
|
||||
};
|
||||
|
||||
let result: any;
|
||||
await transaction(async (client) => {
|
||||
// 결재 요청 생성
|
||||
const { rows: reqRows } = await client.query(
|
||||
`INSERT INTO approval_requests (
|
||||
title, description, definition_id, target_table, target_record_id,
|
||||
target_record_data, status, current_step, total_steps,
|
||||
requester_id, requester_name, requester_dept,
|
||||
screen_id, button_component_id, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
title, description, definition_id, target_table, target_record_id || null,
|
||||
JSON.stringify(mergedRecordData),
|
||||
totalSteps,
|
||||
userId, userName, deptName,
|
||||
screen_id, button_component_id, companyCode,
|
||||
]
|
||||
);
|
||||
result = reqRows[0];
|
||||
|
||||
// 결재 라인 생성
|
||||
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
|
||||
for (let i = 0; i < approvers.length; i++) {
|
||||
const approver = approvers[i];
|
||||
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO approval_lines (
|
||||
request_id, step_order, approver_id, approver_name, approver_position,
|
||||
approver_dept, approver_label, status, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
result.request_id,
|
||||
i + 1,
|
||||
approver.approver_id,
|
||||
approver.approver_name || null,
|
||||
approver.approver_position || null,
|
||||
approver.approver_dept || null,
|
||||
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
|
||||
lineStatus,
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 상태를 in_progress로 업데이트
|
||||
await client.query(
|
||||
"UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1",
|
||||
[result.request_id]
|
||||
);
|
||||
result.status = "in_progress";
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 요청 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 요청 회수 (cancel)
|
||||
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const request = await queryOne<any>(
|
||||
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
if (request.requester_id !== userId) {
|
||||
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
|
||||
}
|
||||
|
||||
if (!["requested", "in_progress"].includes(request.status)) {
|
||||
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, message: "결재 요청이 회수되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 요청 회수 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 회수 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 라인 처리 (Approval Lines - 승인/반려)
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalLineController {
|
||||
// 결재 처리 (승인/반려)
|
||||
static async processApproval(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { lineId } = req.params;
|
||||
const { action, comment } = req.body; // action: 'approved' | 'rejected'
|
||||
|
||||
if (!["approved", "rejected"].includes(action)) {
|
||||
return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." });
|
||||
}
|
||||
|
||||
const line = await queryOne<any>(
|
||||
"SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2",
|
||||
[lineId, companyCode]
|
||||
);
|
||||
|
||||
if (!line) {
|
||||
return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
if (line.approver_id !== userId) {
|
||||
return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." });
|
||||
}
|
||||
|
||||
if (line.status !== "pending") {
|
||||
return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." });
|
||||
}
|
||||
|
||||
await transaction(async (client) => {
|
||||
// 현재 라인 처리
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW()
|
||||
WHERE line_id = $3`,
|
||||
[action, comment || null, lineId]
|
||||
);
|
||||
|
||||
const { rows: reqRows } = await client.query(
|
||||
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
|
||||
[line.request_id, companyCode]
|
||||
);
|
||||
const request = reqRows[0];
|
||||
|
||||
if (!request) return;
|
||||
|
||||
if (action === "rejected") {
|
||||
// 반려: 전체 요청 반려 처리
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = $3`,
|
||||
[userId, comment || null, line.request_id]
|
||||
);
|
||||
// 남은 pending/waiting 라인도 skipped 처리
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = 'skipped'
|
||||
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`,
|
||||
[line.request_id, lineId]
|
||||
);
|
||||
} else {
|
||||
// 승인: 동시결재 vs 다단결재 분기
|
||||
const recordData = request.target_record_data;
|
||||
const isParallelMode = recordData?.approval_mode === "parallel";
|
||||
|
||||
if (isParallelMode) {
|
||||
// 동시결재: 남은 pending 라인이 있는지 확인
|
||||
const { rows: remainingLines } = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM approval_lines
|
||||
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
|
||||
[line.request_id, lineId, companyCode]
|
||||
);
|
||||
const remaining = parseInt(remainingLines[0]?.cnt || "0");
|
||||
|
||||
if (remaining === 0) {
|
||||
// 모든 동시 결재자 승인 완료 → 최종 승인
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = $3`,
|
||||
[userId, comment || null, line.request_id]
|
||||
);
|
||||
}
|
||||
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
|
||||
} else {
|
||||
// 다단결재: 다음 단계 활성화 또는 최종 완료
|
||||
const nextStep = line.step_order + 1;
|
||||
|
||||
if (nextStep <= request.total_steps) {
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = 'pending'
|
||||
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
|
||||
[line.request_id, nextStep, companyCode]
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`,
|
||||
[nextStep, line.request_id]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = $3`,
|
||||
[userId, comment || null, line.request_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 처리 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 처리 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 내 결재 대기 목록 조회
|
||||
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
|
||||
FROM approval_lines l
|
||||
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
|
||||
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2
|
||||
ORDER BY r.created_at ASC`,
|
||||
[userId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("내 결재 대기 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "내 결재 대기 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,37 @@ import { PostgreSQLService } from "./PostgreSQLService";
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* 결재 시스템 테이블 마이그레이션
|
||||
* approval_definitions, approval_line_templates, approval_line_template_steps,
|
||||
* approval_requests, approval_lines 테이블 생성
|
||||
*/
|
||||
export async function runApprovalSystemMigration() {
|
||||
try {
|
||||
console.log("🔄 결재 시스템 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/100_create_approval_system.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ 결재 시스템 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 결재 시스템 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 마이그레이션 실행
|
||||
* dashboard_elements 테이블에 custom_title, show_header 컬럼 추가
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import express from "express";
|
||||
import {
|
||||
ApprovalDefinitionController,
|
||||
ApprovalTemplateController,
|
||||
ApprovalRequestController,
|
||||
ApprovalLineController,
|
||||
} from "../controllers/approvalController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ==================== 결재 유형 (Definitions) ====================
|
||||
router.get("/definitions", ApprovalDefinitionController.getDefinitions);
|
||||
router.get("/definitions/:id", ApprovalDefinitionController.getDefinition);
|
||||
router.post("/definitions", ApprovalDefinitionController.createDefinition);
|
||||
router.put("/definitions/:id", ApprovalDefinitionController.updateDefinition);
|
||||
router.delete("/definitions/:id", ApprovalDefinitionController.deleteDefinition);
|
||||
|
||||
// ==================== 결재선 템플릿 (Templates) ====================
|
||||
router.get("/templates", ApprovalTemplateController.getTemplates);
|
||||
router.get("/templates/:id", ApprovalTemplateController.getTemplate);
|
||||
router.post("/templates", ApprovalTemplateController.createTemplate);
|
||||
router.put("/templates/:id", ApprovalTemplateController.updateTemplate);
|
||||
router.delete("/templates/:id", ApprovalTemplateController.deleteTemplate);
|
||||
|
||||
// ==================== 결재 요청 (Requests) ====================
|
||||
router.get("/requests", ApprovalRequestController.getRequests);
|
||||
router.get("/requests/:id", ApprovalRequestController.getRequest);
|
||||
router.post("/requests", ApprovalRequestController.createRequest);
|
||||
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
|
||||
|
||||
// ==================== 결재 라인 처리 (Lines) ====================
|
||||
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
|
||||
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
|
||||
|
||||
export default router;
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
// Phase 2-1B: 핵심 인증 API 구현
|
||||
|
||||
import { Router } from "express";
|
||||
import { checkAuthStatus } from "../middleware/authMiddleware";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -12,7 +11,7 @@ const router = Router();
|
|||
* 인증 상태 확인 API
|
||||
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
||||
*/
|
||||
router.get("/status", checkAuthStatus);
|
||||
router.get("/status", AuthController.checkAuthStatus);
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ interface Menu {
|
|||
lang_key_desc: string | null;
|
||||
screen_code: string | null;
|
||||
menu_code: string | null;
|
||||
menu_icon: string | null;
|
||||
screen_group_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -371,7 +373,8 @@ export class MenuCopyService {
|
|||
private async collectScreens(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
client: PoolClient,
|
||||
menus?: Menu[]
|
||||
): Promise<Set<number>> {
|
||||
logger.info(
|
||||
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
|
||||
|
|
@ -392,9 +395,25 @@ export class MenuCopyService {
|
|||
screenIds.add(assignment.screen_id);
|
||||
}
|
||||
|
||||
logger.info(`📌 직접 할당 화면: ${screenIds.size}개`);
|
||||
// 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴)
|
||||
if (menus) {
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
for (const menu of menus) {
|
||||
if (menu.menu_url) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (match) {
|
||||
const urlScreenId = parseInt(match[1], 10);
|
||||
if (!isNaN(urlScreenId) && urlScreenId > 0) {
|
||||
screenIds.add(urlScreenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 화면 내부에서 참조되는 화면 (재귀)
|
||||
logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}개`);
|
||||
|
||||
// 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색
|
||||
const queue = Array.from(screenIds);
|
||||
|
||||
while (queue.length > 0) {
|
||||
|
|
@ -403,17 +422,29 @@ export class MenuCopyService {
|
|||
if (visited.has(screenId)) continue;
|
||||
visited.add(screenId);
|
||||
|
||||
// 화면 레이아웃 조회
|
||||
const referencedScreens: number[] = [];
|
||||
|
||||
// V1 레이아웃에서 참조 화면 추출
|
||||
const layoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
// 참조 화면 추출
|
||||
const referencedScreens = this.extractReferencedScreens(
|
||||
layoutsResult.rows
|
||||
referencedScreens.push(
|
||||
...this.extractReferencedScreens(layoutsResult.rows)
|
||||
);
|
||||
|
||||
// V2 레이아웃에서 참조 화면 추출
|
||||
const layoutsV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, sourceCompanyCode]
|
||||
);
|
||||
for (const row of layoutsV2Result.rows) {
|
||||
if (row.layout_data) {
|
||||
this.extractScreenIdsFromObject(row.layout_data, referencedScreens);
|
||||
}
|
||||
}
|
||||
|
||||
if (referencedScreens.length > 0) {
|
||||
logger.info(
|
||||
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
|
||||
|
|
@ -895,6 +926,8 @@ export class MenuCopyService {
|
|||
screenNameConfig?: {
|
||||
removeText?: string;
|
||||
addPrefix?: string;
|
||||
replaceFrom?: string;
|
||||
replaceTo?: string;
|
||||
},
|
||||
additionalCopyOptions?: AdditionalCopyOptions
|
||||
): Promise<MenuCopyResult> {
|
||||
|
|
@ -937,7 +970,8 @@ export class MenuCopyService {
|
|||
const screenIds = await this.collectScreens(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
client,
|
||||
menus
|
||||
);
|
||||
|
||||
const flowIds = await this.collectFlows(screenIds, client);
|
||||
|
|
@ -1093,6 +1127,16 @@ export class MenuCopyService {
|
|||
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
||||
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
||||
|
||||
// === 6.7단계: screen_group_screens 복제 ===
|
||||
logger.info("\n🏷️ [6.7단계] screen_group_screens 복제");
|
||||
await this.copyScreenGroupScreens(
|
||||
screenIds,
|
||||
screenIdMap,
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// === 7단계: 테이블 타입 설정 복사 ===
|
||||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||||
|
|
@ -1417,6 +1461,8 @@ export class MenuCopyService {
|
|||
screenNameConfig?: {
|
||||
removeText?: string;
|
||||
addPrefix?: string;
|
||||
replaceFrom?: string;
|
||||
replaceTo?: string;
|
||||
},
|
||||
numberingRuleIdMap?: Map<string, string>,
|
||||
menuIdMap?: Map<number, number>
|
||||
|
|
@ -1516,6 +1562,13 @@ export class MenuCopyService {
|
|||
// 3) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
if (screenNameConfig.replaceFrom?.trim()) {
|
||||
transformedScreenName = transformedScreenName.replace(
|
||||
new RegExp(screenNameConfig.replaceFrom.trim(), "g"),
|
||||
screenNameConfig.replaceTo?.trim() || ""
|
||||
);
|
||||
transformedScreenName = transformedScreenName.trim();
|
||||
}
|
||||
if (screenNameConfig.removeText?.trim()) {
|
||||
transformedScreenName = transformedScreenName.replace(
|
||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||
|
|
@ -1533,20 +1586,21 @@ export class MenuCopyService {
|
|||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||
const existingScreenId = existingCopy.screen_id;
|
||||
|
||||
// 원본 V2 레이아웃 조회
|
||||
// 원본 V2 레이아웃 조회 (모든 레이어)
|
||||
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 대상 V2 레이아웃 조회
|
||||
// 대상 V2 레이아웃 조회 (모든 레이어)
|
||||
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
|
||||
[existingScreenId]
|
||||
);
|
||||
|
||||
// 변경 여부 확인 (V2 레이아웃 비교)
|
||||
const hasChanges = this.hasLayoutChangesV2(
|
||||
// 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨
|
||||
const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length;
|
||||
const hasChanges = layerCountDiffers || this.hasLayoutChangesV2(
|
||||
sourceLayoutV2Result.rows[0]?.layout_data,
|
||||
targetLayoutV2Result.rows[0]?.layout_data
|
||||
);
|
||||
|
|
@ -1650,7 +1704,7 @@ export class MenuCopyService {
|
|||
}
|
||||
}
|
||||
|
||||
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
|
||||
// === 2단계: screen_conditional_zones + screen_layouts_v2 처리 (멀티 레이어 지원) ===
|
||||
logger.info(
|
||||
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
);
|
||||
|
|
@ -1662,23 +1716,90 @@ export class MenuCopyService {
|
|||
isUpdate,
|
||||
} of screenDefsToProcess) {
|
||||
try {
|
||||
// 원본 V2 레이아웃 조회
|
||||
const layoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
const sourceCompanyCode = screenDef.company_code;
|
||||
|
||||
// 원본 V2 레이아웃 전체 조회 (모든 레이어)
|
||||
const layoutV2Result = await client.query<{
|
||||
layout_data: any;
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
}>(
|
||||
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[originalScreenId, sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (layoutV2Result.rows.length === 0) {
|
||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
let compIdx = 0;
|
||||
for (const layer of layoutV2Result.rows) {
|
||||
const components = layer.layout_data?.components || [];
|
||||
for (const comp of components) {
|
||||
if (!componentIdMap.has(comp.id)) {
|
||||
const newId = `comp_${timestamp}_${compIdx++}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(comp.id, newId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// screen_conditional_zones 복제 + zoneIdMap 생성
|
||||
const zoneIdMap = new Map<number, number>();
|
||||
const zonesResult = await client.query(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
const layoutData = layoutV2Result.rows[0]?.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
if (isUpdate) {
|
||||
await client.query(
|
||||
`DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`,
|
||||
[targetScreenId, targetCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutData && components.length > 0) {
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
components.forEach((comp: any, idx: number) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(comp.id, newComponentId);
|
||||
});
|
||||
for (const zone of zonesResult.rows) {
|
||||
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
|
||||
const newZone = await client.query<{ zone_id: number }>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height,
|
||||
trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING zone_id`,
|
||||
[targetScreenId, targetCompanyCode, zone.zone_name,
|
||||
zone.x, zone.y, zone.width, zone.height,
|
||||
newTriggerCompId, zone.trigger_operator]
|
||||
);
|
||||
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
|
||||
}
|
||||
|
||||
if (zonesResult.rows.length > 0) {
|
||||
logger.info(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개 (zoneIdMap: ${zoneIdMap.size}개)`);
|
||||
}
|
||||
|
||||
// 업데이트인 경우 기존 레이아웃 삭제 (레이어 수 변경 대응)
|
||||
if (isUpdate) {
|
||||
await client.query(
|
||||
`DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`,
|
||||
[targetScreenId, targetCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
// 각 레이어별 처리
|
||||
let totalComponents = 0;
|
||||
for (const layer of layoutV2Result.rows) {
|
||||
const layoutData = layer.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
if (!layoutData || components.length === 0) continue;
|
||||
totalComponents += components.length;
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
||||
|
|
@ -1690,20 +1811,34 @@ export class MenuCopyService {
|
|||
menuIdMap
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
|
||||
);
|
||||
// condition_config의 zone_id 재매핑
|
||||
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
|
||||
if (updatedConditionConfig?.zone_id) {
|
||||
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
|
||||
if (newZoneId) {
|
||||
updatedConditionConfig.zone_id = newZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
|
||||
} else {
|
||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||
// V2 레이아웃 저장 (레이어별 INSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
|
||||
[
|
||||
targetScreenId,
|
||||
targetCompanyCode,
|
||||
layer.layer_id,
|
||||
layer.layer_name,
|
||||
JSON.stringify(updatedLayoutData),
|
||||
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
|
|
@ -1983,6 +2118,26 @@ export class MenuCopyService {
|
|||
|
||||
logger.info(`📂 메뉴 복사 중: ${menus.length}개`);
|
||||
|
||||
// screen_group_id 재매핑 맵 생성 (source company → target company)
|
||||
const screenGroupIdMap = new Map<number, number>();
|
||||
const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[];
|
||||
if (sourceGroupIds.length > 0) {
|
||||
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||
[sourceGroupIds]
|
||||
);
|
||||
for (const sg of sourceGroups.rows) {
|
||||
const targetGroup = await client.query<{ id: number }>(
|
||||
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||
[sg.group_name, targetCompanyCode]
|
||||
);
|
||||
if (targetGroup.rows.length > 0) {
|
||||
screenGroupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||
}
|
||||
}
|
||||
logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`);
|
||||
}
|
||||
|
||||
// 위상 정렬 (부모 먼저 삽입)
|
||||
const sortedMenus = this.topologicalSortMenus(menus);
|
||||
|
||||
|
|
@ -2106,26 +2261,28 @@ export class MenuCopyService {
|
|||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, menu_desc, writer, status, system_name,
|
||||
company_code, lang_key, lang_key_desc, screen_code, menu_code,
|
||||
source_menu_objid
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
source_menu_objid, menu_icon, screen_group_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||
[
|
||||
newObjId,
|
||||
menu.menu_type,
|
||||
newParentObjId, // 재매핑
|
||||
newParentObjId,
|
||||
menu.menu_name_kor,
|
||||
menu.menu_name_eng,
|
||||
menu.seq,
|
||||
menu.menu_url,
|
||||
menu.menu_desc,
|
||||
userId,
|
||||
'active', // 복제된 메뉴는 항상 활성화 상태
|
||||
menu.status || 'active',
|
||||
menu.system_name,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
targetCompanyCode,
|
||||
menu.lang_key,
|
||||
menu.lang_key_desc,
|
||||
menu.screen_code, // 그대로 유지
|
||||
menu.screen_code,
|
||||
menu.menu_code,
|
||||
sourceMenuObjid, // 원본 메뉴 ID (최상위만)
|
||||
sourceMenuObjid,
|
||||
menu.menu_icon,
|
||||
menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -2246,8 +2403,9 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 메뉴 URL 업데이트 (화면 ID 재매핑)
|
||||
* 메뉴 URL + screen_code 업데이트 (화면 ID 재매핑)
|
||||
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
||||
* menu_info.screen_code도 복제된 screen_definitions.screen_code로 교체
|
||||
*/
|
||||
private async updateMenuUrls(
|
||||
menuIdMap: Map<number, number>,
|
||||
|
|
@ -2255,56 +2413,197 @@ export class MenuCopyService {
|
|||
client: PoolClient
|
||||
): Promise<void> {
|
||||
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
||||
logger.info("📭 메뉴 URL 업데이트 대상 없음");
|
||||
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMenuObjids = Array.from(menuIdMap.values());
|
||||
|
||||
// 복제된 메뉴 중 menu_url이 있는 것 조회
|
||||
const menusWithUrl = await client.query<{
|
||||
// 복제된 메뉴 조회
|
||||
const menusToUpdate = await client.query<{
|
||||
objid: number;
|
||||
menu_url: string;
|
||||
menu_url: string | null;
|
||||
screen_code: string | null;
|
||||
}>(
|
||||
`SELECT objid, menu_url FROM menu_info
|
||||
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
|
||||
`SELECT objid, menu_url, screen_code FROM menu_info
|
||||
WHERE objid = ANY($1)`,
|
||||
[newMenuObjids]
|
||||
);
|
||||
|
||||
if (menusWithUrl.rows.length === 0) {
|
||||
logger.info("📭 menu_url 업데이트 대상 없음");
|
||||
if (menusToUpdate.rows.length === 0) {
|
||||
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
|
||||
for (const menu of menusWithUrl.rows) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (!match) continue;
|
||||
|
||||
const originalScreenId = parseInt(match[1], 10);
|
||||
const newScreenId = screenIdMap.get(originalScreenId);
|
||||
|
||||
if (newScreenId && newScreenId !== originalScreenId) {
|
||||
const newMenuUrl = menu.menu_url.replace(
|
||||
`/screens/${originalScreenId}`,
|
||||
`/screens/${newScreenId}`
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
|
||||
[newMenuUrl, menu.objid]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
updatedCount++;
|
||||
// screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회
|
||||
const newScreenIds = Array.from(screenIdMap.values());
|
||||
const screenCodeMap = new Map<string, string>();
|
||||
if (newScreenIds.length > 0) {
|
||||
const screenCodesResult = await client.query<{
|
||||
screen_id: number;
|
||||
screen_code: string;
|
||||
source_screen_id: number;
|
||||
}>(
|
||||
`SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id
|
||||
FROM screen_definitions sd_new
|
||||
WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`,
|
||||
[newScreenIds]
|
||||
);
|
||||
for (const row of screenCodesResult.rows) {
|
||||
if (row.source_screen_id) {
|
||||
// 원본의 screen_code 조회
|
||||
const origResult = await client.query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
|
||||
[row.source_screen_id]
|
||||
);
|
||||
if (origResult.rows[0]?.screen_code) {
|
||||
screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`);
|
||||
let updatedUrlCount = 0;
|
||||
let updatedCodeCount = 0;
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
|
||||
for (const menu of menusToUpdate.rows) {
|
||||
let newMenuUrl = menu.menu_url;
|
||||
let newScreenCode = menu.screen_code;
|
||||
let changed = false;
|
||||
|
||||
// menu_url 재매핑
|
||||
if (menu.menu_url) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (match) {
|
||||
const originalScreenId = parseInt(match[1], 10);
|
||||
const newScreenId = screenIdMap.get(originalScreenId);
|
||||
if (newScreenId && newScreenId !== originalScreenId) {
|
||||
newMenuUrl = menu.menu_url.replace(
|
||||
`/screens/${originalScreenId}`,
|
||||
`/screens/${newScreenId}`
|
||||
);
|
||||
changed = true;
|
||||
updatedUrlCount++;
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// /screen/{screen_code} 형식도 처리
|
||||
const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/);
|
||||
if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) {
|
||||
const origCode = screenCodeUrlMatch[1];
|
||||
const newCode = screenCodeMap.get(origCode);
|
||||
if (newCode && newCode !== origCode) {
|
||||
newMenuUrl = `/screen/${newCode}`;
|
||||
changed = true;
|
||||
updatedUrlCount++;
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// screen_code 재매핑
|
||||
if (menu.screen_code) {
|
||||
const mappedCode = screenCodeMap.get(menu.screen_code);
|
||||
if (mappedCode && mappedCode !== menu.screen_code) {
|
||||
newScreenCode = mappedCode;
|
||||
changed = true;
|
||||
updatedCodeCount++;
|
||||
logger.info(
|
||||
` 🏷️ screen_code 업데이트: ${menu.screen_code} → ${newScreenCode}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
|
||||
[newMenuUrl, newScreenCode, menu.objid]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* screen_group_screens 복제 (화면-스크린그룹 매핑)
|
||||
*/
|
||||
private async copyScreenGroupScreens(
|
||||
screenIds: Set<number>,
|
||||
screenIdMap: Map<number, number>,
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
if (screenIds.size === 0 || screenIdMap.size === 0) {
|
||||
logger.info("📭 screen_group_screens 복제 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리)
|
||||
await client.query(
|
||||
`DELETE FROM screen_group_screens WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
|
||||
// 소스 회사의 screen_group_screens 조회
|
||||
const sourceScreenIds = Array.from(screenIds);
|
||||
const sourceResult = await client.query<{
|
||||
group_id: number;
|
||||
screen_id: number;
|
||||
screen_role: string;
|
||||
display_order: number;
|
||||
is_default: string;
|
||||
}>(
|
||||
`SELECT group_id, screen_id, screen_role, display_order, is_default
|
||||
FROM screen_group_screens
|
||||
WHERE company_code = $1 AND screen_id = ANY($2)`,
|
||||
[sourceCompanyCode, sourceScreenIds]
|
||||
);
|
||||
|
||||
if (sourceResult.rows.length === 0) {
|
||||
logger.info("📭 소스에 screen_group_screens 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// screen_group ID 매핑 (source group_name → target group_id)
|
||||
const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))];
|
||||
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||
[sourceGroupIds]
|
||||
);
|
||||
const groupIdMap = new Map<number, number>();
|
||||
for (const sg of sourceGroups.rows) {
|
||||
const targetGroup = await client.query<{ id: number }>(
|
||||
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||
[sg.group_name, targetCompanyCode]
|
||||
);
|
||||
if (targetGroup.rows.length > 0) {
|
||||
groupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
let insertedCount = 0;
|
||||
for (const row of sourceResult.rows) {
|
||||
const newGroupId = groupIdMap.get(row.group_id);
|
||||
const newScreenId = screenIdMap.get(row.screen_id);
|
||||
if (!newGroupId || !newScreenId) continue;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'system')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode]
|
||||
);
|
||||
insertedCount++;
|
||||
}
|
||||
|
||||
logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -124,7 +124,10 @@ export async function syncScreenGroupsToMenu(
|
|||
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
||||
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
||||
|
||||
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||
// 3. objid 충돌 방지: 순차 카운터 사용
|
||||
let nextObjid = Date.now();
|
||||
|
||||
// 4. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||
// 없으면 생성
|
||||
let userMenuRootObjid: number | null = null;
|
||||
const rootMenuQuery = `
|
||||
|
|
@ -138,19 +141,18 @@ export async function syncScreenGroupsToMenu(
|
|||
if (rootMenuResult.rows.length > 0) {
|
||||
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
||||
} else {
|
||||
// 루트 메뉴가 없으면 생성
|
||||
const newObjid = Date.now();
|
||||
const rootObjid = nextObjid++;
|
||||
const createRootQuery = `
|
||||
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
||||
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
||||
RETURNING objid
|
||||
`;
|
||||
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
|
||||
const createRootResult = await client.query(createRootQuery, [rootObjid, companyCode, userId]);
|
||||
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
||||
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
||||
}
|
||||
|
||||
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||
// 5. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||
const groupToMenuMap: Map<number, number> = new Map();
|
||||
|
||||
// screen_groups의 부모 이름 조회를 위한 매핑
|
||||
|
|
@ -280,7 +282,7 @@ export async function syncScreenGroupsToMenu(
|
|||
|
||||
} else {
|
||||
// 새 메뉴 생성
|
||||
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
||||
const newObjid = nextObjid++;
|
||||
|
||||
// 부모 메뉴 objid 결정
|
||||
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
||||
|
|
@ -334,8 +336,8 @@ export async function syncScreenGroupsToMenu(
|
|||
INSERT INTO menu_info (
|
||||
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
|
||||
menu_url, screen_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
|
||||
menu_url, screen_code, menu_icon
|
||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12)
|
||||
RETURNING objid
|
||||
`;
|
||||
await client.query(insertMenuQuery, [
|
||||
|
|
@ -350,6 +352,7 @@ export async function syncScreenGroupsToMenu(
|
|||
group.description || null,
|
||||
menuUrl,
|
||||
screenCode,
|
||||
group.icon || null,
|
||||
]);
|
||||
|
||||
// screen_groups에 menu_objid 업데이트
|
||||
|
|
|
|||
|
|
@ -3482,8 +3482,74 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
console.log(
|
||||
`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
||||
`✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
||||
);
|
||||
|
||||
// V2 레이아웃(screen_layouts_v2)도 동일하게 처리
|
||||
const v2LayoutsResult = await client.query(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id IN (${placeholders})
|
||||
AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`,
|
||||
targetScreenIds,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`,
|
||||
);
|
||||
|
||||
let v2Updated = 0;
|
||||
for (const v2Layout of v2LayoutsResult.rows) {
|
||||
let layoutData = v2Layout.layout_data;
|
||||
if (!layoutData) continue;
|
||||
|
||||
let v2HasChanges = false;
|
||||
|
||||
const updateV2References = (obj: any): void => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) updateV2References(item);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (
|
||||
(key === "screenId" || key === "targetScreenId" || key === "modalScreenId" ||
|
||||
key === "leftScreenId" || key === "rightScreenId" ||
|
||||
key === "addModalScreenId" || key === "editModalScreenId")
|
||||
) {
|
||||
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||
if (!isNaN(numVal) && numVal > 0) {
|
||||
const newId = screenMap.get(numVal);
|
||||
if (newId) {
|
||||
obj[key] = typeof value === "number" ? newId : String(newId);
|
||||
v2HasChanges = true;
|
||||
console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
updateV2References(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateV2References(layoutData);
|
||||
|
||||
if (v2HasChanges) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||
);
|
||||
v2Updated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`,
|
||||
);
|
||||
result.updated += v2Updated;
|
||||
});
|
||||
|
||||
return result;
|
||||
|
|
@ -4210,39 +4276,65 @@ export class ScreenManagementService {
|
|||
|
||||
const newScreen = newScreenResult.rows[0];
|
||||
|
||||
// 4. 원본 화면의 V2 레이아웃 조회
|
||||
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
// 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어)
|
||||
let sourceLayoutV2Result = await client.query<{
|
||||
layout_data: any;
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
}>(
|
||||
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[sourceScreenId, sourceScreen.company_code],
|
||||
);
|
||||
|
||||
// 없으면 공통(*) 레이아웃 조회
|
||||
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
|
||||
if (!layoutData && sourceScreen.company_code !== "*") {
|
||||
const fallbackResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") {
|
||||
sourceLayoutV2Result = await client.query<{
|
||||
layout_data: any;
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
}>(
|
||||
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id`,
|
||||
[sourceScreenId],
|
||||
);
|
||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||
}
|
||||
|
||||
const components = layoutData?.components || [];
|
||||
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const layer of sourceLayoutV2Result.rows) {
|
||||
const components = layer.layout_data?.components || [];
|
||||
for (const comp of components) {
|
||||
if (!componentIdMap.has(comp.id)) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasComponents = componentIdMap.size > 0;
|
||||
// 첫 번째 레이어의 layoutData (flowId/ruleId 수집용 - 모든 레이어에서 수집)
|
||||
const allLayoutDatas = sourceLayoutV2Result.rows.map((r: any) => r.layout_data).filter(Boolean);
|
||||
|
||||
// 5. 노드 플로우 복사 (회사가 다른 경우)
|
||||
let flowIdMap = new Map<number, number>();
|
||||
if (
|
||||
components.length > 0 &&
|
||||
hasComponents &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// V2 레이아웃에서 flowId 수집
|
||||
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
|
||||
const flowIds = new Set<number>();
|
||||
for (const ld of allLayoutDatas) {
|
||||
const ids = this.collectFlowIdsFromLayoutData(ld);
|
||||
ids.forEach((id: number) => flowIds.add(id));
|
||||
}
|
||||
|
||||
if (flowIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`);
|
||||
|
||||
// 노드 플로우 복사 및 매핑 생성
|
||||
flowIdMap = await this.copyNodeFlowsForScreen(
|
||||
flowIds,
|
||||
sourceScreen.company_code,
|
||||
|
|
@ -4255,16 +4347,17 @@ export class ScreenManagementService {
|
|||
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
|
||||
let ruleIdMap = new Map<string, string>();
|
||||
if (
|
||||
components.length > 0 &&
|
||||
hasComponents &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// V2 레이아웃에서 채번 규칙 ID 수집
|
||||
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
|
||||
const ruleIds = new Set<string>();
|
||||
for (const ld of allLayoutDatas) {
|
||||
const ids = this.collectNumberingRuleIdsFromLayoutData(ld);
|
||||
ids.forEach((id: string) => ruleIds.add(id));
|
||||
}
|
||||
|
||||
if (ruleIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`);
|
||||
|
||||
// 채번 규칙 복사 및 매핑 생성
|
||||
ruleIdMap = await this.copyNumberingRulesForScreen(
|
||||
ruleIds,
|
||||
sourceScreen.company_code,
|
||||
|
|
@ -4274,39 +4367,89 @@ export class ScreenManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
// 6. V2 레이아웃이 있다면 복사
|
||||
if (layoutData && components.length > 0) {
|
||||
// 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
|
||||
const zoneIdMap = new Map<number, number>();
|
||||
if (hasComponents) {
|
||||
try {
|
||||
// componentId 매핑 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const comp of components) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
const zonesResult = await client.query(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
|
||||
[sourceScreenId]
|
||||
);
|
||||
|
||||
for (const zone of zonesResult.rows) {
|
||||
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
|
||||
const newZone = await client.query<{ zone_id: number }>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height,
|
||||
trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING zone_id`,
|
||||
[newScreen.screen_id, targetCompanyCode, zone.zone_name,
|
||||
zone.x, zone.y, zone.width, zone.height,
|
||||
newTriggerCompId, zone.trigger_operator]
|
||||
);
|
||||
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
|
||||
}
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
|
||||
},
|
||||
);
|
||||
if (zonesResult.rows.length > 0) {
|
||||
console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조건부 영역 복사 중 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
// 6. V2 레이아웃 복사 (모든 레이어 순회)
|
||||
if (sourceLayoutV2Result.rows.length > 0 && hasComponents) {
|
||||
try {
|
||||
let totalComponents = 0;
|
||||
|
||||
for (const layer of sourceLayoutV2Result.rows) {
|
||||
const layoutData = layer.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
if (!layoutData || components.length === 0) continue;
|
||||
totalComponents += components.length;
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// condition_config의 zone_id 재매핑
|
||||
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
|
||||
if (updatedConditionConfig?.zone_id) {
|
||||
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
|
||||
if (newZoneId) {
|
||||
updatedConditionConfig.zone_id = newZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
// V2 레이아웃 저장 (레이어별 INSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
|
||||
[
|
||||
newScreen.screen_id,
|
||||
targetCompanyCode,
|
||||
layer.layer_id,
|
||||
layer.layer_name,
|
||||
JSON.stringify(updatedLayoutData),
|
||||
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ↳ V2 레이아웃 복사: ${sourceLayoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
|
||||
} catch (error) {
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4533,9 +4676,60 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
console.log(
|
||||
`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`,
|
||||
`✅ V1: ${updateCount}개 레이아웃 업데이트 완료`,
|
||||
);
|
||||
return updateCount;
|
||||
|
||||
// V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑
|
||||
const v2Layouts = await query<any>(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
AND layout_data IS NOT NULL`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
let v2UpdateCount = 0;
|
||||
for (const v2Layout of v2Layouts) {
|
||||
const layoutData = v2Layout.layout_data;
|
||||
if (!layoutData?.components) continue;
|
||||
|
||||
let v2Changed = false;
|
||||
const updateV2Refs = (obj: any): void => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; }
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (
|
||||
(key === "targetScreenId" || key === "screenId" || key === "modalScreenId" ||
|
||||
key === "leftScreenId" || key === "rightScreenId" ||
|
||||
key === "addModalScreenId" || key === "editModalScreenId")
|
||||
) {
|
||||
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||
if (!isNaN(numVal) && screenIdMapping.has(numVal)) {
|
||||
obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString();
|
||||
v2Changed = true;
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && value !== null) updateV2Refs(value);
|
||||
}
|
||||
};
|
||||
updateV2Refs(layoutData);
|
||||
|
||||
if (v2Changed) {
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||
);
|
||||
v2UpdateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const total = updateCount + v2UpdateCount;
|
||||
console.log(
|
||||
`✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`,
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
6
backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties
vendored
Normal file
6
backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
@ -12,7 +12,7 @@ services:
|
|||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=8080
|
||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=http://localhost:9771
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
# 결재 시스템 구현 현황
|
||||
|
||||
## 1. 개요
|
||||
|
||||
어떤 화면/테이블에서든 결재 버튼을 추가하여 다단계(순차) 및 다중(병렬) 결재를 처리할 수 있는 범용 결재 시스템.
|
||||
|
||||
### 핵심 특징
|
||||
- **범용성**: 특정 테이블에 종속되지 않고 어떤 화면에서든 사용 가능
|
||||
- **멀티테넌시**: 모든 데이터가 `company_code`로 격리
|
||||
- **사용자 주도**: 결재 요청 시 결재 모드/결재자를 직접 설정 (관리자 사전 세팅 불필요)
|
||||
- **컴포넌트 연동**: 버튼 액션 타입 + 결재 단계 시각화 컴포넌트 제공
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처
|
||||
|
||||
```
|
||||
[버튼 클릭 (approval 액션)]
|
||||
↓
|
||||
[ButtonActionExecutor] → CustomEvent('open-approval-modal') 발송
|
||||
↓
|
||||
[ApprovalGlobalListener] → 이벤트 수신
|
||||
↓
|
||||
[ApprovalRequestModal] → 결재 모드/결재자 선택 UI
|
||||
↓
|
||||
[POST /api/approval/requests] → 결재 요청 생성
|
||||
↓
|
||||
[approval_requests + approval_lines 테이블에 저장]
|
||||
↓
|
||||
[결재함 / 결재 단계 컴포넌트에서 조회 및 처리]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터베이스
|
||||
|
||||
### 마이그레이션 파일
|
||||
- `db/migrations/100_create_approval_system.sql`
|
||||
|
||||
### 테이블 구조
|
||||
|
||||
| 테이블 | 용도 | 주요 컬럼 |
|
||||
|--------|------|-----------|
|
||||
| `approval_definitions` | 결재 유형 정의 (구매결재, 문서결재 등) | definition_id, definition_name, max_steps, company_code |
|
||||
| `approval_line_templates` | 결재선 템플릿 (미리 저장된 결재선) | template_id, template_name, definition_id, company_code |
|
||||
| `approval_line_template_steps` | 템플릿별 결재 단계 | step_id, template_id, step_order, approver_user_id, company_code |
|
||||
| `approval_requests` | 실제 결재 요청 건 | request_id, title, target_table, target_record_id, status, requester_id, company_code |
|
||||
| `approval_lines` | 결재 건별 각 단계 결재자 | line_id, request_id, step_order, approver_id, status, comment, company_code |
|
||||
|
||||
### 결재 상태 흐름
|
||||
|
||||
```
|
||||
[requested] → [in_progress] → [approved] (모든 단계 승인)
|
||||
→ [rejected] (어느 단계에서든 반려)
|
||||
→ [cancelled] (요청자가 취소)
|
||||
```
|
||||
|
||||
#### approval_requests.status
|
||||
| 상태 | 의미 |
|
||||
|------|------|
|
||||
| `requested` | 결재 요청됨 (1단계 결재자 처리 대기) |
|
||||
| `in_progress` | 결재 진행 중 (2단계 이상 진행) |
|
||||
| `approved` | 최종 승인 완료 |
|
||||
| `rejected` | 반려됨 |
|
||||
| `cancelled` | 요청자에 의해 취소 |
|
||||
|
||||
#### approval_lines.status
|
||||
| 상태 | 의미 |
|
||||
|------|------|
|
||||
| `waiting` | 아직 차례가 아님 |
|
||||
| `pending` | 현재 결재 차례 (처리 대기) |
|
||||
| `approved` | 승인 완료 |
|
||||
| `rejected` | 반려 |
|
||||
| `skipped` | 이전 단계 반려로 스킵됨 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API
|
||||
|
||||
### 파일 위치
|
||||
- **컨트롤러**: `backend-node/src/controllers/approvalController.ts`
|
||||
- **라우트**: `backend-node/src/routes/approvalRoutes.ts`
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
#### 결재 유형 (Definitions)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/approval/definitions` | 결재 유형 목록 |
|
||||
| GET | `/api/approval/definitions/:id` | 결재 유형 상세 |
|
||||
| POST | `/api/approval/definitions` | 결재 유형 생성 |
|
||||
| PUT | `/api/approval/definitions/:id` | 결재 유형 수정 |
|
||||
| DELETE | `/api/approval/definitions/:id` | 결재 유형 삭제 |
|
||||
|
||||
#### 결재선 템플릿 (Templates)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/approval/templates` | 템플릿 목록 |
|
||||
| GET | `/api/approval/templates/:id` | 템플릿 상세 (단계 포함) |
|
||||
| POST | `/api/approval/templates` | 템플릿 생성 |
|
||||
| PUT | `/api/approval/templates/:id` | 템플릿 수정 |
|
||||
| DELETE | `/api/approval/templates/:id` | 템플릿 삭제 |
|
||||
|
||||
#### 결재 요청 (Requests)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/approval/requests` | 결재 요청 목록 (필터 가능) |
|
||||
| GET | `/api/approval/requests/:id` | 결재 요청 상세 (결재 라인 포함) |
|
||||
| POST | `/api/approval/requests` | 결재 요청 생성 |
|
||||
| POST | `/api/approval/requests/:id/cancel` | 결재 취소 |
|
||||
|
||||
#### 결재 라인 처리 (Lines)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/approval/my-pending` | 내 미처리 결재 목록 |
|
||||
| POST | `/api/approval/lines/:lineId/process` | 승인/반려 처리 |
|
||||
|
||||
### 결재 요청 생성 시 입력
|
||||
|
||||
```typescript
|
||||
interface CreateApprovalRequestInput {
|
||||
title: string; // 결재 제목
|
||||
description?: string; // 결재 설명
|
||||
target_table: string; // 대상 테이블명 (예: sales_order_mng)
|
||||
target_record_id?: string; // 대상 레코드 ID (선택)
|
||||
approval_mode?: "sequential" | "parallel"; // 결재 모드
|
||||
approvers: { // 결재자 목록
|
||||
approver_id: string;
|
||||
approver_name?: string;
|
||||
approver_position?: string;
|
||||
approver_dept?: string;
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
### 결재 처리 로직
|
||||
|
||||
#### 순차 결재 (sequential)
|
||||
1. 첫 번째 결재자 `status = 'pending'`, 나머지 `'waiting'`
|
||||
2. 1단계 승인 → 2단계 `'pending'`으로 변경
|
||||
3. 모든 단계 승인 → `approval_requests.status = 'approved'`
|
||||
4. 어느 단계에서 반려 → 이후 단계 `'skipped'`, 요청 `'rejected'`
|
||||
|
||||
#### 병렬 결재 (parallel)
|
||||
1. 모든 결재자 `status = 'pending'` (동시 처리)
|
||||
2. 모든 결재자 승인 → `'approved'`
|
||||
3. 한 명이라도 반려 → `'rejected'`
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드
|
||||
|
||||
### 5.1 결재 요청 모달
|
||||
|
||||
**파일**: `frontend/components/approval/ApprovalRequestModal.tsx`
|
||||
|
||||
- 결재 모드 선택 (다단 결재 / 다중 결재)
|
||||
- 결재자 검색 (사용자 API 검색, 한글/영문/ID 검색 가능)
|
||||
- 결재자 추가/삭제, 순서 변경 (순차 결재 시)
|
||||
- 대상 테이블/레코드 ID 자동 세팅
|
||||
|
||||
### 5.2 결재 글로벌 리스너
|
||||
|
||||
**파일**: `frontend/components/approval/ApprovalGlobalListener.tsx`
|
||||
|
||||
- `open-approval-modal` CustomEvent를 전역으로 수신
|
||||
- 이벤트의 `detail`에서 `targetTable`, `targetRecordId`, `formData` 추출
|
||||
- `ApprovalRequestModal` 열기
|
||||
|
||||
### 5.3 결재함 페이지
|
||||
|
||||
**파일**: `frontend/app/(main)/admin/approvalBox/page.tsx`
|
||||
|
||||
- 탭 구성: 보낸 결재 / 받은 결재 / 완료된 결재
|
||||
- 결재 상태별 필터링
|
||||
- 결재 상세 조회 및 승인/반려 처리
|
||||
|
||||
**진입점**: 사용자 프로필 드롭다운 > "결재함"
|
||||
|
||||
### 5.4 결재 단계 시각화 컴포넌트 (v2-approval-step)
|
||||
|
||||
**파일 위치**: `frontend/lib/registry/components/v2-approval-step/`
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `types.ts` | ApprovalStepConfig 타입 정의 |
|
||||
| `ApprovalStepComponent.tsx` | 결재 단계 시각화 UI (가로형 스테퍼 / 세로형 타임라인) |
|
||||
| `ApprovalStepConfigPanel.tsx` | 설정 패널 (대상 테이블/컬럼 Combobox, 표시 옵션) |
|
||||
| `ApprovalStepRenderer.tsx` | 컴포넌트 레지스트리 등록 |
|
||||
| `index.ts` | 컴포넌트 정의 (이름, 태그, 기본값 등) |
|
||||
|
||||
#### 설정 항목
|
||||
| 설정 | 설명 |
|
||||
|------|------|
|
||||
| 대상 테이블 | 결재를 걸 데이터가 있는 테이블 (Combobox 검색) |
|
||||
| 레코드 ID 필드명 | 테이블의 PK 컬럼 (Combobox 검색) |
|
||||
| 표시 모드 | 가로형 스테퍼 / 세로형 타임라인 |
|
||||
| 부서/직급 표시 | 결재자의 부서/직급 정보 표시 여부 |
|
||||
| 결재 코멘트 표시 | 승인/반려 시 입력한 코멘트 표시 여부 |
|
||||
| 처리 시각 표시 | 결재 처리 시각 표시 여부 |
|
||||
| 콤팩트 모드 | 작게 표시 |
|
||||
|
||||
### 5.5 API 클라이언트
|
||||
|
||||
**파일**: `frontend/lib/api/approval.ts`
|
||||
|
||||
| 함수 | 용도 |
|
||||
|------|------|
|
||||
| `getApprovalDefinitions()` | 결재 유형 목록 조회 |
|
||||
| `getApprovalTemplates()` | 결재선 템플릿 목록 조회 |
|
||||
| `getApprovalRequests()` | 결재 요청 목록 조회 (필터 지원) |
|
||||
| `getApprovalRequest(id)` | 결재 요청 상세 조회 |
|
||||
| `createApprovalRequest(data)` | 결재 요청 생성 |
|
||||
| `cancelApprovalRequest(id)` | 결재 취소 |
|
||||
| `getMyPendingApprovals()` | 내 미처리 결재 목록 |
|
||||
| `processApprovalLine(lineId, data)` | 승인/반려 처리 |
|
||||
|
||||
### 5.6 버튼 액션 연동
|
||||
|
||||
#### 관련 파일
|
||||
| 파일 | 수정 내용 |
|
||||
|------|-----------|
|
||||
| `frontend/lib/utils/buttonActions.ts` | `ButtonActionType`에 `"approval"` 추가, `handleApproval` 구현 |
|
||||
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | `approval` 액션 핸들러 추가 |
|
||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `silentActions`에 `"approval"` 추가 |
|
||||
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 결재 액션 설정 UI (대상 테이블 자동 세팅) |
|
||||
|
||||
#### 동작 흐름
|
||||
1. 버튼 설정에서 액션 타입 = `"approval"` 선택
|
||||
2. 대상 테이블 자동 설정 (현재 화면 테이블)
|
||||
3. 버튼 클릭 시 `CustomEvent('open-approval-modal')` 발송
|
||||
4. `ApprovalGlobalListener`가 수신하여 `ApprovalRequestModal` 오픈
|
||||
|
||||
---
|
||||
|
||||
## 6. 멀티테넌시 적용
|
||||
|
||||
| 영역 | 적용 |
|
||||
|------|------|
|
||||
| DB 테이블 | 모든 테이블에 `company_code NOT NULL` 포함 |
|
||||
| 인덱스 | `company_code` 컬럼에 인덱스 생성 |
|
||||
| SELECT | `WHERE company_code = $N` 필수 |
|
||||
| INSERT | `company_code` 값 포함 필수 |
|
||||
| UPDATE/DELETE | `WHERE` 절에 `company_code` 조건 포함 |
|
||||
| 최고관리자 | `company_code = '*'` → 모든 데이터 조회 가능 |
|
||||
| JOIN | `ON` 절에 `company_code` 매칭 포함 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 전체 파일 목록
|
||||
|
||||
### 데이터베이스
|
||||
```
|
||||
db/migrations/100_create_approval_system.sql
|
||||
```
|
||||
|
||||
### 백엔드
|
||||
```
|
||||
backend-node/src/controllers/approvalController.ts
|
||||
backend-node/src/routes/approvalRoutes.ts
|
||||
```
|
||||
|
||||
### 프론트엔드 - 결재 모달/리스너
|
||||
```
|
||||
frontend/components/approval/ApprovalRequestModal.tsx
|
||||
frontend/components/approval/ApprovalGlobalListener.tsx
|
||||
```
|
||||
|
||||
### 프론트엔드 - 결재함 페이지
|
||||
```
|
||||
frontend/app/(main)/admin/approvalBox/page.tsx
|
||||
```
|
||||
|
||||
### 프론트엔드 - 결재 단계 컴포넌트
|
||||
```
|
||||
frontend/lib/registry/components/v2-approval-step/types.ts
|
||||
frontend/lib/registry/components/v2-approval-step/ApprovalStepComponent.tsx
|
||||
frontend/lib/registry/components/v2-approval-step/ApprovalStepConfigPanel.tsx
|
||||
frontend/lib/registry/components/v2-approval-step/ApprovalStepRenderer.tsx
|
||||
frontend/lib/registry/components/v2-approval-step/index.ts
|
||||
```
|
||||
|
||||
### 프론트엔드 - API 클라이언트
|
||||
```
|
||||
frontend/lib/api/approval.ts
|
||||
```
|
||||
|
||||
### 프론트엔드 - 버튼 액션 연동 (수정된 파일)
|
||||
```
|
||||
frontend/lib/utils/buttonActions.ts
|
||||
frontend/lib/utils/improvedButtonActionExecutor.ts
|
||||
frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
|
||||
frontend/components/screen/config-panels/ButtonConfigPanel.tsx
|
||||
```
|
||||
|
||||
### 프론트엔드 - 레이아웃 (수정된 파일)
|
||||
```
|
||||
frontend/components/layout/UserDropdown.tsx (결재함 메뉴 추가)
|
||||
frontend/components/layout/AppLayout.tsx (결재함 메뉴 추가)
|
||||
frontend/lib/registry/components/index.ts (v2-approval-step 렌더러 import)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 사용 방법
|
||||
|
||||
### 결재 버튼 추가
|
||||
1. 화면 디자이너에서 버튼 컴포넌트 추가
|
||||
2. 버튼 설정 > 액션 타입 = `결재` 선택
|
||||
3. 대상 테이블이 자동 설정됨 (수동 변경 가능)
|
||||
4. 저장
|
||||
|
||||
### 결재 요청하기
|
||||
1. 데이터 행 선택 (선택적)
|
||||
2. 결재 버튼 클릭
|
||||
3. 결재 모달에서:
|
||||
- 결재 제목 입력
|
||||
- 결재 모드 선택 (다단 결재 / 다중 결재)
|
||||
- 결재자 검색하여 추가
|
||||
4. 결재 요청 클릭
|
||||
|
||||
### 결재 처리하기
|
||||
1. 프로필 드롭다운 > 결재함 클릭
|
||||
2. 받은 결재 탭에서 대기 중인 결재 확인
|
||||
3. 상세 보기 > 승인 또는 반려
|
||||
|
||||
### 결재 단계 표시하기
|
||||
1. 화면 디자이너에서 `결재 단계` 컴포넌트 추가
|
||||
2. 설정에서 대상 테이블 / 레코드 ID 필드 선택
|
||||
3. 표시 모드 (가로/세로) 및 옵션 설정
|
||||
4. 저장 → 행 선택 시 해당 레코드의 결재 단계가 표시됨
|
||||
|
||||
---
|
||||
|
||||
## 9. 향후 개선 사항
|
||||
|
||||
- [ ] 결재 알림 (실시간 알림, 이메일 연동)
|
||||
- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션)
|
||||
- [ ] 결재 위임 기능
|
||||
- [ ] 결재 이력 조회 / 통계 대시보드
|
||||
- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장)
|
||||
- [ ] 모바일 결재 처리 최적화
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye,
|
||||
} from "lucide-react";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
getApprovalRequests,
|
||||
getApprovalRequest,
|
||||
getMyPendingApprovals,
|
||||
processApprovalLine,
|
||||
cancelApprovalRequest,
|
||||
type ApprovalRequest,
|
||||
type ApprovalLine,
|
||||
} from "@/lib/api/approval";
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
requested: { label: "요청", variant: "outline" },
|
||||
in_progress: { label: "진행중", variant: "default" },
|
||||
approved: { label: "승인", variant: "default" },
|
||||
rejected: { label: "반려", variant: "destructive" },
|
||||
cancelled: { label: "회수", variant: "secondary" },
|
||||
waiting: { label: "대기", variant: "outline" },
|
||||
pending: { label: "결재대기", variant: "default" },
|
||||
skipped: { label: "건너뜀", variant: "secondary" },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
|
||||
return <Badge variant={info.variant}>{info.label}</Badge>;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string) {
|
||||
if (!dateStr) return "-";
|
||||
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 상신함 (내가 올린 결재)
|
||||
// ============================================================
|
||||
function SentTab() {
|
||||
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await getApprovalRequests({ my_approvals: false });
|
||||
if (res.success && res.data) setRequests(res.data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchRequests(); }, [fetchRequests]);
|
||||
|
||||
const openDetail = async (req: ApprovalRequest) => {
|
||||
setDetailLoading(true);
|
||||
setDetailOpen(true);
|
||||
const res = await getApprovalRequest(req.request_id);
|
||||
if (res.success && res.data) {
|
||||
setSelectedRequest(res.data);
|
||||
} else {
|
||||
setSelectedRequest(req);
|
||||
}
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!selectedRequest) return;
|
||||
const res = await cancelApprovalRequest(selectedRequest.request_id);
|
||||
if (res.success) {
|
||||
toast.success("결재가 회수되었습니다.");
|
||||
setDetailOpen(false);
|
||||
fetchRequests();
|
||||
} else {
|
||||
toast.error(res.error || "회수 실패");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<Send className="text-muted-foreground mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">상신한 결재가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">진행</TableHead>
|
||||
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
||||
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold">보기</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((req) => (
|
||||
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
{req.current_step}/{req.total_steps}
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
|
||||
<TableCell className="h-14 text-center">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상세 모달 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">결재 상세</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{selectedRequest?.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : selectedRequest && (
|
||||
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">상태</span>
|
||||
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">진행</span>
|
||||
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}단계</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">대상 테이블</span>
|
||||
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">요청일</span>
|
||||
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedRequest.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">사유</span>
|
||||
<p className="mt-1 text-sm">{selectedRequest.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 결재선 */}
|
||||
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">결재선</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedRequest.lines
|
||||
.sort((a, b) => a.step_order - b.step_order)
|
||||
.map((line) => (
|
||||
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">{line.step_order}차</Badge>
|
||||
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
|
||||
{line.approver_position && (
|
||||
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={line.status} />
|
||||
{line.processed_at && (
|
||||
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{selectedRequest?.status === "requested" && (
|
||||
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
결재 회수
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 수신함 (내가 결재해야 할 것)
|
||||
// ============================================================
|
||||
function ReceivedTab() {
|
||||
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [processOpen, setProcessOpen] = useState(false);
|
||||
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const fetchPending = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await getMyPendingApprovals();
|
||||
if (res.success && res.data) setPendingLines(res.data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPending(); }, [fetchPending]);
|
||||
|
||||
const openProcess = (line: ApprovalLine) => {
|
||||
setSelectedLine(line);
|
||||
setComment("");
|
||||
setProcessOpen(true);
|
||||
};
|
||||
|
||||
const handleProcess = async (action: "approved" | "rejected") => {
|
||||
if (!selectedLine) return;
|
||||
setIsProcessing(true);
|
||||
const res = await processApprovalLine(selectedLine.line_id, {
|
||||
action,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
setIsProcessing(false);
|
||||
if (res.success) {
|
||||
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
|
||||
setProcessOpen(false);
|
||||
fetchPending();
|
||||
} else {
|
||||
toast.error(res.error || "처리 실패");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : pendingLines.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">결재 대기 건이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">요청자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
||||
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">단계</TableHead>
|
||||
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">처리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendingLines.map((line) => (
|
||||
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-sm">
|
||||
{line.requester_name || "-"}
|
||||
{line.requester_dept && (
|
||||
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant="outline">{line.step_order}차</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
|
||||
<TableCell className="h-14 text-center">
|
||||
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
|
||||
결재하기
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결재 처리 모달 */}
|
||||
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">결재 처리</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{selectedLine?.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">요청자</span>
|
||||
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">결재 단계</span>
|
||||
<p className="mt-1 font-medium">{selectedLine?.step_order}차 결재</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">의견</Label>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="결재 의견을 입력하세요 (선택사항)"
|
||||
className="min-h-[80px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleProcess("rejected")}
|
||||
disabled={isProcessing}
|
||||
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
반려
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleProcess("approved")}
|
||||
disabled={isProcessing}
|
||||
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
승인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 페이지
|
||||
// ============================================================
|
||||
export default function ApprovalBoxPage() {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">결재함</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
내가 상신한 결재와 나에게 온 결재를 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="received" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="received" className="gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
수신함 (결재 대기)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
<Send className="h-4 w-4" />
|
||||
상신함 (내가 올린)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="received">
|
||||
<ReceivedTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sent">
|
||||
<SentTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,788 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Edit, Trash2, Search, Users, FileText, Loader2 } from "lucide-react";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
type ApprovalDefinition,
|
||||
type ApprovalLineTemplate,
|
||||
type ApprovalLineTemplateStep,
|
||||
getApprovalDefinitions,
|
||||
createApprovalDefinition,
|
||||
updateApprovalDefinition,
|
||||
deleteApprovalDefinition,
|
||||
getApprovalTemplates,
|
||||
getApprovalTemplate,
|
||||
createApprovalTemplate,
|
||||
updateApprovalTemplate,
|
||||
deleteApprovalTemplate,
|
||||
} from "@/lib/api/approval";
|
||||
|
||||
// ============================================================
|
||||
// 결재 유형 관리 탭
|
||||
// ============================================================
|
||||
|
||||
function DefinitionsTab() {
|
||||
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingDef, setEditingDef] = useState<ApprovalDefinition | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
definition_name: "",
|
||||
definition_name_eng: "",
|
||||
description: "",
|
||||
max_steps: 3,
|
||||
allow_self_approval: false,
|
||||
allow_cancel: true,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApprovalDefinition | null>(null);
|
||||
|
||||
const fetchDefinitions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await getApprovalDefinitions({ search: searchTerm || undefined });
|
||||
if (res.success && res.data) {
|
||||
setDefinitions(res.data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefinitions();
|
||||
}, [fetchDefinitions]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingDef(null);
|
||||
setFormData({
|
||||
definition_name: "",
|
||||
definition_name_eng: "",
|
||||
description: "",
|
||||
max_steps: 3,
|
||||
allow_self_approval: false,
|
||||
allow_cancel: true,
|
||||
is_active: "Y",
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (def: ApprovalDefinition) => {
|
||||
setEditingDef(def);
|
||||
setFormData({
|
||||
definition_name: def.definition_name,
|
||||
definition_name_eng: def.definition_name_eng || "",
|
||||
description: def.description || "",
|
||||
max_steps: def.max_steps,
|
||||
allow_self_approval: def.allow_self_approval,
|
||||
allow_cancel: def.allow_cancel,
|
||||
is_active: def.is_active,
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.definition_name.trim()) {
|
||||
toast.warning("결재 유형명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
let res;
|
||||
if (editingDef) {
|
||||
res = await updateApprovalDefinition(editingDef.definition_id, formData);
|
||||
} else {
|
||||
res = await createApprovalDefinition(formData);
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(editingDef ? "수정되었습니다." : "등록되었습니다.");
|
||||
setEditOpen(false);
|
||||
fetchDefinitions();
|
||||
} else {
|
||||
toast.error(res.error || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const res = await deleteApprovalDefinition(deleteTarget.definition_id);
|
||||
if (res.success) {
|
||||
toast.success("삭제되었습니다.");
|
||||
setDeleteTarget(null);
|
||||
fetchDefinitions();
|
||||
} else {
|
||||
toast.error(res.error || "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = definitions.filter(
|
||||
(d) =>
|
||||
d.definition_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(d.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 + 등록 */}
|
||||
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="유형명 또는 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
총 <span className="text-foreground font-semibold">{filtered.length}</span> 건
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
결재 유형 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<p className="text-muted-foreground text-sm">등록된 결재 유형이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||
<TableHead className="h-12 text-sm font-semibold">유형명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">최대 단계</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">자가결재</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">회수가능</TableHead>
|
||||
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((def) => (
|
||||
<TableRow key={def.definition_id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-14 text-sm font-medium">{def.definition_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{def.description || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">{def.max_steps}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={def.allow_self_approval ? "default" : "secondary"}>
|
||||
{def.allow_self_approval ? "허용" : "불가"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={def.allow_cancel ? "default" : "secondary"}>
|
||||
{def.allow_cancel ? "허용" : "불가"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={def.is_active === "Y" ? "default" : "outline"}>
|
||||
{def.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(def)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(def)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editingDef ? "결재 유형 수정" : "결재 유형 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
결재 유형의 기본 정보를 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">유형명 *</Label>
|
||||
<Input
|
||||
value={formData.definition_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, definition_name: e.target.value }))}
|
||||
placeholder="예: 일반 결재, 긴급 결재"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">영문명</Label>
|
||||
<Input
|
||||
value={formData.definition_name_eng}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, definition_name_eng: e.target.value }))}
|
||||
placeholder="예: General Approval"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">설명</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="유형에 대한 설명"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">최대 결재 단계</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={formData.max_steps}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, max_steps: Number(e.target.value) }))}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">자가 결재 허용</Label>
|
||||
<Switch
|
||||
checked={formData.allow_self_approval}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_self_approval: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">회수 가능</Label>
|
||||
<Switch
|
||||
checked={formData.allow_cancel}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_cancel: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">활성 상태</Label>
|
||||
<Switch
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
{editingDef ? "수정" : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>결재 유형 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.definition_name}"을(를) 삭제하시겠습니까?
|
||||
<br />이 유형에 연결된 결재 요청이 있으면 삭제할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재선 템플릿 관리 탭
|
||||
// ============================================================
|
||||
|
||||
function TemplatesTab() {
|
||||
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
||||
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingTpl, setEditingTpl] = useState<ApprovalLineTemplate | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
template_name: "",
|
||||
description: "",
|
||||
definition_id: null as number | null,
|
||||
is_active: "Y",
|
||||
steps: [] as Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[],
|
||||
});
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApprovalLineTemplate | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [tplRes, defRes] = await Promise.all([
|
||||
getApprovalTemplates(),
|
||||
getApprovalDefinitions({ is_active: "Y" }),
|
||||
]);
|
||||
if (tplRes.success && tplRes.data) setTemplates(tplRes.data);
|
||||
if (defRes.success && defRes.data) setDefinitions(defRes.data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingTpl(null);
|
||||
setFormData({
|
||||
template_name: "",
|
||||
description: "",
|
||||
definition_id: null,
|
||||
is_active: "Y",
|
||||
steps: [{ step_order: 1, approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = async (tpl: ApprovalLineTemplate) => {
|
||||
const res = await getApprovalTemplate(tpl.template_id);
|
||||
if (!res.success || !res.data) {
|
||||
toast.error("템플릿 정보를 불러올 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
const detail = res.data;
|
||||
setEditingTpl(detail);
|
||||
setFormData({
|
||||
template_name: detail.template_name,
|
||||
description: detail.description || "",
|
||||
definition_id: detail.definition_id || null,
|
||||
is_active: detail.is_active,
|
||||
steps: (detail.steps || []).map((s) => ({
|
||||
step_order: s.step_order,
|
||||
approver_type: s.approver_type,
|
||||
approver_user_id: s.approver_user_id,
|
||||
approver_position: s.approver_position,
|
||||
approver_dept_code: s.approver_dept_code,
|
||||
approver_label: s.approver_label,
|
||||
})),
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: [
|
||||
...p.steps,
|
||||
{
|
||||
step_order: p.steps.length + 1,
|
||||
approver_type: "user" as const,
|
||||
approver_user_id: "",
|
||||
approver_label: `${p.steps.length + 1}차 결재자`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: p.steps.filter((_, i) => i !== idx).map((s, i) => ({ ...s, step_order: i + 1 })),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateStep = (idx: number, field: string, value: string) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: p.steps.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.template_name.trim()) {
|
||||
toast.warning("템플릿명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (formData.steps.length === 0) {
|
||||
toast.warning("결재 단계를 최소 1개 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
template_name: formData.template_name,
|
||||
description: formData.description || undefined,
|
||||
definition_id: formData.definition_id || undefined,
|
||||
is_active: formData.is_active,
|
||||
steps: formData.steps,
|
||||
};
|
||||
|
||||
let res;
|
||||
if (editingTpl) {
|
||||
res = await updateApprovalTemplate(editingTpl.template_id, payload);
|
||||
} else {
|
||||
res = await createApprovalTemplate(payload);
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다.");
|
||||
setEditOpen(false);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(res.error || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const res = await deleteApprovalTemplate(deleteTarget.template_id);
|
||||
if (res.success) {
|
||||
toast.success("삭제되었습니다.");
|
||||
setDeleteTarget(null);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(res.error || "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = templates.filter(
|
||||
(t) =>
|
||||
t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(t.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 + 등록 */}
|
||||
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="템플릿명 또는 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
총 <span className="text-foreground font-semibold">{filtered.length}</span> 건
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
결재선 템플릿 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<p className="text-muted-foreground text-sm">등록된 결재선 템플릿이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||
<TableHead className="h-12 text-sm font-semibold">템플릿명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">연결된 유형</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">단계 수</TableHead>
|
||||
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((tpl) => (
|
||||
<TableRow key={tpl.template_id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-14 text-sm font-medium">{tpl.template_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{tpl.description || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-sm">{tpl.definition_name || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant="secondary">{tpl.steps?.length || 0}단계</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={tpl.is_active === "Y" ? "default" : "outline"}>
|
||||
{tpl.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(tpl)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(tpl)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editingTpl ? "결재선 템플릿 수정" : "결재선 템플릿 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
결재선의 기본 정보와 결재 단계를 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">템플릿명 *</Label>
|
||||
<Input
|
||||
value={formData.template_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, template_name: e.target.value }))}
|
||||
placeholder="예: 일반 3단계 결재선"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">설명</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="템플릿에 대한 설명"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">연결 결재 유형</Label>
|
||||
<Select
|
||||
value={formData.definition_id ? String(formData.definition_id) : "none"}
|
||||
onValueChange={(v) => setFormData((p) => ({ ...p, definition_id: v === "none" ? null : Number(v) }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="결재 유형 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">연결 없음</SelectItem>
|
||||
{definitions.map((d) => (
|
||||
<SelectItem key={d.definition_id} value={String(d.definition_id)}>
|
||||
{d.definition_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">활성 상태</Label>
|
||||
<Switch
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 결재 단계 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold sm:text-sm">결재 단계</Label>
|
||||
<Button variant="outline" size="sm" onClick={addStep} className="h-7 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
단계 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.steps.length === 0 && (
|
||||
<p className="text-muted-foreground py-4 text-center text-xs">
|
||||
결재 단계를 추가해주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{formData.steps.map((step, idx) => (
|
||||
<div key={idx} className="bg-muted/30 space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{step.step_order}단계</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
onClick={() => removeStep(idx)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">결재자 유형</Label>
|
||||
<Select value={step.approver_type} onValueChange={(v) => updateStep(idx, "approver_type", v)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">사용자 지정</SelectItem>
|
||||
<SelectItem value="position">직급 지정</SelectItem>
|
||||
<SelectItem value="dept">부서 지정</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 라벨</Label>
|
||||
<Input
|
||||
value={step.approver_label || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_label", e.target.value)}
|
||||
placeholder="예: 팀장"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{step.approver_type === "user" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">사용자 ID</Label>
|
||||
<Input
|
||||
value={step.approver_user_id || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_user_id", e.target.value)}
|
||||
placeholder="고정 결재자 ID (비워두면 요청 시 지정)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.approver_type === "position" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">직급</Label>
|
||||
<Input
|
||||
value={step.approver_position || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_position", e.target.value)}
|
||||
placeholder="예: 부장, 이사"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.approver_type === "dept" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">부서 코드</Label>
|
||||
<Input
|
||||
value={step.approver_dept_code || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_dept_code", e.target.value)}
|
||||
placeholder="예: DEPT001"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
{editingTpl ? "수정" : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>결재선 템플릿 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.template_name}"을(를) 삭제하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 페이지
|
||||
// ============================================================
|
||||
|
||||
export default function ApprovalManagementPage() {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">결재 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
결재 유형과 결재선 템플릿을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="definitions" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="definitions" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
결재 유형
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
결재선 템플릿
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="definitions">
|
||||
<DefinitionsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<TemplatesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
|
||||
import {
|
||||
getApprovalRequests,
|
||||
getApprovalRequest,
|
||||
getMyPendingApprovals,
|
||||
processApprovalLine,
|
||||
cancelApprovalRequest,
|
||||
type ApprovalRequest,
|
||||
type ApprovalLine,
|
||||
} from "@/lib/api/approval";
|
||||
|
||||
// 상태 배지 색상
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
requested: { label: "요청됨", variant: "secondary" },
|
||||
in_progress: { label: "진행 중", variant: "default" },
|
||||
approved: { label: "승인됨", variant: "outline" },
|
||||
rejected: { label: "반려됨", variant: "destructive" },
|
||||
cancelled: { label: "취소됨", variant: "secondary" },
|
||||
};
|
||||
|
||||
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
||||
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
|
||||
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
||||
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||
};
|
||||
|
||||
// 결재 상세 모달
|
||||
interface ApprovalDetailModalProps {
|
||||
request: ApprovalRequest | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
pendingLineId?: number; // 내가 처리해야 할 결재 라인 ID
|
||||
}
|
||||
|
||||
function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId }: ApprovalDetailModalProps) {
|
||||
const [comment, setComment] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setComment("");
|
||||
}, [open]);
|
||||
|
||||
const handleProcess = async (action: "approved" | "rejected") => {
|
||||
if (!pendingLineId) return;
|
||||
setIsProcessing(true);
|
||||
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
|
||||
setIsProcessing(false);
|
||||
if (res.success) {
|
||||
onRefresh();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!request) return;
|
||||
setIsCancelling(true);
|
||||
const res = await cancelApprovalRequest(request.request_id);
|
||||
setIsCancelling(false);
|
||||
if (res.success) {
|
||||
onRefresh();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<FileCheck2 className="h-5 w-5" />
|
||||
{request.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
<Badge variant={statusInfo.variant} className="mr-2">
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
요청자: {request.requester_name || request.requester_id}
|
||||
{request.requester_dept ? ` (${request.requester_dept})` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 결재 사유 */}
|
||||
{request.description && (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 사유</p>
|
||||
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm">{request.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결재선 */}
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium">결재선</p>
|
||||
<div className="space-y-2">
|
||||
{(request.lines || []).map((line) => {
|
||||
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||
return (
|
||||
<div
|
||||
key={line.line_id}
|
||||
className="flex items-start justify-between rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{lineStatus.icon}
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">
|
||||
{line.approver_label || `${line.step_order}차 결재`} — {line.approver_name || line.approver_id}
|
||||
</p>
|
||||
{line.approver_position && (
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
|
||||
)}
|
||||
{line.comment && (
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
의견: {line.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 승인/반려 입력 (대기 상태일 때만) */}
|
||||
{pendingLineId && (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 의견 (선택사항)</p>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="결재 의견을 입력하세요"
|
||||
className="min-h-[60px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
|
||||
{/* 요청자만 취소 가능 (요청됨/진행 중 상태) */}
|
||||
{(request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
|
||||
회수
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
|
||||
{pendingLineId && (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleProcess("rejected")}
|
||||
disabled={isProcessing}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
|
||||
반려
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleProcess("approved")}
|
||||
disabled={isProcessing}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||||
승인
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 결재 대기 행 (ApprovalLine 기반)
|
||||
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
|
||||
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||
const createdAt = line.request_created_at || line.created_at;
|
||||
const formattedDate = createdAt
|
||||
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
|
||||
{line.requester_name && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||
요청자: {line.requester_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="flex items-center gap-1 text-[10px] sm:text-xs">
|
||||
{statusInfo.icon}
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 결재 요청 행 (ApprovalRequest 기반)
|
||||
function ApprovalRequestRow({ request, onClick }: { request: ApprovalRequest; onClick: () => void }) {
|
||||
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
||||
const formattedDate = request.created_at
|
||||
? new Date(request.created_at).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">{request.title}</p>
|
||||
{request.requester_name && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||
요청자: {request.requester_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<Badge variant={statusInfo.variant} className="text-[10px]">
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태 컴포넌트
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<FileCheck2 className="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 결재함 페이지
|
||||
export default function ApprovalPage() {
|
||||
const [activeTab, setActiveTab] = useState("pending");
|
||||
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
||||
const [myRequests, setMyRequests] = useState<ApprovalRequest[]>([]);
|
||||
const [completedRequests, setCompletedRequests] = useState<ApprovalRequest[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 상세 모달
|
||||
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
||||
const [selectedPendingLineId, setSelectedPendingLineId] = useState<number | undefined>();
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const [pendingRes, myRes, completedRes] = await Promise.all([
|
||||
getMyPendingApprovals(),
|
||||
// my_approvals 없이 호출 → 백엔드에서 현재 사용자의 요청 건 반환
|
||||
getApprovalRequests(),
|
||||
getApprovalRequests({ status: "approved" }),
|
||||
]);
|
||||
|
||||
if (pendingRes.success && pendingRes.data) setPendingLines(pendingRes.data);
|
||||
if (myRes.success && myRes.data) setMyRequests(myRes.data);
|
||||
if (completedRes.success && completedRes.data) setCompletedRequests(completedRes.data);
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleOpenDetail = async (requestId: number, pendingLineId?: number) => {
|
||||
const res = await getApprovalRequest(requestId);
|
||||
if (res.success && res.data) {
|
||||
setSelectedRequest(res.data);
|
||||
setSelectedPendingLineId(pendingLineId);
|
||||
setDetailModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFromLine = async (line: ApprovalLine) => {
|
||||
if (!line.request_id) return;
|
||||
await handleOpenDetail(line.request_id, line.line_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl p-4 sm:p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold sm:text-2xl">결재함</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">결재 요청을 확인하고 처리합니다.</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-4 grid w-full grid-cols-3">
|
||||
<TabsTrigger value="pending" className="text-xs sm:text-sm">
|
||||
대기함
|
||||
{pendingLines.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1 h-4 min-w-[16px] px-1 text-[10px]">
|
||||
{pendingLines.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="my-requests" className="text-xs sm:text-sm">
|
||||
요청함
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="completed" className="text-xs sm:text-sm">
|
||||
완료함
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 대기함: 내가 결재해야 할 건 */}
|
||||
<TabsContent value="pending">
|
||||
{pendingLines.length === 0 ? (
|
||||
<EmptyState message="결재 대기 중인 건이 없습니다." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pendingLines.map((line) => (
|
||||
<ApprovalLineRow
|
||||
key={line.line_id}
|
||||
line={line}
|
||||
onClick={() => handleOpenFromLine(line)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 요청함: 내가 요청한 건 */}
|
||||
<TabsContent value="my-requests">
|
||||
{myRequests.length === 0 ? (
|
||||
<EmptyState message="요청한 결재 건이 없습니다." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{myRequests.map((req) => (
|
||||
<ApprovalRequestRow
|
||||
key={req.request_id}
|
||||
request={req}
|
||||
onClick={() => handleOpenDetail(req.request_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 완료함 */}
|
||||
<TabsContent value="completed">
|
||||
{completedRequests.length === 0 ? (
|
||||
<EmptyState message="완료된 결재 건이 없습니다." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{completedRequests.map((req) => (
|
||||
<ApprovalRequestRow
|
||||
key={req.request_id}
|
||||
request={req}
|
||||
onClick={() => handleOpenDetail(req.request_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* 결재 상세 모달 */}
|
||||
<ApprovalDetailModal
|
||||
request={selectedRequest}
|
||||
open={detailModalOpen}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
onRefresh={loadData}
|
||||
pendingLineId={selectedPendingLineId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { MenuProvider } from "@/contexts/MenuContext";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
<AppLayout>{children}</AppLayout>
|
||||
<ApprovalGlobalListener />
|
||||
</MenuProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ApprovalRequestModal, type ApprovalModalEventDetail } from "./ApprovalRequestModal";
|
||||
|
||||
/**
|
||||
* 전역 결재 요청 모달 리스너
|
||||
*
|
||||
* CustomEvent('open-approval-modal')를 수신하여 ApprovalRequestModal을 엽니다.
|
||||
*
|
||||
* 이벤트 발송 예시:
|
||||
* window.dispatchEvent(new CustomEvent('open-approval-modal', {
|
||||
* detail: {
|
||||
* targetTable: 'purchase_orders',
|
||||
* targetRecordId: '123',
|
||||
* targetRecordData: { ... },
|
||||
* definitionId: 1,
|
||||
* screenId: 10,
|
||||
* buttonComponentId: 'btn-approval-001',
|
||||
* }
|
||||
* }));
|
||||
*/
|
||||
export const ApprovalGlobalListener: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [eventDetail, setEventDetail] = useState<ApprovalModalEventDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<ApprovalModalEventDetail>;
|
||||
setEventDetail(customEvent.detail || null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener("open-approval-modal", handleOpenModal);
|
||||
return () => {
|
||||
window.removeEventListener("open-approval-modal", handleOpenModal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ApprovalRequestModal
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
if (!v) setEventDetail(null);
|
||||
}}
|
||||
eventDetail={eventDetail}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalGlobalListener;
|
||||
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createApprovalRequest } from "@/lib/api/approval";
|
||||
import { getUserList } from "@/lib/api/user";
|
||||
|
||||
// 결재 방식
|
||||
type ApprovalMode = "sequential" | "parallel";
|
||||
|
||||
interface ApproverRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
position_name: string;
|
||||
dept_name: string;
|
||||
}
|
||||
|
||||
export interface ApprovalModalEventDetail {
|
||||
targetTable: string;
|
||||
targetRecordId: string;
|
||||
targetRecordData?: Record<string, any>;
|
||||
definitionId?: number;
|
||||
screenId?: number;
|
||||
buttonComponentId?: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequestModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
eventDetail?: ApprovalModalEventDetail | null;
|
||||
}
|
||||
|
||||
interface UserSearchResult {
|
||||
userId: string;
|
||||
userName: string;
|
||||
positionName?: string;
|
||||
deptName?: string;
|
||||
deptCode?: string;
|
||||
email?: string;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
position_name?: string;
|
||||
dept_name?: string;
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
eventDetail,
|
||||
}) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
|
||||
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 사용자 검색 상태
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 모달 닫힐 때 초기화
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setApprovalMode("sequential");
|
||||
setApprovers([]);
|
||||
setError(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 사용자 검색 (디바운스)
|
||||
const searchUsers = useCallback(async (query: string) => {
|
||||
if (!query.trim() || query.trim().length < 1) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await getUserList({ search: query.trim(), limit: 20 });
|
||||
const data = res?.data || res || [];
|
||||
const rawUsers: any[] = Array.isArray(data) ? data : [];
|
||||
const users: UserSearchResult[] = rawUsers.map((u: any) => ({
|
||||
userId: u.userId || u.user_id || "",
|
||||
userName: u.userName || u.user_name || "",
|
||||
positionName: u.positionName || u.position_name || "",
|
||||
deptName: u.deptName || u.dept_name || "",
|
||||
deptCode: u.deptCode || u.dept_code || "",
|
||||
email: u.email || "",
|
||||
}));
|
||||
const existingIds = new Set(approvers.map((a) => a.user_id));
|
||||
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [approvers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
searchUsers(searchQuery);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
};
|
||||
}, [searchQuery, searchUsers]);
|
||||
|
||||
const addApprover = (user: UserSearchResult) => {
|
||||
setApprovers((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: genId(),
|
||||
user_id: user.userId,
|
||||
user_name: user.userName,
|
||||
position_name: user.positionName || "",
|
||||
dept_name: user.deptName || "",
|
||||
},
|
||||
]);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
setSearchOpen(false);
|
||||
};
|
||||
|
||||
const removeApprover = (id: string) => {
|
||||
setApprovers((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const moveApprover = (idx: number, direction: "up" | "down") => {
|
||||
setApprovers((prev) => {
|
||||
const next = [...prev];
|
||||
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (targetIdx < 0 || targetIdx >= next.length) return prev;
|
||||
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
setError("결재 제목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (approvers.length === 0) {
|
||||
setError("결재자를 1명 이상 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!eventDetail?.targetTable) {
|
||||
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const res = await createApprovalRequest({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
target_table: eventDetail.targetTable,
|
||||
target_record_id: eventDetail.targetRecordId || undefined,
|
||||
target_record_data: eventDetail.targetRecordData,
|
||||
approval_mode: approvalMode,
|
||||
screen_id: eventDetail.screenId,
|
||||
button_component_id: eventDetail.buttonComponentId,
|
||||
approvers: approvers.map((a, idx) => ({
|
||||
approver_id: a.user_id,
|
||||
approver_name: a.user_name,
|
||||
approver_position: a.position_name || undefined,
|
||||
approver_dept: a.dept_name || undefined,
|
||||
approver_label:
|
||||
approvalMode === "sequential"
|
||||
? `${idx + 1}차 결재`
|
||||
: "동시 결재",
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (res.success) {
|
||||
toast.success("결재 요청이 완료되었습니다.");
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError(res.error || res.message || "결재 요청에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">결재 상신</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
결재 방식을 선택하고 결재자를 검색하여 추가합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
|
||||
{/* 결재 제목 */}
|
||||
<div>
|
||||
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
|
||||
결재 제목 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="approval-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="결재 제목을 입력하세요"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 결재 사유 */}
|
||||
<div>
|
||||
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
|
||||
결재 사유
|
||||
</Label>
|
||||
<Textarea
|
||||
id="approval-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="결재 사유를 입력하세요 (선택사항)"
|
||||
className="min-h-[60px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 결재 방식 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 방식</Label>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("sequential")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "sequential"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">다단 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">순차적으로 결재</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("parallel")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "parallel"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Layers className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">동시 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 결재자 동시 진행</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재자 추가 (사용자 검색) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
결재자 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{approvers.length}명 선택됨
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="이름 또는 사번으로 검색..."
|
||||
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">검색 중...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
type="button"
|
||||
onClick={() => addApprover(user)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.userId})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 클릭 외부 영역 닫기 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선택된 결재자 목록 */}
|
||||
{approvers.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
|
||||
위 검색창에서 결재자를 검색하여 추가하세요
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{approvers.map((approver, idx) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
{/* 순서 표시 */}
|
||||
{approvalMode === "sequential" ? (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "up")}
|
||||
disabled={idx === 0}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "down")}
|
||||
disabled={idx === approvers.length - 1}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
동시
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{approver.user_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({approver.user_id})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 제거 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeApprover(approver.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 결재 흐름 시각화 */}
|
||||
{approvalMode === "sequential" && approvers.length > 1 && (
|
||||
<p className="text-muted-foreground text-center text-[10px]">
|
||||
{approvers.map((a) => a.user_name).join(" → ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 rounded-md p-2">
|
||||
<p className="text-destructive text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || approvers.length === 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
요청 중...
|
||||
</>
|
||||
) : (
|
||||
`결재 상신 (${approvers.length}명)`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRequestModal;
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
LogOut,
|
||||
User,
|
||||
Building2,
|
||||
FileCheck,
|
||||
} from "lucide-react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
|
@ -524,6 +525,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
@ -692,6 +698,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { LogOut, User, FileCheck } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface UserDropdownProps {
|
||||
user: any;
|
||||
|
|
@ -20,6 +21,8 @@ interface UserDropdownProps {
|
|||
* 사용자 드롭다운 메뉴 컴포넌트
|
||||
*/
|
||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -79,6 +82,11 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
|
|||
|
|
@ -577,8 +577,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
const getActionDisplayName = (actionType: ButtonActionType): string => {
|
||||
const displayNames: Record<ButtonActionType, string> = {
|
||||
save: "저장",
|
||||
cancel: "취소",
|
||||
delete: "삭제",
|
||||
edit: "수정",
|
||||
copy: "복사",
|
||||
add: "추가",
|
||||
search: "검색",
|
||||
reset: "초기화",
|
||||
|
|
@ -589,6 +591,9 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
newWindow: "새 창",
|
||||
navigate: "페이지 이동",
|
||||
control: "제어",
|
||||
transferData: "데이터 전달",
|
||||
quickInsert: "즉시 저장",
|
||||
approval: "결재",
|
||||
};
|
||||
return displayNames[actionType] || actionType;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
|||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
||||
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
|
||||
|
||||
// 🆕 제목 블록 타입
|
||||
interface TitleBlock {
|
||||
|
|
@ -107,6 +108,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
// 결재 유형 목록 상태
|
||||
const [approvalDefinitions, setApprovalDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||
const [approvalDefinitionsLoading, setApprovalDefinitionsLoading] = useState(false);
|
||||
|
||||
// 🆕 그룹화 컬럼 선택용 상태
|
||||
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
||||
|
|
@ -689,6 +694,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
fetchScreens();
|
||||
}, [currentScreenCompanyCode]);
|
||||
|
||||
// 결재 유형 목록 가져오기 (approval 액션일 때)
|
||||
useEffect(() => {
|
||||
if (localInputs.actionType !== "approval") return;
|
||||
const fetchApprovalDefinitions = async () => {
|
||||
setApprovalDefinitionsLoading(true);
|
||||
try {
|
||||
const res = await getApprovalDefinitions({ is_active: "Y" });
|
||||
if (res.success && res.data) {
|
||||
setApprovalDefinitions(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 조용히 실패
|
||||
} finally {
|
||||
setApprovalDefinitionsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchApprovalDefinitions();
|
||||
}, [localInputs.actionType]);
|
||||
|
||||
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
||||
useEffect(() => {
|
||||
const fetchTableColumns = async () => {
|
||||
|
|
@ -831,6 +855,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{/* 고급 기능 */}
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="approval">결재 요청</SelectItem>
|
||||
|
||||
{/* 특수 기능 (필요 시 사용) */}
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
|
|
@ -3730,6 +3755,79 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* 결재 요청(approval) 액션 설정 */}
|
||||
{localInputs.actionType === "approval" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">결재 요청 설정</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
버튼 클릭 시 결재 요청 모달이 열립니다. 결재 유형을 선택하면 기본 결재선이 자동으로 세팅됩니다.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="approval-definition" className="text-xs sm:text-sm">
|
||||
결재 유형
|
||||
</Label>
|
||||
<Select
|
||||
value={String(component.componentConfig?.action?.approvalDefinitionId || "")}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.approvalDefinitionId", value === "none" ? null : Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={approvalDefinitionsLoading ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">유형 없음 (직접 설정)</SelectItem>
|
||||
{approvalDefinitions.map((def) => (
|
||||
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
|
||||
{def.definition_name}
|
||||
{def.description ? ` - ${def.description}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
결재 유형을 선택하면 기본 결재선 템플릿이 자동 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="approval-target-table" className="text-xs sm:text-sm">
|
||||
대상 테이블
|
||||
</Label>
|
||||
<Input
|
||||
id="approval-target-table"
|
||||
placeholder={currentTableName || "예: purchase_orders"}
|
||||
value={component.componentConfig?.action?.approvalTargetTable || currentTableName || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
readOnly={!!currentTableName && !component.componentConfig?.action?.approvalTargetTable}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
{currentTableName
|
||||
? `현재 화면 테이블 "${currentTableName}" 자동 적용됨`
|
||||
: "결재 대상 레코드가 저장된 테이블명"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="approval-record-id-field" className="text-xs sm:text-sm">
|
||||
레코드 ID 필드명
|
||||
</Label>
|
||||
<Input
|
||||
id="approval-record-id-field"
|
||||
placeholder="예: id, purchase_id"
|
||||
value={component.componentConfig?.action?.approvalRecordIdField || "id"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.approvalRecordIdField", e.target.value)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
현재 선택된 레코드의 PK 컬럼명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 이벤트 발송 액션 설정 */}
|
||||
{localInputs.actionType === "event" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export interface ActionTabProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동작 탭: 클릭 이벤트, 네비게이션, 모달 열기, 확인 다이얼로그 등 동작 설정
|
||||
* 실제 UI는 메인 ButtonConfigPanel에서 렌더링 후 children으로 전달
|
||||
*/
|
||||
export const ActionTab: React.FC<ActionTabProps> = ({ children }) => {
|
||||
return <div className="space-y-4">{children}</div>;
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface BasicTabProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
localText?: string;
|
||||
onTextChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export const BasicTab: React.FC<BasicTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
localText,
|
||||
onTextChange,
|
||||
}) => {
|
||||
const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼");
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onTextChange?.(newValue);
|
||||
onChange("componentConfig.text", newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={text}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="버튼 텍스트를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,872 @@
|
|||
"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 { Switch } from "@/components/ui/switch";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
export interface DataTabProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
component: ComponentData;
|
||||
allComponents: ComponentData[];
|
||||
currentTableName?: string;
|
||||
availableTables: Array<{ name: string; label: string }>;
|
||||
mappingTargetColumns: Array<{ name: string; label: string }>;
|
||||
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
||||
currentTableColumns: Array<{ name: string; label: string }>;
|
||||
mappingSourcePopoverOpen: Record<string, boolean>;
|
||||
setMappingSourcePopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
mappingTargetPopoverOpen: Record<string, boolean>;
|
||||
setMappingTargetPopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
activeMappingGroupIndex: number;
|
||||
setActiveMappingGroupIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
loadMappingColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
||||
setMappingSourceColumnsMap: React.Dispatch<
|
||||
React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>
|
||||
>;
|
||||
}
|
||||
|
||||
export const DataTab: React.FC<DataTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
component,
|
||||
allComponents,
|
||||
currentTableName,
|
||||
availableTables,
|
||||
mappingTargetColumns,
|
||||
mappingSourceColumnsMap,
|
||||
currentTableColumns,
|
||||
mappingSourcePopoverOpen,
|
||||
setMappingSourcePopoverOpen,
|
||||
mappingTargetPopoverOpen,
|
||||
setMappingTargetPopoverOpen,
|
||||
activeMappingGroupIndex,
|
||||
setActiveMappingGroupIndex,
|
||||
loadMappingColumns,
|
||||
setMappingSourceColumnsMap,
|
||||
}) => {
|
||||
const actionType = config.action?.type;
|
||||
const onUpdateProperty = (path: string, value: any) => onChange(path, value);
|
||||
|
||||
if (actionType === "quickInsert") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<QuickInsertConfigSection
|
||||
component={component}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
allComponents={allComponents}
|
||||
currentTableName={currentTableName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (actionType !== "transferData") {
|
||||
return (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">데이터 전달 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
소스 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
const layerName = comp._layerName;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
{layerName && (
|
||||
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
{layerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 제공 가능한 컴포넌트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-type">
|
||||
타겟 타입 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.targetType || "component"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||||
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||||
<SelectItem value="screen" disabled>
|
||||
다른 화면 (구현 예정)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.action?.dataTransfer?.targetType === "component" && (
|
||||
<div>
|
||||
<Label>
|
||||
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||||
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||||
if (selectedComp && (selectedComp as any)._layerId) {
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.dataTransfer.targetLayerId",
|
||||
(selectedComp as any)._layerId,
|
||||
);
|
||||
} else {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t),
|
||||
);
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
const layerName = comp._layerName;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
{layerName && (
|
||||
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
{layerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t),
|
||||
);
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 수신 가능한 컴포넌트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||
<div>
|
||||
<Label>타겟 컴포넌트 ID (선택사항)</Label>
|
||||
<Input
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
|
||||
}
|
||||
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="transfer-mode">데이터 전달 모드</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.mode || "append"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="append">추가 (Append)</SelectItem>
|
||||
<SelectItem value="replace">교체 (Replace)</SelectItem>
|
||||
<SelectItem value="merge">병합 (Merge)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">기존 데이터를 어떻게 처리할지 선택</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="clear-after-transfer">전달 후 소스 선택 초기화</Label>
|
||||
<p className="text-muted-foreground text-xs">데이터 전달 후 소스의 선택을 해제합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="clear-after-transfer"
|
||||
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="confirm-before-transfer">전달 전 확인 메시지</Label>
|
||||
<p className="text-muted-foreground text-xs">데이터 전달 전 확인 다이얼로그를 표시합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="confirm-before-transfer"
|
||||
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.action?.dataTransfer?.confirmBeforeTransfer && (
|
||||
<div>
|
||||
<Label htmlFor="confirm-message">확인 메시지</Label>
|
||||
<Input
|
||||
id="confirm-message"
|
||||
placeholder="선택한 항목을 전달하시겠습니까?"
|
||||
value={config.action?.dataTransfer?.confirmMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>검증 설정</Label>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="min-selection" className="text-xs">
|
||||
최소 선택 개수
|
||||
</Label>
|
||||
<Input
|
||||
id="min-selection"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={config.action?.dataTransfer?.validation?.minSelection || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.dataTransfer.validation.minSelection",
|
||||
parseInt(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="max-selection" className="text-xs">
|
||||
최대 선택 개수
|
||||
</Label>
|
||||
<Input
|
||||
id="max-selection"
|
||||
type="number"
|
||||
placeholder="제한없음"
|
||||
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.dataTransfer.validation.maxSelection",
|
||||
parseInt(e.target.value) || undefined,
|
||||
)
|
||||
}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>추가 데이터 소스 (선택사항)</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
||||
</p>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div>
|
||||
<Label className="text-xs">추가 컴포넌트</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
|
||||
onValueChange={(value) => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: value, fieldName: "" });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], componentId: value };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__clear__">
|
||||
<span className="text-muted-foreground">선택 안 함</span>
|
||||
</SelectItem>
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="additional-field-name" className="text-xs">
|
||||
타겟 필드명 (선택사항)
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{(() => {
|
||||
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||||
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||||
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||||
const found = cols.find((c) => c.name === fieldName);
|
||||
return found ? `${found.label || found.name}` : fieldName;
|
||||
})()}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: "" });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: "" };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||||
</CommandItem>
|
||||
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label || ""} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: col.name });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{col.label || col.name}</span>
|
||||
{col.label && col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>필드 매핑 설정</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{config.action?.dataTransfer?.targetTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||
config.action?.dataTransfer?.targetTable
|
||||
: "타겟 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||||
...currentMappings,
|
||||
{ sourceTable: "", mappingRules: [] },
|
||||
]);
|
||||
setActiveMappingGroupIndex(currentMappings.length);
|
||||
}}
|
||||
disabled={!config.action?.dataTransfer?.targetTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
소스 테이블 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||
</p>
|
||||
|
||||
{!config.action?.dataTransfer?.targetTable ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-muted-foreground text-xs">매핑 그룹이 없습니다. 소스 테이블을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||||
<div key={gIdx} className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||
>
|
||||
{group.sourceTable
|
||||
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||
: `그룹 ${gIdx + 1}`}
|
||||
{group.mappingRules?.length > 0 && (
|
||||
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||||
{group.mappingRules.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||||
mappings.splice(gIdx, 1);
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||
if (activeMappingGroupIndex >= mappings.length) {
|
||||
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||||
if (!activeGroup) return null;
|
||||
|
||||
const activeSourceTable = activeGroup.sourceTable || "";
|
||||
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||
const activeRules: any[] = activeGroup.mappingRules || [];
|
||||
|
||||
const updateGroupField = (field: string, value: any) => {
|
||||
const mappings = [...multiMappings];
|
||||
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{activeSourceTable
|
||||
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||
: "소스 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={async () => {
|
||||
updateGroupField("sourceTable", table.name);
|
||||
if (!mappingSourceColumnsMap[table.name]) {
|
||||
const cols = await loadMappingColumns(table.name);
|
||||
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">매핑 규칙</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-5 text-[10px]"
|
||||
onClick={() => {
|
||||
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||
}}
|
||||
disabled={!activeSourceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!activeSourceTable ? (
|
||||
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||||
) : activeRules.length === 0 ? (
|
||||
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||
) : (
|
||||
activeRules.map((rule: any, rIdx: number) => {
|
||||
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||
return (
|
||||
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||||
onOpenChange={(open) =>
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{rule.sourceField
|
||||
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
||||
rule.sourceField
|
||||
: "소스 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{activeSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setMappingSourcePopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[popoverKeyS]: false,
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||||
onOpenChange={(open) =>
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{rule.targetField
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
||||
rule.targetField
|
||||
: "타겟 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setMappingTargetPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[popoverKeyT]: false,
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
onClick={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules.splice(rIdx, 1);
|
||||
updateGroupField("mappingRules", newRules);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||
<br />
|
||||
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||||
<br />
|
||||
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -420,10 +420,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{entityJoinTables.map((joinTable, idx) => {
|
||||
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
|
||||
const uniqueKey = joinTable.joinConfig?.sourceColumn
|
||||
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
|
||||
: `entity-join-${joinTable.tableName}-${idx}`;
|
||||
const uniqueKey = `entity-join-${joinTable.tableName}-${joinTable.joinConfig?.sourceColumn || ''}-${idx}`;
|
||||
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
||||
// 검색어로 필터링
|
||||
const filteredColumns = searchTerm
|
||||
|
|
|
|||
|
|
@ -161,13 +161,14 @@ export const useAuth = () => {
|
|||
setLoading(true);
|
||||
|
||||
const token = TokenManager.getToken();
|
||||
if (!token || TokenManager.isTokenExpired(token)) {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
|
||||
if (!token) {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "refreshUserData: 토큰 없음");
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// 만료된 토큰이라도 apiClient 요청 인터셉터가 자동 갱신하므로 여기서 차단하지 않음
|
||||
|
||||
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
||||
|
||||
|
|
@ -177,6 +178,10 @@ export const useAuth = () => {
|
|||
});
|
||||
|
||||
try {
|
||||
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
|
||||
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
|
||||
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
|
||||
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
|
||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||
|
||||
if (userInfo) {
|
||||
|
|
@ -184,19 +189,12 @@ export const useAuth = () => {
|
|||
|
||||
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
||||
const finalAuthStatus = {
|
||||
isLoggedIn: authStatusData.isLoggedIn,
|
||||
isLoggedIn: true,
|
||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||
};
|
||||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||
|
||||
if (!finalAuthStatus.isLoggedIn) {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
}
|
||||
} else {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||
try {
|
||||
|
|
@ -412,18 +410,19 @@ export const useAuth = () => {
|
|||
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
|
||||
if (token) {
|
||||
// 유효/만료 모두 refreshUserData로 처리
|
||||
// apiClient 요청 인터셉터가 만료 토큰을 자동 갱신하므로 여기서 삭제하지 않음
|
||||
const isExpired = TokenManager.isTokenExpired(token);
|
||||
AuthLogger.log(
|
||||
"AUTH_CHECK_START",
|
||||
`초기 인증 확인: 토큰 ${isExpired ? "만료됨 → 갱신 시도" : "유효"} (경로: ${window.location.pathname})`,
|
||||
);
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: false,
|
||||
});
|
||||
refreshUserData();
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
|
||||
TokenManager.removeToken();
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
} else {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,453 @@
|
|||
/**
|
||||
* 결재 시스템 API 클라이언트
|
||||
* 엔드포인트: /api/approval/*
|
||||
*/
|
||||
|
||||
// API URL 동적 설정
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem("authToken") || sessionStorage.getItem("authToken");
|
||||
}
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = getAuthToken();
|
||||
const headers: HeadersInit = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 공통 타입 정의
|
||||
// ============================================================
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ApprovalDefinition {
|
||||
definition_id: number;
|
||||
definition_name: string;
|
||||
definition_name_eng?: string;
|
||||
description?: string;
|
||||
default_template_id?: number;
|
||||
max_steps: number;
|
||||
allow_self_approval: boolean;
|
||||
allow_cancel: boolean;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_by?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ApprovalLineTemplate {
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
description?: string;
|
||||
definition_id?: number;
|
||||
definition_name?: string;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_by?: string;
|
||||
updated_at: string;
|
||||
steps?: ApprovalLineTemplateStep[];
|
||||
}
|
||||
|
||||
export interface ApprovalLineTemplateStep {
|
||||
step_id: number;
|
||||
template_id: number;
|
||||
step_order: number;
|
||||
approver_type: "user" | "position" | "dept";
|
||||
approver_user_id?: string;
|
||||
approver_position?: string;
|
||||
approver_dept_code?: string;
|
||||
approver_label?: string;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
request_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
definition_id?: number;
|
||||
definition_name?: string;
|
||||
target_table: string;
|
||||
target_record_id: string;
|
||||
target_record_data?: Record<string, any>;
|
||||
status: "requested" | "in_progress" | "approved" | "rejected" | "cancelled";
|
||||
current_step: number;
|
||||
total_steps: number;
|
||||
requester_id: string;
|
||||
requester_name?: string;
|
||||
requester_dept?: string;
|
||||
completed_at?: string;
|
||||
final_approver_id?: string;
|
||||
final_comment?: string;
|
||||
screen_id?: number;
|
||||
button_component_id?: string;
|
||||
company_code: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
lines?: ApprovalLine[];
|
||||
}
|
||||
|
||||
export interface ApprovalLine {
|
||||
line_id: number;
|
||||
request_id: number;
|
||||
step_order: number;
|
||||
approver_id: string;
|
||||
approver_name?: string;
|
||||
approver_position?: string;
|
||||
approver_dept?: string;
|
||||
approver_label?: string;
|
||||
status: "waiting" | "pending" | "approved" | "rejected" | "skipped";
|
||||
comment?: string;
|
||||
processed_at?: string;
|
||||
company_code: string;
|
||||
created_at: string;
|
||||
// 요청 정보 (my-pending 조회 시 포함)
|
||||
title?: string;
|
||||
target_table?: string;
|
||||
target_record_id?: string;
|
||||
requester_name?: string;
|
||||
requester_dept?: string;
|
||||
request_created_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateApprovalRequestInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
definition_id?: number;
|
||||
target_table: string;
|
||||
target_record_id?: string;
|
||||
target_record_data?: Record<string, any>;
|
||||
screen_id?: number;
|
||||
button_component_id?: string;
|
||||
approval_mode?: "sequential" | "parallel";
|
||||
approvers: {
|
||||
approver_id: string;
|
||||
approver_name?: string;
|
||||
approver_position?: string;
|
||||
approver_dept?: string;
|
||||
approver_label?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 유형 (Definitions) API
|
||||
// ============================================================
|
||||
|
||||
export async function getApprovalDefinitions(params?: {
|
||||
is_active?: string;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<ApprovalDefinition[]>> {
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.is_active) qs.append("is_active", params.is_active);
|
||||
if (params?.search) qs.append("search", params.search);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/approval/definitions${qs.toString() ? `?${qs}` : ""}`,
|
||||
{ headers: getAuthHeaders(), credentials: "include" }
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApprovalDefinition(id: number): Promise<ApiResponse<ApprovalDefinition>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApprovalDefinition(data: {
|
||||
definition_name: string;
|
||||
definition_name_eng?: string;
|
||||
description?: string;
|
||||
default_template_id?: number;
|
||||
max_steps?: number;
|
||||
allow_self_approval?: boolean;
|
||||
allow_cancel?: boolean;
|
||||
is_active?: string;
|
||||
}): Promise<ApiResponse<ApprovalDefinition>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/definitions`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateApprovalDefinition(
|
||||
id: number,
|
||||
data: Partial<ApprovalDefinition>
|
||||
): Promise<ApiResponse<ApprovalDefinition>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApprovalDefinition(id: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재선 템플릿 (Templates) API
|
||||
// ============================================================
|
||||
|
||||
export async function getApprovalTemplates(params?: {
|
||||
definition_id?: number;
|
||||
is_active?: string;
|
||||
}): Promise<ApiResponse<ApprovalLineTemplate[]>> {
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.definition_id) qs.append("definition_id", String(params.definition_id));
|
||||
if (params?.is_active) qs.append("is_active", params.is_active);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/approval/templates${qs.toString() ? `?${qs}` : ""}`,
|
||||
{ headers: getAuthHeaders(), credentials: "include" }
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApprovalTemplate(id: number): Promise<ApiResponse<ApprovalLineTemplate>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApprovalTemplate(data: {
|
||||
template_name: string;
|
||||
description?: string;
|
||||
definition_id?: number;
|
||||
is_active?: string;
|
||||
steps?: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[];
|
||||
}): Promise<ApiResponse<ApprovalLineTemplate>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/templates`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateApprovalTemplate(
|
||||
id: number,
|
||||
data: {
|
||||
template_name?: string;
|
||||
description?: string;
|
||||
definition_id?: number;
|
||||
is_active?: string;
|
||||
steps?: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[];
|
||||
}
|
||||
): Promise<ApiResponse<ApprovalLineTemplate>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApprovalTemplate(id: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 요청 (Requests) API
|
||||
// ============================================================
|
||||
|
||||
export async function getApprovalRequests(params?: {
|
||||
status?: string;
|
||||
target_table?: string;
|
||||
target_record_id?: string;
|
||||
requester_id?: string;
|
||||
my_approvals?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<ApiResponse<ApprovalRequest[]>> {
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.append("status", params.status);
|
||||
if (params?.target_table) qs.append("target_table", params.target_table);
|
||||
if (params?.target_record_id) qs.append("target_record_id", params.target_record_id);
|
||||
if (params?.requester_id) qs.append("requester_id", params.requester_id);
|
||||
if (params?.my_approvals !== undefined) qs.append("my_approvals", String(params.my_approvals));
|
||||
if (params?.page) qs.append("page", String(params.page));
|
||||
if (params?.limit) qs.append("limit", String(params.limit));
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/approval/requests${qs.toString() ? `?${qs}` : ""}`,
|
||||
{ headers: getAuthHeaders(), credentials: "include" }
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApprovalRequest(id: number): Promise<ApiResponse<ApprovalRequest>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/requests/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApprovalRequest(
|
||||
data: CreateApprovalRequestInput
|
||||
): Promise<ApiResponse<ApprovalRequest>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/requests`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelApprovalRequest(id: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/requests/${id}/cancel`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 라인 처리 (Lines) API
|
||||
// ============================================================
|
||||
|
||||
export async function getMyPendingApprovals(): Promise<ApiResponse<ApprovalLine[]>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/my-pending`, {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function processApprovalLine(
|
||||
lineId: number,
|
||||
data: { action: "approved" | "rejected"; comment?: string }
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/approval/lines/${lineId}/process`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
|
@ -329,6 +329,11 @@ apiClient.interceptors.request.use(
|
|||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
} else {
|
||||
// 갱신 실패 시 인증 없는 요청을 보내면 TOKEN_MISSING 401 → 즉시 redirectToLogin 연쇄 장애
|
||||
// 요청 자체를 차단하여 호출부의 try/catch에서 처리하도록 함
|
||||
authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 요청 차단 (${config.url})`);
|
||||
return Promise.reject(new Error("TOKEN_REFRESH_FAILED"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes";
|
||||
|
||||
interface ConfigFieldProps<T = any> {
|
||||
field: ConfigFieldDefinition<T>;
|
||||
value: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
tableColumns?: ConfigOption[];
|
||||
}
|
||||
|
||||
export function ConfigField<T>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
tableColumns,
|
||||
}: ConfigFieldProps<T>) {
|
||||
const handleChange = (newValue: any) => {
|
||||
onChange(field.key, newValue);
|
||||
};
|
||||
|
||||
const renderField = () => {
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
e.target.value === "" ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "switch":
|
||||
return (
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={field.placeholder || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case "color":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value ?? "#000000"}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "slider":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? field.min ?? 0}
|
||||
onChange={(e) => handleChange(Number(e.target.value))}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{field.min ?? 0} ~ {field.max ?? 100}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "multi-select":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{(field.options || []).map((opt) => {
|
||||
const selected = Array.isArray(value) && value.includes(opt.value);
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => {
|
||||
const current = Array.isArray(value) ? [...value] : [];
|
||||
if (selected) {
|
||||
handleChange(current.filter((v: string) => v !== opt.value));
|
||||
} else {
|
||||
handleChange([...current, opt.value]);
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs">{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "key-value": {
|
||||
const entries: Array<[string, string]> = Object.entries(
|
||||
(value as Record<string, string>) || {},
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.map(([k, v], idx) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={k}
|
||||
onChange={(e) => {
|
||||
const newObj = { ...(value || {}) };
|
||||
delete newObj[k];
|
||||
newObj[e.target.value] = v;
|
||||
handleChange(newObj);
|
||||
}}
|
||||
placeholder="키"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={v}
|
||||
onChange={(e) => {
|
||||
handleChange({ ...(value || {}), [k]: e.target.value });
|
||||
}}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
const newObj = { ...(value || {}) };
|
||||
delete newObj[k];
|
||||
handleChange(newObj);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => {
|
||||
handleChange({ ...(value || {}), "": "" });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "column-picker": {
|
||||
const options = tableColumns || field.options || [];
|
||||
return (
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">{field.label}</Label>
|
||||
{field.type === "switch" && renderField()}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-muted-foreground text-[10px]">{field.description}</p>
|
||||
)}
|
||||
{field.type !== "switch" && renderField()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ConfigPanelBuilderProps } from "./ConfigPanelTypes";
|
||||
import { ConfigSection } from "./ConfigSection";
|
||||
import { ConfigField } from "./ConfigField";
|
||||
|
||||
export function ConfigPanelBuilder<T extends Record<string, any>>({
|
||||
config,
|
||||
onChange,
|
||||
sections,
|
||||
presets,
|
||||
tableColumns,
|
||||
children,
|
||||
}: ConfigPanelBuilderProps<T>) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 프리셋 버튼 */}
|
||||
{presets && presets.length > 0 && (
|
||||
<div className="border-b pb-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
빠른 설정
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{presets.map((preset, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
Object.entries(preset.values).forEach(([key, value]) => {
|
||||
onChange(key, value);
|
||||
});
|
||||
}}
|
||||
className="rounded-full bg-muted px-2.5 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
title={preset.description}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 섹션 렌더링 */}
|
||||
{sections.map((section) => {
|
||||
if (section.condition && !section.condition(config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleFields = section.fields.filter(
|
||||
(field) => !field.condition || field.condition(config),
|
||||
);
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigSection key={section.id} section={section}>
|
||||
{visibleFields.map((field) => (
|
||||
<ConfigField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={(config as any)[field.key]}
|
||||
onChange={onChange}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
))}
|
||||
</ConfigSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 커스텀 children */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React from "react";
|
||||
|
||||
export type ConfigFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "switch"
|
||||
| "select"
|
||||
| "textarea"
|
||||
| "color"
|
||||
| "slider"
|
||||
| "multi-select"
|
||||
| "key-value"
|
||||
| "column-picker";
|
||||
|
||||
export interface ConfigOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ConfigFieldDefinition<T = any> {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ConfigFieldType;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
options?: ConfigOption[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
condition?: (config: T) => boolean;
|
||||
disabled?: (config: T) => boolean;
|
||||
}
|
||||
|
||||
export interface ConfigSectionDefinition<T = any> {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
fields: ConfigFieldDefinition<T>[];
|
||||
condition?: (config: T) => boolean;
|
||||
}
|
||||
|
||||
export interface ConfigPanelBuilderProps<T = any> {
|
||||
config: T;
|
||||
onChange: (key: string, value: any) => void;
|
||||
sections: ConfigSectionDefinition<T>[];
|
||||
presets?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
values: Partial<T>;
|
||||
}>;
|
||||
tableColumns?: ConfigOption[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ConfigSectionDefinition } from "./ConfigPanelTypes";
|
||||
|
||||
interface ConfigSectionProps {
|
||||
section: ConfigSectionDefinition;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigSection({ section, children }: ConfigSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(section.defaultOpen ?? true);
|
||||
|
||||
if (section.collapsible) {
|
||||
return (
|
||||
<div className="border-b pb-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center gap-1.5 py-1 text-left"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{section.title}</span>
|
||||
{section.description && (
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{section.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && <div className="mt-2 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b pb-3">
|
||||
<div className="mb-2">
|
||||
<h4 className="text-sm font-medium">{section.title}</h4>
|
||||
{section.description && (
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,6 +117,7 @@ import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
|||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||
import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ApprovalStepConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import {
|
||||
getApprovalRequests,
|
||||
getApprovalRequest,
|
||||
type ApprovalRequest,
|
||||
type ApprovalLine,
|
||||
} from "@/lib/api/approval";
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
SkipForward,
|
||||
Loader2,
|
||||
FileCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ApprovalStepComponentProps extends ComponentRendererProps {}
|
||||
|
||||
interface ApprovalStepData {
|
||||
request: ApprovalRequest;
|
||||
lines: ApprovalLine[];
|
||||
approvalMode: "sequential" | "parallel";
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
approved: {
|
||||
label: "승인",
|
||||
icon: Check,
|
||||
bgColor: "bg-emerald-100",
|
||||
borderColor: "border-emerald-500",
|
||||
textColor: "text-emerald-700",
|
||||
iconColor: "text-emerald-600",
|
||||
dotColor: "bg-emerald-500",
|
||||
},
|
||||
rejected: {
|
||||
label: "반려",
|
||||
icon: X,
|
||||
bgColor: "bg-rose-100",
|
||||
borderColor: "border-rose-500",
|
||||
textColor: "text-rose-700",
|
||||
iconColor: "text-rose-600",
|
||||
dotColor: "bg-rose-500",
|
||||
},
|
||||
pending: {
|
||||
label: "결재 대기",
|
||||
icon: Clock,
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-400",
|
||||
textColor: "text-amber-700",
|
||||
iconColor: "text-amber-500",
|
||||
dotColor: "bg-amber-400",
|
||||
},
|
||||
waiting: {
|
||||
label: "대기",
|
||||
icon: Clock,
|
||||
bgColor: "bg-muted",
|
||||
borderColor: "border-border",
|
||||
textColor: "text-muted-foreground",
|
||||
iconColor: "text-muted-foreground",
|
||||
dotColor: "bg-muted-foreground/40",
|
||||
},
|
||||
skipped: {
|
||||
label: "건너뜀",
|
||||
icon: SkipForward,
|
||||
bgColor: "bg-muted/50",
|
||||
borderColor: "border-border/50",
|
||||
textColor: "text-muted-foreground/70",
|
||||
iconColor: "text-muted-foreground/50",
|
||||
dotColor: "bg-muted-foreground/30",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const REQUEST_STATUS_CONFIG = {
|
||||
requested: { label: "요청됨", color: "text-blue-600", bg: "bg-blue-50" },
|
||||
in_progress: { label: "진행 중", color: "text-amber-600", bg: "bg-amber-50" },
|
||||
approved: { label: "승인 완료", color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||
rejected: { label: "반려", color: "text-rose-600", bg: "bg-rose-50" },
|
||||
cancelled: { label: "취소", color: "text-muted-foreground", bg: "bg-muted" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 결재 단계 시각화 컴포넌트
|
||||
* 결재 요청의 각 단계별 상태를 스테퍼 형태로 표시
|
||||
*/
|
||||
export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
formData,
|
||||
...props
|
||||
}) => {
|
||||
const componentConfig = (component.componentConfig || {}) as ApprovalStepConfig;
|
||||
const {
|
||||
targetTable,
|
||||
targetRecordIdField,
|
||||
displayMode = "horizontal",
|
||||
showComment = true,
|
||||
showTimestamp = true,
|
||||
showDept = true,
|
||||
compact = false,
|
||||
} = componentConfig;
|
||||
|
||||
const [stepData, setStepData] = useState<ApprovalStepData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const targetRecordId = targetRecordIdField && formData
|
||||
? String(formData[targetRecordIdField] || "")
|
||||
: "";
|
||||
|
||||
const fetchApprovalData = useCallback(async () => {
|
||||
if (isDesignMode || !targetTable || !targetRecordId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await getApprovalRequests({
|
||||
target_table: targetTable,
|
||||
target_record_id: targetRecordId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (res.success && res.data && res.data.length > 0) {
|
||||
const latestRequest = res.data[0];
|
||||
const detailRes = await getApprovalRequest(latestRequest.request_id);
|
||||
|
||||
if (detailRes.success && detailRes.data) {
|
||||
const request = detailRes.data;
|
||||
const lines = request.lines || [];
|
||||
const approvalMode =
|
||||
(request.target_record_data?.approval_mode as "sequential" | "parallel") || "sequential";
|
||||
|
||||
setStepData({ request, lines, approvalMode });
|
||||
}
|
||||
} else {
|
||||
setStepData(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("결재 정보를 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isDesignMode, targetTable, targetRecordId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApprovalData();
|
||||
}, [fetchApprovalData]);
|
||||
|
||||
// 디자인 모드용 샘플 데이터
|
||||
useEffect(() => {
|
||||
if (isDesignMode) {
|
||||
setStepData({
|
||||
request: {
|
||||
request_id: 0,
|
||||
title: "결재 요청 샘플",
|
||||
target_table: "sample_table",
|
||||
target_record_id: "1",
|
||||
status: "in_progress",
|
||||
current_step: 2,
|
||||
total_steps: 3,
|
||||
requester_id: "admin",
|
||||
requester_name: "홍길동",
|
||||
requester_dept: "개발팀",
|
||||
company_code: "SAMPLE",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
line_id: 1, request_id: 0, step_order: 1,
|
||||
approver_id: "user1", approver_name: "김부장", approver_position: "부장", approver_dept: "경영지원팀",
|
||||
status: "approved", comment: "확인했습니다.",
|
||||
processed_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
line_id: 2, request_id: 0, step_order: 2,
|
||||
approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀",
|
||||
status: "pending",
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
line_id: 3, request_id: 0, step_order: 3,
|
||||
approver_id: "user3", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀",
|
||||
status: "waiting",
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
approvalMode: "sequential",
|
||||
});
|
||||
}
|
||||
}, [isDesignMode]);
|
||||
|
||||
const componentStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: `${component.style?.width || 500}px`,
|
||||
height: "auto",
|
||||
minHeight: `${component.style?.height || 80}px`,
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
border: isSelected ? "2px solid #3b82f6" : "none",
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDesignMode) {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
const domProps = filterDOMProps(props);
|
||||
|
||||
const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
if (!isDesignMode && !targetTable) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div className="flex items-center justify-center rounded-md border border-dashed border-border p-4 text-xs text-muted-foreground">
|
||||
대상 테이블이 설정되지 않았습니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div className="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
결재 정보 로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stepData) {
|
||||
if (!isDesignMode && !targetRecordId) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
레코드를 선택하면 결재 현황이 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
결재 요청 내역이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { request, lines, approvalMode } = stepData;
|
||||
const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
{/* 헤더 - 요약 */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDesignMode) setExpanded((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">{request.title}</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
|
||||
{reqStatus.label}
|
||||
</span>
|
||||
{approvalMode === "parallel" && (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
|
||||
동시결재
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-[10px]">
|
||||
{request.current_step}/{request.total_steps}단계
|
||||
</span>
|
||||
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 스테퍼 영역 */}
|
||||
<div className={cn("border-t border-border", compact ? "px-2 py-1.5" : "px-3 py-2.5")}>
|
||||
{displayMode === "horizontal" ? (
|
||||
<HorizontalStepper
|
||||
lines={lines}
|
||||
approvalMode={approvalMode}
|
||||
compact={compact}
|
||||
showDept={showDept}
|
||||
/>
|
||||
) : (
|
||||
<VerticalStepper
|
||||
lines={lines}
|
||||
approvalMode={approvalMode}
|
||||
compact={compact}
|
||||
showDept={showDept}
|
||||
showComment={showComment}
|
||||
showTimestamp={showTimestamp}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확장 영역 - 상세 정보 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
|
||||
<span>상신자: {request.requester_name || request.requester_id}</span>
|
||||
{request.requester_dept && <span>부서: {request.requester_dept}</span>}
|
||||
<span>상신일: {formatDate(request.created_at)}</span>
|
||||
</div>
|
||||
{displayMode === "horizontal" && lines.length > 0 && (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{lines.map((line) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
return (
|
||||
<div key={line.line_id} className="flex items-start gap-2 text-[11px]">
|
||||
<span className={cn("mt-0.5 inline-block h-2 w-2 shrink-0 rounded-full", sc.dotColor)} />
|
||||
<span className="font-medium">{line.approver_name || line.approver_id}</span>
|
||||
<span className={cn("font-medium", sc.textColor)}>{sc.label}</span>
|
||||
{showTimestamp && line.processed_at && (
|
||||
<span className="text-muted-foreground">{formatDate(line.processed_at)}</span>
|
||||
)}
|
||||
{showComment && line.comment && (
|
||||
<span className="text-muted-foreground">- {line.comment}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ========== 가로형 스테퍼 ========== */
|
||||
interface StepperProps {
|
||||
lines: ApprovalLine[];
|
||||
approvalMode: "sequential" | "parallel";
|
||||
compact: boolean;
|
||||
showDept: boolean;
|
||||
showComment?: boolean;
|
||||
showTimestamp?: boolean;
|
||||
formatDate?: (d?: string | null) => string;
|
||||
}
|
||||
|
||||
const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compact, showDept }) => {
|
||||
if (lines.length === 0) {
|
||||
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 overflow-x-auto">
|
||||
{lines.map((line, idx) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const isLast = idx === lines.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={line.line_id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
{/* 아이콘 원 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border-2 transition-all",
|
||||
sc.bgColor,
|
||||
sc.borderColor,
|
||||
compact ? "h-6 w-6" : "h-8 w-8"
|
||||
)}
|
||||
>
|
||||
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
|
||||
</div>
|
||||
{/* 결재자 이름 */}
|
||||
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{/* 직급/부서 */}
|
||||
{showDept && !compact && (line.approver_position || line.approver_dept) && (
|
||||
<span className="max-w-[70px] truncate text-center text-[9px] text-muted-foreground">
|
||||
{line.approver_position || line.approver_dept}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연결선 */}
|
||||
{!isLast && (
|
||||
<div className="mx-1 flex shrink-0 items-center">
|
||||
{approvalMode === "parallel" ? (
|
||||
<div className="flex h-[1px] w-4 items-center border-t border-dashed border-muted-foreground/40" />
|
||||
) : (
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ========== 세로형 스테퍼 ========== */
|
||||
const VerticalStepper: React.FC<StepperProps> = ({
|
||||
lines,
|
||||
approvalMode,
|
||||
compact,
|
||||
showDept,
|
||||
showComment,
|
||||
showTimestamp,
|
||||
formatDate,
|
||||
}) => {
|
||||
if (lines.length === 0) {
|
||||
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{lines.map((line, idx) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const isLast = idx === lines.length - 1;
|
||||
|
||||
return (
|
||||
<div key={line.line_id} className="flex gap-3">
|
||||
{/* 타임라인 바 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full border-2",
|
||||
sc.bgColor,
|
||||
sc.borderColor,
|
||||
compact ? "h-5 w-5" : "h-7 w-7"
|
||||
)}
|
||||
>
|
||||
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3.5 w-3.5")} />
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[2px] flex-1",
|
||||
approvalMode === "parallel"
|
||||
? "border-l border-dashed border-muted-foreground/30"
|
||||
: "bg-muted-foreground/20",
|
||||
compact ? "min-h-[12px]" : "min-h-[20px]"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 결재자 정보 */}
|
||||
<div className={cn("pb-2", compact ? "pb-1" : "pb-3")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{showDept && (line.approver_position || line.approver_dept) && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-medium", sc.bgColor, sc.textColor)}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</div>
|
||||
{showTimestamp && line.processed_at && formatDate && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{formatDate(line.processed_at)}
|
||||
</div>
|
||||
)}
|
||||
{showComment && line.comment && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
"{line.comment}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApprovalStepWrapper: React.FC<ApprovalStepComponentProps> = (props) => {
|
||||
return <ApprovalStepComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Check, ChevronsUpDown, Table2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { ApprovalStepConfig } from "./types";
|
||||
|
||||
export interface ApprovalStepConfigPanelProps {
|
||||
config: ApprovalStepConfig;
|
||||
onChange: (config: Partial<ApprovalStepConfig>) => void;
|
||||
tables?: any[];
|
||||
allTables?: any[];
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
}
|
||||
|
||||
export const ApprovalStepConfigPanel: React.FC<ApprovalStepConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
}) => {
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<Array<{ columnName: string; label: string }>>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
|
||||
|
||||
const handleChange = (key: keyof ApprovalStepConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
const targetTableName = config.targetTable || screenTableName;
|
||||
|
||||
// 테이블 목록 가져오기 - tableTypeApi 사용 (다른 ConfigPanel과 동일)
|
||||
useEffect(() => {
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableTypeApi.getTables();
|
||||
setAvailableTables(
|
||||
response.map((table: any) => ({
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 가져오기 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
fetchTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드 - tableManagementApi 사용 (다른 ConfigPanel과 동일)
|
||||
useEffect(() => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data) {
|
||||
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
|
||||
if (columns && Array.isArray(columns)) {
|
||||
setAvailableColumns(
|
||||
columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 가져오기 실패:", error);
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
fetchColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
const handleTableChange = (newTableName: string) => {
|
||||
if (newTableName === targetTableName) return;
|
||||
handleChange("targetTable", newTableName);
|
||||
handleChange("targetRecordIdField", "");
|
||||
setTableComboboxOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">결재 단계 설정</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 대상 테이블 선택 - TableListConfigPanel과 동일한 Combobox */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 소스</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
결재 상태를 조회할 대상 테이블을 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">대상 테이블</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Table2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{loadingTables
|
||||
? "테이블 로딩 중..."
|
||||
: targetTableName
|
||||
? availableTables.find((t) => t.tableName === targetTableName)?.displayName ||
|
||||
targetTableName
|
||||
: "테이블 선택"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName}`}
|
||||
onSelect={() => handleTableChange(table.tableName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
targetTableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
{table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-gray-400">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{screenTableName && targetTableName !== screenTableName && (
|
||||
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
|
||||
<span className="text-[10px] text-amber-700">
|
||||
화면 기본 테이블({screenTableName})과 다른 테이블을 사용 중
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
|
||||
onClick={() => handleTableChange(screenTableName)}
|
||||
>
|
||||
기본으로
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레코드 ID 필드 선택 - 동일한 Combobox 패턴 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">레코드 식별</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
결재 대상 레코드를 식별할 PK 컬럼을 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">레코드 ID 필드명</Label>
|
||||
{targetTableName ? (
|
||||
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={columnComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingColumns}
|
||||
>
|
||||
<span className="truncate">
|
||||
{loadingColumns
|
||||
? "컬럼 로딩 중..."
|
||||
: config.targetRecordIdField
|
||||
? availableColumns.find((c) => c.columnName === config.targetRecordIdField)?.label ||
|
||||
config.targetRecordIdField
|
||||
: "컬럼 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={`${col.columnName} ${col.label}`}
|
||||
onSelect={() => {
|
||||
handleChange("targetRecordIdField", col.columnName);
|
||||
setColumnComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.targetRecordIdField === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.columnName && (
|
||||
<span className="text-[10px] text-gray-400">{col.columnName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
대상 테이블을 먼저 선택하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 모드 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">표시 설정</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
결재 단계의 표시 방식을 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">표시 모드</Label>
|
||||
<Select
|
||||
value={config.displayMode || "horizontal"}
|
||||
onValueChange={(v) => handleChange("displayMode", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="표시 모드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로형 스테퍼</SelectItem>
|
||||
<SelectItem value="vertical">세로형 타임라인</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 옵션 체크박스들 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 옵션</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showDept"
|
||||
checked={config.showDept !== false}
|
||||
onCheckedChange={(checked) => handleChange("showDept", !!checked)}
|
||||
/>
|
||||
<Label htmlFor="showDept" className="text-xs font-normal">
|
||||
부서/직급 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showComment"
|
||||
checked={config.showComment !== false}
|
||||
onCheckedChange={(checked) => handleChange("showComment", !!checked)}
|
||||
/>
|
||||
<Label htmlFor="showComment" className="text-xs font-normal">
|
||||
결재 코멘트 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showTimestamp"
|
||||
checked={config.showTimestamp !== false}
|
||||
onCheckedChange={(checked) => handleChange("showTimestamp", !!checked)}
|
||||
/>
|
||||
<Label htmlFor="showTimestamp" className="text-xs font-normal">
|
||||
처리 시각 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compact"
|
||||
checked={config.compact || false}
|
||||
onCheckedChange={(checked) => handleChange("compact", !!checked)}
|
||||
/>
|
||||
<Label htmlFor="compact" className="text-xs font-normal">
|
||||
콤팩트 모드 (작게 표시)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2ApprovalStepDefinition } from "./index";
|
||||
import { ApprovalStepComponent } from "./ApprovalStepComponent";
|
||||
|
||||
/**
|
||||
* ApprovalStep 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class ApprovalStepRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2ApprovalStepDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <ApprovalStepComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
ApprovalStepRenderer.registerSelf();
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
|
||||
import { ApprovalStepConfigPanel } from "./ApprovalStepConfigPanel";
|
||||
import { ApprovalStepConfig } from "./types";
|
||||
|
||||
/**
|
||||
* ApprovalStep 컴포넌트 정의
|
||||
* 결재 단계를 시각적으로 표시하는 스테퍼 컴포넌트
|
||||
*/
|
||||
export const V2ApprovalStepDefinition = createComponentDefinition({
|
||||
id: "v2-approval-step",
|
||||
name: "결재 단계",
|
||||
nameEng: "ApprovalStep Component",
|
||||
description: "결재 요청의 각 단계별 상태를 스테퍼 형태로 시각화합니다",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: ApprovalStepWrapper,
|
||||
defaultConfig: {
|
||||
targetTable: "",
|
||||
targetRecordIdField: "",
|
||||
displayMode: "horizontal",
|
||||
showComment: true,
|
||||
showTimestamp: true,
|
||||
showDept: true,
|
||||
compact: false,
|
||||
},
|
||||
defaultSize: { width: 500, height: 100 },
|
||||
configPanel: ApprovalStepConfigPanel,
|
||||
icon: "GitBranchPlus",
|
||||
tags: ["결재", "승인", "단계", "스테퍼", "워크플로우"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export type { ApprovalStepConfig } from "./types";
|
||||
export { ApprovalStepComponent } from "./ApprovalStepComponent";
|
||||
export { ApprovalStepRenderer } from "./ApprovalStepRenderer";
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* ApprovalStep 컴포넌트 설정 타입
|
||||
* 결재 단계를 시각적으로 표시하는 컴포넌트
|
||||
*/
|
||||
export interface ApprovalStepConfig extends ComponentConfig {
|
||||
targetTable?: string;
|
||||
targetRecordIdField?: string;
|
||||
displayMode?: "horizontal" | "vertical";
|
||||
showComment?: boolean;
|
||||
showTimestamp?: boolean;
|
||||
showDept?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
|
@ -585,7 +585,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
toast.dismiss();
|
||||
|
||||
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
||||
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval"];
|
||||
if (!silentActions.includes(actionConfig.type)) {
|
||||
currentLoadingToastRef.current = toast.loading(
|
||||
actionConfig.type === "save"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SplitPanelLayoutConfig } from "../types";
|
||||
|
||||
export interface CommonConfigTabProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange?: (key: string, value: any) => void;
|
||||
updateConfig: (updates: Partial<SplitPanelLayoutConfig>) => void;
|
||||
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||
}
|
||||
|
||||
export const CommonConfigTab: React.FC<CommonConfigTabProps> = ({
|
||||
config,
|
||||
updateConfig,
|
||||
updateRightPanel,
|
||||
}) => {
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 표시 방식</h3>
|
||||
<p className="text-muted-foreground text-xs">좌측 항목 선택 시 우측에 어떤 형태로 데이터를 보여줄지 설정합니다</p>
|
||||
<Select
|
||||
value={relationshipType}
|
||||
onValueChange={(value: "join" | "detail") => {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, type: value },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="표시 방식 선택">
|
||||
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="detail">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">선택 시 표시</span>
|
||||
<span className="text-xs text-gray-500">좌측 선택 시에만 우측 데이터 표시 / 미선택 시 빈 화면</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="join">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">연관 목록</span>
|
||||
<span className="text-xs text-gray-500">미선택 시 전체 표시 / 좌측 선택 시 필터링</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">레이아웃</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
<Slider
|
||||
value={[config.splitRatio || 30]}
|
||||
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
|
||||
min={20}
|
||||
max={80}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>크기 조절 가능</Label>
|
||||
<Switch
|
||||
checked={config.resizable ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>자동 데이터 로드</Label>
|
||||
<Switch
|
||||
checked={config.autoLoad ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,606 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, ChevronRight, Database, Link2, Move, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "../types";
|
||||
import { PanelInlineComponent } from "../types";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { SortableColumnRow, ScreenSelector } from "./SharedComponents";
|
||||
|
||||
export interface LeftPanelConfigTabProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
updateLeftPanel: (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => void;
|
||||
screenTableName?: string;
|
||||
allTables: any[];
|
||||
leftTableOpen: boolean;
|
||||
setLeftTableOpen: (open: boolean) => void;
|
||||
localTitles: { left: string; right: string };
|
||||
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
|
||||
isUserEditing: boolean;
|
||||
setIsUserEditing: (v: boolean) => void;
|
||||
leftTableColumns: ColumnInfo[];
|
||||
entityJoinColumns: Record<string, {
|
||||
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
|
||||
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
|
||||
}>;
|
||||
menuObjid?: number;
|
||||
}
|
||||
|
||||
export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
updateLeftPanel,
|
||||
screenTableName,
|
||||
allTables,
|
||||
leftTableOpen,
|
||||
setLeftTableOpen,
|
||||
localTitles,
|
||||
setLocalTitles,
|
||||
setIsUserEditing,
|
||||
leftTableColumns,
|
||||
entityJoinColumns,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 테이블</Label>
|
||||
<Popover open={leftTableOpen} onOpenChange={setLeftTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={leftTableOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{config.leftPanel?.tableName
|
||||
? allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.tableLabel ||
|
||||
allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.displayName ||
|
||||
config.leftPanel?.tableName
|
||||
: "테이블을 선택하세요"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => {
|
||||
updateLeftPanel({ tableName: screenTableName, columns: [] });
|
||||
setLeftTableOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.leftPanel?.tableName === screenTableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
|
||||
{allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.tableLabel ||
|
||||
allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.displayName ||
|
||||
screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter((t) => (t.tableName || t.table_name) !== screenTableName)
|
||||
.map((table) => {
|
||||
const tableName = table.tableName || table.table_name;
|
||||
const displayName = table.tableLabel || table.displayName || tableName;
|
||||
return (
|
||||
<CommandItem
|
||||
key={tableName}
|
||||
value={tableName}
|
||||
onSelect={() => {
|
||||
updateLeftPanel({ tableName, columns: [] });
|
||||
setLeftTableOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.leftPanel?.tableName === tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{displayName}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.leftPanel?.tableName && config.leftPanel?.tableName !== screenTableName && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={localTitles.left}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalTitles((prev) => ({ ...prev, left: e.target.value }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
updateLeftPanel({ title: localTitles.left });
|
||||
}}
|
||||
placeholder="좌측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>표시 모드</Label>
|
||||
<Select
|
||||
value={config.leftPanel?.displayMode || "list"}
|
||||
onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="표시 모드 선택">
|
||||
{(config.leftPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.leftPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">커스텀 (CUSTOM)</span>
|
||||
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.leftPanel?.displayMode === "custom" && (
|
||||
<p className="text-xs text-amber-600">
|
||||
화면 디자이너에서 좌측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.displayMode === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const updatedComponents = (config.leftPanel?.components || []).filter(
|
||||
(c: PanelInlineComponent) => c.id !== comp.id
|
||||
);
|
||||
onChange({
|
||||
...config,
|
||||
leftPanel: {
|
||||
...config.leftPanel,
|
||||
components: updatedComponents,
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.leftPanel?.displayMode !== "custom" && (() => {
|
||||
const selectedColumns = config.leftPanel?.columns || [];
|
||||
const filteredTableColumns = leftTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||
|
||||
const handleLeftDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
updateLeftPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시할 컬럼 ({selectedColumns.length}개 선택)</Label>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
|
||||
{leftTableColumns.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">컬럼 로딩 중...</p>
|
||||
) : (
|
||||
<>
|
||||
{selectedColumns.length > 0 && (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleLeftDragEnd}>
|
||||
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{selectedColumns.map((col, index) => {
|
||||
const colInfo = leftTableColumns.find((c) => c.columnName === col.name);
|
||||
const isNumeric = colInfo && (
|
||||
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||
);
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={col.name}
|
||||
id={col.name}
|
||||
col={col}
|
||||
index={index}
|
||||
isNumeric={!!isNumeric}
|
||||
isEntityJoin={!!(col as any).isEntityJoin}
|
||||
onLabelChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], label: value };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onWidthChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], width: value };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onFormatChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
|
||||
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{unselectedColumns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||
onClick={() => {
|
||||
updateLeftPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const leftTable = config.leftPanel?.tableName || screenTableName;
|
||||
const joinData = leftTable ? entityJoinColumns[leftTable] : null;
|
||||
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||
|
||||
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return false;
|
||||
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||
});
|
||||
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||
|
||||
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<details key={`join-${tableIndex}`} className="group">
|
||||
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||
{addedCount > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||
)}
|
||||
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||
</summary>
|
||||
<div className="space-y-0.5 pt-1">
|
||||
{joinColumnsToShow.map((column, colIndex) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||
onClick={() => {
|
||||
updateLeftPanel({
|
||||
columns: [...selectedColumns, {
|
||||
name: matchingJoinColumn.joinAlias,
|
||||
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||
width: 100,
|
||||
isEntityJoin: true,
|
||||
joinInfo: {
|
||||
sourceTable: leftTable!,
|
||||
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||
referenceTable: matchingJoinColumn.tableName,
|
||||
joinAlias: matchingJoinColumn.joinAlias,
|
||||
},
|
||||
}],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{joinColumnsToShow.length === 0 && (
|
||||
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 데이터 필터링</h3>
|
||||
<p className="text-muted-foreground text-xs">특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다</p>
|
||||
<DataFilterConfigPanel
|
||||
tableName={config.leftPanel?.tableName || screenTableName}
|
||||
columns={leftTableColumns.map(
|
||||
(col) =>
|
||||
({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel || col.columnName,
|
||||
dataType: col.dataType || "text",
|
||||
input_type: (col as any).input_type,
|
||||
}) as any,
|
||||
)}
|
||||
config={config.leftPanel?.dataFilter}
|
||||
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 버튼 설정</h3>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showSearch"
|
||||
checked={config.leftPanel?.showSearch ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showSearch: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showSearch" className="text-xs">검색</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showAdd"
|
||||
checked={config.leftPanel?.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showAdd: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showAdd" className="text-xs">추가</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showEdit"
|
||||
checked={config.leftPanel?.showEdit ?? true}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showEdit: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showEdit" className="text-xs">수정</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showDelete"
|
||||
checked={config.leftPanel?.showDelete ?? true}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showDelete: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showDelete" className="text-xs">삭제</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">추가 버튼 설정</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모드</Label>
|
||||
<Select
|
||||
value={config.leftPanel?.addButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateLeftPanel({
|
||||
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (내장 폼)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.addButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.leftPanel?.addButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateLeftPanel({
|
||||
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.addButton?.buttonLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateLeftPanel({
|
||||
addButton: {
|
||||
...config.leftPanel?.addButton,
|
||||
enabled: true,
|
||||
mode: config.leftPanel?.addButton?.mode || "auto",
|
||||
buttonLabel: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="추가"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.leftPanel?.showEdit ?? true) && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">수정 버튼 설정</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">수정 모드</Label>
|
||||
<Select
|
||||
value={config.leftPanel?.editButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateLeftPanel({
|
||||
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.editButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">수정 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.leftPanel?.editButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateLeftPanel({
|
||||
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.editButton?.buttonLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateLeftPanel({
|
||||
editButton: {
|
||||
...config.leftPanel?.editButton,
|
||||
enabled: true,
|
||||
mode: config.leftPanel?.editButton?.mode || "auto",
|
||||
buttonLabel: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="수정"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,801 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, ChevronRight, Link2, Move, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "../types";
|
||||
import { PanelInlineComponent } from "../types";
|
||||
import { ColumnInfo, TableInfo } from "@/types/screen";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { SortableColumnRow, ScreenSelector, GroupByColumnsSelector } from "./SharedComponents";
|
||||
|
||||
export interface RightPanelConfigTabProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||
relationshipType: "join" | "detail";
|
||||
localTitles: { left: string; right: string };
|
||||
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
|
||||
setIsUserEditing: (v: boolean) => void;
|
||||
rightTableOpen: boolean;
|
||||
setRightTableOpen: (open: boolean) => void;
|
||||
availableRightTables: TableInfo[];
|
||||
rightTableColumns: ColumnInfo[];
|
||||
entityJoinColumns: Record<string, {
|
||||
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
|
||||
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
|
||||
}>;
|
||||
menuObjid?: number;
|
||||
renderAdditionalTabs?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
updateRightPanel,
|
||||
relationshipType,
|
||||
localTitles,
|
||||
setLocalTitles,
|
||||
setIsUserEditing,
|
||||
rightTableOpen,
|
||||
setRightTableOpen,
|
||||
availableRightTables,
|
||||
rightTableColumns,
|
||||
entityJoinColumns,
|
||||
menuObjid,
|
||||
renderAdditionalTabs,
|
||||
}) => {
|
||||
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "선택 시 표시" : "연관 목록"})</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={localTitles.right}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalTitles((prev) => ({ ...prev, right: e.target.value }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
updateRightPanel({ title: localTitles.right });
|
||||
}}
|
||||
placeholder="우측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>우측 패널 테이블</Label>
|
||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightTableOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{config.rightPanel?.tableName || "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{availableRightTables.map((table) => {
|
||||
const tableName = (table as any).tableName || (table as any).table_name;
|
||||
return (
|
||||
<CommandItem
|
||||
key={tableName}
|
||||
value={`${(table as any).displayName || ""} ${tableName}`}
|
||||
onSelect={() => {
|
||||
updateRightPanel({ tableName });
|
||||
setRightTableOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{(table as any).displayName || tableName}
|
||||
{(table as any).displayName && <span className="ml-2 text-xs text-gray-500">({tableName})</span>}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>표시 모드</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.displayMode || "list"}
|
||||
onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="표시 모드 선택">
|
||||
{(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">커스텀 (CUSTOM)</span>
|
||||
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.rightPanel?.displayMode === "custom" && (
|
||||
<p className="text-xs text-amber-600">
|
||||
화면 디자이너에서 우측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.displayMode === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const updatedComponents = (config.rightPanel?.components || []).filter(
|
||||
(c: PanelInlineComponent) => c.id !== comp.id
|
||||
);
|
||||
onChange({
|
||||
...config,
|
||||
rightPanel: {
|
||||
...config.rightPanel,
|
||||
components: updatedComponents,
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.rightPanel?.displayMode || "list") === "list" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">표시할 컬럼 개수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={config.rightPanel?.summaryColumnCount ?? 3}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 3;
|
||||
updateRightPanel({ summaryColumnCount: value });
|
||||
}}
|
||||
className="bg-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">접기 전에 표시할 컬럼 개수 (기본: 3개)</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">라벨 표시</Label>
|
||||
<p className="text-xs text-gray-500">컬럼명 표시 여부</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={config.rightPanel?.summaryShowLabel ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
updateRightPanel({ summaryShowLabel: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.rightPanel?.displayMode !== "custom" && (() => {
|
||||
const selectedColumns = config.rightPanel?.columns || [];
|
||||
const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||
|
||||
const handleRightDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시할 컬럼 ({selectedColumns.length}개 선택)</Label>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
|
||||
{rightTableColumns.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">테이블을 선택해주세요</p>
|
||||
) : (
|
||||
<>
|
||||
{selectedColumns.length > 0 && (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleRightDragEnd}>
|
||||
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{selectedColumns.map((col, index) => {
|
||||
const colInfo = rightTableColumns.find((c) => c.columnName === col.name);
|
||||
const isNumeric = colInfo && (
|
||||
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||
);
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={col.name}
|
||||
id={col.name}
|
||||
col={col}
|
||||
index={index}
|
||||
isNumeric={!!isNumeric}
|
||||
isEntityJoin={!!(col as any).isEntityJoin}
|
||||
onLabelChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], label: value };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onWidthChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], width: value };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onFormatChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
onShowInSummaryChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onShowInDetailChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
|
||||
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{unselectedColumns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||
onClick={() => {
|
||||
updateRightPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const rightTable = config.rightPanel?.tableName;
|
||||
const joinData = rightTable ? entityJoinColumns[rightTable] : null;
|
||||
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||
|
||||
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return false;
|
||||
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||
});
|
||||
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||
|
||||
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<details key={`join-${tableIndex}`} className="group">
|
||||
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||
{addedCount > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||
)}
|
||||
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||
</summary>
|
||||
<div className="space-y-0.5 pt-1">
|
||||
{joinColumnsToShow.map((column, colIndex) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||
onClick={() => {
|
||||
updateRightPanel({
|
||||
columns: [...selectedColumns, {
|
||||
name: matchingJoinColumn.joinAlias,
|
||||
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||
width: 100,
|
||||
isEntityJoin: true,
|
||||
joinInfo: {
|
||||
sourceTable: rightTable!,
|
||||
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||
referenceTable: matchingJoinColumn.tableName,
|
||||
joinAlias: matchingJoinColumn.joinAlias,
|
||||
},
|
||||
}],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{joinColumnsToShow.length === 0 && (
|
||||
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 데이터 필터링</h3>
|
||||
<p className="text-muted-foreground text-xs">특정 컬럼 값으로 우측 패널 데이터를 필터링합니다</p>
|
||||
<DataFilterConfigPanel
|
||||
tableName={config.rightPanel?.tableName}
|
||||
columns={rightTableColumns.map(
|
||||
(col) =>
|
||||
({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel || col.columnName,
|
||||
dataType: col.dataType || "text",
|
||||
input_type: (col as any).input_type,
|
||||
}) as any,
|
||||
)}
|
||||
config={config.rightPanel?.dataFilter}
|
||||
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">중복 데이터 제거</h3>
|
||||
<p className="text-muted-foreground text-xs">같은 값을 가진 데이터를 하나로 통합하여 표시</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.rightPanel?.deduplication?.enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateRightPanel({
|
||||
deduplication: {
|
||||
enabled: true,
|
||||
groupByColumn: "",
|
||||
keepStrategy: "latest",
|
||||
sortColumn: "start_date",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateRightPanel({ deduplication: undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.deduplication?.enabled && (
|
||||
<div className="space-y-3 border-l-2 pl-4">
|
||||
<div>
|
||||
<Label className="text-xs">중복 제거 기준 컬럼</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deduplication?.groupByColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateRightPanel({
|
||||
deduplication: { ...config.rightPanel?.deduplication!, groupByColumn: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="기준 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rightTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
이 컬럼의 값이 같은 데이터들 중 하나만 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">유지 전략</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deduplication?.keepStrategy || "latest"}
|
||||
onValueChange={(value: any) =>
|
||||
updateRightPanel({
|
||||
deduplication: { ...config.rightPanel?.deduplication!, keepStrategy: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">최신 데이터 (가장 최근)</SelectItem>
|
||||
<SelectItem value="earliest">최초 데이터 (가장 오래된)</SelectItem>
|
||||
<SelectItem value="current_date">현재 유효한 데이터 (날짜 기준)</SelectItem>
|
||||
<SelectItem value="base_price">기준단가로 설정된 데이터</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(config.rightPanel?.deduplication?.keepStrategy === "latest" ||
|
||||
config.rightPanel?.deduplication?.keepStrategy === "earliest") && (
|
||||
<div>
|
||||
<Label className="text-xs">정렬 기준 컬럼</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deduplication?.sortColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateRightPanel({
|
||||
deduplication: { ...config.rightPanel?.deduplication!, sortColumn: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rightTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수정 버튼 설정 */}
|
||||
{config.rightPanel?.showEdit && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">수정 버튼 설정</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">수정 모드</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.editButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton,
|
||||
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||
mode: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||
<GroupByColumnsSelector
|
||||
tableName={config.rightPanel?.tableName}
|
||||
selectedColumns={config.rightPanel?.editButton?.groupByColumns || []}
|
||||
onChange={(columns) => {
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton!,
|
||||
groupByColumns: columns,
|
||||
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">수정 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.rightPanel?.editButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton!,
|
||||
modalScreenId: screenId,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.editButton?.buttonLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton!,
|
||||
buttonLabel: e.target.value || undefined,
|
||||
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="수정"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 버튼 설정 */}
|
||||
{config.rightPanel?.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">추가 버튼 설정</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">추가 모드</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.addButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateRightPanel({
|
||||
addButton: {
|
||||
...config.rightPanel?.addButton,
|
||||
mode: value,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (내장 폼)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.addButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.rightPanel?.addButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateRightPanel({
|
||||
addButton: {
|
||||
...config.rightPanel?.addButton!,
|
||||
modalScreenId: screenId,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.addButton?.buttonLabel || "추가"}
|
||||
onChange={(e) =>
|
||||
updateRightPanel({
|
||||
addButton: {
|
||||
...config.rightPanel?.addButton!,
|
||||
buttonLabel: e.target.value,
|
||||
enabled: true,
|
||||
mode: config.rightPanel?.addButton?.mode || "auto",
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="추가"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 설정 */}
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">삭제 버튼 설정</h3>
|
||||
<Switch
|
||||
checked={config.rightPanel?.deleteButton?.enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
enabled: checked,
|
||||
buttonLabel: config.rightPanel?.deleteButton?.buttonLabel,
|
||||
buttonVariant: config.rightPanel?.deleteButton?.buttonVariant,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.deleteButton?.buttonLabel || ""}
|
||||
placeholder="삭제"
|
||||
onChange={(e) => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
...config.rightPanel?.deleteButton!,
|
||||
buttonLabel: e.target.value || undefined,
|
||||
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 스타일</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deleteButton?.buttonVariant || "ghost"}
|
||||
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
...config.rightPanel?.deleteButton!,
|
||||
buttonVariant: value,
|
||||
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="destructive">Destructive (빨강)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">삭제 확인 메시지</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.deleteButton?.confirmMessage || ""}
|
||||
placeholder="정말 삭제하시겠습니까?"
|
||||
onChange={(e) => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
...config.rightPanel?.deleteButton!,
|
||||
confirmMessage: e.target.value || undefined,
|
||||
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가 탭 */}
|
||||
{renderAdditionalTabs && (
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">추가 탭</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
우측 패널에 다른 테이블 데이터를 탭으로 추가합니다
|
||||
</p>
|
||||
{renderAdditionalTabs()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SortableColumnRow({
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
}: {
|
||||
id: string;
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||
index: number;
|
||||
isNumeric: boolean;
|
||||
isEntityJoin?: boolean;
|
||||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onFormatChange: (checked: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
)}
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => onLabelChange(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={col.width || ""}
|
||||
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||
placeholder="너비"
|
||||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
{isNumeric && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.format?.thousandSeparator ?? false}
|
||||
onChange={(e) => onFormatChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
,
|
||||
</label>
|
||||
)}
|
||||
{onShowInSummaryChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.showInSummary !== false}
|
||||
onChange={(e) => onShowInSummaryChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
헤더
|
||||
</label>
|
||||
)}
|
||||
{onShowInDetailChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.showInDetail !== false}
|
||||
onChange={(e) => onShowInDetailChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
상세
|
||||
</label>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GroupByColumnsSelector: React.FC<{
|
||||
tableName?: string;
|
||||
selectedColumns: string[];
|
||||
onChange: (columns: string[]) => void;
|
||||
}> = ({ tableName, selectedColumns, onChange }) => {
|
||||
const [columns, setColumns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
const loadColumns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data && response.data.columns) {
|
||||
setColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [tableName]);
|
||||
|
||||
const toggleColumn = (columnName: string) => {
|
||||
const newSelection = selectedColumns.includes(columnName)
|
||||
? selectedColumns.filter((c) => c !== columnName)
|
||||
: [...selectedColumns, columnName];
|
||||
onChange(newSelection);
|
||||
};
|
||||
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="text-muted-foreground text-center text-xs">먼저 우측 패널의 테이블을 선택하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs">그룹핑 기준 컬럼</Label>
|
||||
{loading ? (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-muted-foreground text-center text-xs">로딩 중...</p>
|
||||
</div>
|
||||
) : columns.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="text-muted-foreground text-center text-xs">컬럼을 찾을 수 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-3">
|
||||
{columns.map((col) => (
|
||||
<div key={col.columnName} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`groupby-${col.columnName}`}
|
||||
checked={selectedColumns.includes(col.columnName)}
|
||||
onCheckedChange={() => toggleColumn(col.columnName)}
|
||||
/>
|
||||
<label htmlFor={`groupby-${col.columnName}`} className="flex-1 cursor-pointer text-xs">
|
||||
{col.columnLabel || col.columnName}
|
||||
<span className="text-muted-foreground ml-1">({col.columnName})</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
선택된 컬럼: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"}
|
||||
<br />
|
||||
같은 값을 가진 모든 레코드를 함께 불러옵니다
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScreenSelector: React.FC<{
|
||||
value?: number;
|
||||
onChange: (screenId?: number) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { screenApi } = await import("@/lib/api/screen");
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
setScreens(
|
||||
response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
const selectedScreen = screens.find((s) => s.screenId === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-6 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
|
||||
onSelect={() => {
|
||||
onChange(screen.screenId === value ? undefined : screen.screenId);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface BasicConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
screenTableName?: string;
|
||||
availableTables: Array<{ tableName: string; displayName: string }>;
|
||||
loadingTables: boolean;
|
||||
targetTableName: string | undefined;
|
||||
tableComboboxOpen: boolean;
|
||||
onTableComboboxOpenChange: (open: boolean) => void;
|
||||
onTableChange: (newTableName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 설정 패널: 테이블 선택, 데이터 소스
|
||||
*/
|
||||
export const BasicConfigPanel: React.FC<BasicConfigPanelProps> = ({
|
||||
config,
|
||||
screenTableName,
|
||||
availableTables,
|
||||
loadingTables,
|
||||
targetTableName,
|
||||
tableComboboxOpen,
|
||||
onTableComboboxOpenChange,
|
||||
onTableChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 소스</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
테이블을 선택하세요. 미선택 시 화면 메인 테이블을 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={onTableComboboxOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Table2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{loadingTables
|
||||
? "테이블 로딩 중..."
|
||||
: targetTableName
|
||||
? availableTables.find((t) => t.tableName === targetTableName)?.displayName || targetTableName
|
||||
: "테이블 선택"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName}`}
|
||||
onSelect={() => onTableChange(table.tableName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
targetTableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
{table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-gray-400">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{screenTableName && targetTableName && targetTableName !== screenTableName && (
|
||||
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
|
||||
<span className="text-[10px] text-amber-700">
|
||||
화면 기본 테이블({screenTableName})과 다른 테이블을 사용 중
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
|
||||
onClick={() => onTableChange(screenTableName)}
|
||||
>
|
||||
기본으로
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ColumnConfig } from "../types";
|
||||
import { Database, Link2, GripVertical, X, Check, ChevronsUpDown, Lock, Unlock } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
function SortableColumnRow({
|
||||
id,
|
||||
col,
|
||||
index,
|
||||
isEntityJoin,
|
||||
onLabelChange,
|
||||
onWidthChange,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
col: ColumnConfig;
|
||||
index: number;
|
||||
isEntityJoin?: boolean;
|
||||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
)}
|
||||
<Input
|
||||
value={col.displayName || col.columnName}
|
||||
onChange={(e) => onLabelChange(e.target.value)}
|
||||
placeholder="표시명"
|
||||
className="h-6 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={col.width || ""}
|
||||
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||
placeholder="너비"
|
||||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ColumnsConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
screenTableName?: string;
|
||||
targetTableName: string | undefined;
|
||||
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
|
||||
tableColumns?: any[];
|
||||
entityJoinColumns: {
|
||||
availableColumns: Array<{
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}>;
|
||||
joinTables: Array<{
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
entityDisplayConfigs: Record<
|
||||
string,
|
||||
{
|
||||
sourceColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
||||
joinColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
||||
selectedColumns: string[];
|
||||
separator: string;
|
||||
}
|
||||
>;
|
||||
onAddColumn: (columnName: string) => void;
|
||||
onAddEntityColumn: (joinColumn: {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}) => void;
|
||||
onRemoveColumn: (columnName: string) => void;
|
||||
onUpdateColumn: (columnName: string, updates: Partial<ColumnConfig>) => void;
|
||||
onToggleEntityDisplayColumn: (columnName: string, selectedColumn: string) => void;
|
||||
onUpdateEntityDisplaySeparator: (columnName: string, separator: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 설정 패널: 컬럼 선택, Entity 조인, DnD 순서 변경
|
||||
*/
|
||||
export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
targetTableName,
|
||||
availableColumns,
|
||||
tableColumns,
|
||||
entityJoinColumns,
|
||||
entityDisplayConfigs,
|
||||
onAddColumn,
|
||||
onAddEntityColumn,
|
||||
onRemoveColumn,
|
||||
onUpdateColumn,
|
||||
onToggleEntityDisplayColumn,
|
||||
onUpdateEntityDisplaySeparator,
|
||||
}) => {
|
||||
const handleChange = (key: string, value: any) => {
|
||||
onChange(key, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 엔티티 컬럼 표시 설정 섹션 */}
|
||||
{config.columns?.some((col: ColumnConfig) => col.isEntityJoin) && (
|
||||
<div className="space-y-3">
|
||||
{config.columns
|
||||
?.filter((col: ColumnConfig) => col.isEntityJoin && col.entityDisplayConfig)
|
||||
.map((column: ColumnConfig) => (
|
||||
<div key={column.columnName} className="space-y-2">
|
||||
<div className="mb-2">
|
||||
<span className="truncate text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
{column.displayName || column.columnName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entityDisplayConfigs[column.columnName] ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">구분자</Label>
|
||||
<Input
|
||||
value={entityDisplayConfigs[column.columnName].separator}
|
||||
onChange={(e) => onUpdateEntityDisplaySeparator(column.columnName, e.target.value)}
|
||||
className="h-6 w-full text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder=" - "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
|
||||
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
|
||||
<div className="py-2 text-center text-xs text-gray-400">
|
||||
표시 가능한 컬럼이 없습니다.
|
||||
{!column.entityDisplayConfig?.joinTable && (
|
||||
<p className="mt-1 text-[10px]">
|
||||
테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 w-full justify-between text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
||||
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}
|
||||
>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`source-${col.columnName}`}
|
||||
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{col.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
||||
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
|
||||
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`join-${col.columnName}`}
|
||||
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{col.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!column.entityDisplayConfig?.joinTable &&
|
||||
entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
|
||||
현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된
|
||||
테이블의 컬럼도 선택할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">미리보기</Label>
|
||||
<div className="flex flex-wrap gap-1 rounded bg-gray-50 p-2 text-xs">
|
||||
{entityDisplayConfigs[column.columnName].selectedColumns.map((colName, idx) => (
|
||||
<React.Fragment key={colName}>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{colName}
|
||||
</Badge>
|
||||
{idx < entityDisplayConfigs[column.columnName].selectedColumns.length - 1 && (
|
||||
<span className="text-gray-400">
|
||||
{entityDisplayConfigs[column.columnName].separator}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">컬럼 정보 로딩 중...</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!targetTableName ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>테이블이 선택되지 않았습니다.</p>
|
||||
<p className="text-sm">기본 설정 탭에서 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : availableColumns.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나</p>
|
||||
<p className="text-sm">기본 설정 탭에서 테이블을 설정해주세요.</p>
|
||||
<p className="mt-2 text-xs text-blue-600">현재 화면 테이블: {screenTableName}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">컬럼 선택</h3>
|
||||
<p className="text-muted-foreground text-[10px]">표시할 컬럼을 선택하세요</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
||||
{availableColumns.map((column) => {
|
||||
const isAdded = config.columns?.some((c: ColumnConfig) => c.columnName === column.columnName);
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
||||
isAdded && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isAdded) {
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
||||
);
|
||||
} else {
|
||||
onAddColumn(column.columnName);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isAdded}
|
||||
onCheckedChange={() => {
|
||||
if (isAdded) {
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
||||
);
|
||||
} else {
|
||||
onAddColumn(column.columnName);
|
||||
}
|
||||
}}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate text-xs">{column.label || column.columnName}</span>
|
||||
{isAdded && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
||||
? "편집 잠금 (클릭하여 해제)"
|
||||
: "편집 가능 (클릭하여 잠금)"
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
||||
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentCol = config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName);
|
||||
if (currentCol) {
|
||||
onUpdateColumn(column.columnName, {
|
||||
editable: currentCol.editable === false ? undefined : false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false ? (
|
||||
<Lock className="h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
|
||||
{column.input_type || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entityJoinColumns.joinTables.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Entity 조인 컬럼</h3>
|
||||
<p className="text-muted-foreground text-[10px]">연관 테이블의 컬럼을 선택하세요</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-3">
|
||||
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
|
||||
<div key={tableIndex} className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{joinTable.currentDisplayColumn}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
||||
{joinTable.availableColumns.map((column, colIndex) => {
|
||||
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
const isAlreadyAdded = config.columns?.some(
|
||||
(col: ColumnConfig) => col.columnName === matchingJoinColumn?.joinAlias,
|
||||
);
|
||||
if (!matchingJoinColumn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
||||
isAlreadyAdded && "bg-blue-100",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isAlreadyAdded) {
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter(
|
||||
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
||||
) || [],
|
||||
);
|
||||
} else {
|
||||
onAddEntityColumn(matchingJoinColumn);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isAlreadyAdded}
|
||||
onCheckedChange={() => {
|
||||
if (isAlreadyAdded) {
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter(
|
||||
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
||||
) || [],
|
||||
);
|
||||
} else {
|
||||
onAddEntityColumn(matchingJoinColumn);
|
||||
}
|
||||
}}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-blue-400">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.columns && config.columns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const columns = [...(config.columns || [])];
|
||||
const oldIndex = columns.findIndex((c: ColumnConfig) => c.columnName === active.id);
|
||||
const newIndex = columns.findIndex((c: ColumnConfig) => c.columnName === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const reordered = arrayMove(columns, oldIndex, newIndex);
|
||||
reordered.forEach((col: ColumnConfig, idx: number) => {
|
||||
col.order = idx;
|
||||
});
|
||||
handleChange("columns", reordered);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={(config.columns || []).map((c: ColumnConfig) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{(config.columns || []).map((column: ColumnConfig, idx: number) => {
|
||||
const resolvedLabel =
|
||||
column.displayName && column.displayName !== column.columnName
|
||||
? column.displayName
|
||||
: availableColumns.find((c) => c.columnName === column.columnName)?.label ||
|
||||
column.displayName ||
|
||||
column.columnName;
|
||||
const colWithLabel = { ...column, displayName: resolvedLabel };
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={column.columnName}
|
||||
id={column.columnName}
|
||||
col={colWithLabel}
|
||||
index={idx}
|
||||
isEntityJoin={!!column.isEntityJoin}
|
||||
onLabelChange={(value) => onUpdateColumn(column.columnName, { displayName: value })}
|
||||
onWidthChange={(value) => onUpdateColumn(column.columnName, { width: value })}
|
||||
onRemove={() => onRemoveColumn(column.columnName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
|
||||
export interface OptionsConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
onNestedChange: (parentKey: string, childKey: string, value: any) => void;
|
||||
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 옵션 설정 패널: 툴바, 체크박스, 기본 정렬, 가로 스크롤, 데이터 필터링
|
||||
*/
|
||||
export const OptionsConfigPanel: React.FC<OptionsConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
onNestedChange,
|
||||
availableColumns,
|
||||
screenTableName,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 툴바 버튼 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">툴바 버튼 설정</h3>
|
||||
<p className="text-muted-foreground text-[10px]">테이블 상단에 표시할 버튼을 선택합니다</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showEditMode"
|
||||
checked={config.toolbar?.showEditMode ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showEditMode", checked)}
|
||||
/>
|
||||
<Label htmlFor="showEditMode" className="text-xs">
|
||||
즉시 저장
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showExcel"
|
||||
checked={config.toolbar?.showExcel ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showExcel", checked)}
|
||||
/>
|
||||
<Label htmlFor="showExcel" className="text-xs">
|
||||
Excel
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showPdf"
|
||||
checked={config.toolbar?.showPdf ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showPdf", checked)}
|
||||
/>
|
||||
<Label htmlFor="showPdf" className="text-xs">
|
||||
PDF
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showCopy"
|
||||
checked={config.toolbar?.showCopy ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showCopy", checked)}
|
||||
/>
|
||||
<Label htmlFor="showCopy" className="text-xs">
|
||||
복사
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showSearch"
|
||||
checked={config.toolbar?.showSearch ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showSearch", checked)}
|
||||
/>
|
||||
<Label htmlFor="showSearch" className="text-xs">
|
||||
검색
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showFilter"
|
||||
checked={config.toolbar?.showFilter ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showFilter", checked)}
|
||||
/>
|
||||
<Label htmlFor="showFilter" className="text-xs">
|
||||
필터
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showRefresh"
|
||||
checked={config.toolbar?.showRefresh ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showRefresh", checked)}
|
||||
/>
|
||||
<Label htmlFor="showRefresh" className="text-xs">
|
||||
새로고침 (상단)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showPaginationRefresh"
|
||||
checked={config.toolbar?.showPaginationRefresh ?? true}
|
||||
onCheckedChange={(checked) => onNestedChange("toolbar", "showPaginationRefresh", checked)}
|
||||
/>
|
||||
<Label htmlFor="showPaginationRefresh" className="text-xs">
|
||||
새로고침 (하단)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">체크박스 설정</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="checkboxEnabled"
|
||||
checked={config.checkbox?.enabled ?? true}
|
||||
onCheckedChange={(checked) => onNestedChange("checkbox", "enabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="checkboxEnabled">체크박스 표시</Label>
|
||||
</div>
|
||||
|
||||
{config.checkbox?.enabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="checkboxSelectAll"
|
||||
checked={config.checkbox?.selectAll ?? true}
|
||||
onCheckedChange={(checked) => onNestedChange("checkbox", "selectAll", checked)}
|
||||
/>
|
||||
<Label htmlFor="checkboxSelectAll">전체 선택 체크박스 표시</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="checkboxPosition" className="text-xs">
|
||||
체크박스 위치
|
||||
</Label>
|
||||
<select
|
||||
id="checkboxPosition"
|
||||
value={config.checkbox?.position || "left"}
|
||||
onChange={(e) => onNestedChange("checkbox", "position", e.target.value)}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정렬 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">기본 정렬 설정</h3>
|
||||
<p className="text-muted-foreground text-[10px]">테이블 로드 시 기본 정렬 순서를 지정합니다</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="defaultSortColumn" className="text-xs">
|
||||
정렬 컬럼
|
||||
</Label>
|
||||
<select
|
||||
id="defaultSortColumn"
|
||||
value={config.defaultSort?.columnName || ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
onChange("defaultSort", {
|
||||
columnName: e.target.value,
|
||||
direction: config.defaultSort?.direction || "asc",
|
||||
});
|
||||
} else {
|
||||
onChange("defaultSort", undefined);
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="">정렬 없음</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col.columnName} value={col.columnName}>
|
||||
{col.label || col.columnName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.defaultSort?.columnName && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="defaultSortDirection" className="text-xs">
|
||||
정렬 방향
|
||||
</Label>
|
||||
<select
|
||||
id="defaultSortDirection"
|
||||
value={config.defaultSort?.direction || "asc"}
|
||||
onChange={(e) =>
|
||||
onChange("defaultSort", {
|
||||
...config.defaultSort,
|
||||
columnName: config.defaultSort?.columnName || "",
|
||||
direction: e.target.value as "asc" | "desc",
|
||||
})
|
||||
}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="asc">오름차순 (A→Z, 1→9)</option>
|
||||
<option value="desc">내림차순 (Z→A, 9→1)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가로 스크롤 및 컬럼 고정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">가로 스크롤 및 컬럼 고정</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="horizontalScrollEnabled"
|
||||
checked={config.horizontalScroll?.enabled}
|
||||
onCheckedChange={(checked) => onNestedChange("horizontalScroll", "enabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="horizontalScrollEnabled">가로 스크롤 사용</Label>
|
||||
</div>
|
||||
|
||||
{config.horizontalScroll?.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="maxVisibleColumns" className="text-sm">
|
||||
최대 표시 컬럼 수
|
||||
</Label>
|
||||
<Input
|
||||
id="maxVisibleColumns"
|
||||
type="number"
|
||||
value={config.horizontalScroll?.maxVisibleColumns || 8}
|
||||
onChange={(e) =>
|
||||
onNestedChange("horizontalScroll", "maxVisibleColumns", parseInt(e.target.value) || 8)
|
||||
}
|
||||
min={3}
|
||||
max={20}
|
||||
placeholder="8"
|
||||
className="h-8"
|
||||
/>
|
||||
<div className="text-xs text-gray-500">이 수를 넘는 컬럼이 있으면 가로 스크롤이 생성됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 데이터 필터링 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 필터링</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">특정 컬럼 값으로 데이터를 필터링합니다</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<DataFilterConfigPanel
|
||||
tableName={config.selectedTable || screenTableName}
|
||||
columns={availableColumns.map(
|
||||
(col) =>
|
||||
({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.label || col.columnName,
|
||||
dataType: col.dataType,
|
||||
input_type: col.input_type,
|
||||
}) as any,
|
||||
)}
|
||||
config={config.dataFilter}
|
||||
onConfigChange={(dataFilter) => onChange("dataFilter", dataFilter)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
export interface StyleConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
onNestedChange: (parentKey: string, childKey: string, value: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스타일 설정 패널: 테이블 스타일 (theme, headerStyle, rowHeight 등)
|
||||
*/
|
||||
export const StyleConfigPanel: React.FC<StyleConfigPanelProps> = ({
|
||||
config,
|
||||
onNestedChange,
|
||||
}) => {
|
||||
const tableStyle = config.tableStyle || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">테이블 스타일</h3>
|
||||
<p className="text-muted-foreground text-[10px]">테이블의 시각적 스타일을 설정합니다</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="tableTheme" className="text-xs">
|
||||
테마
|
||||
</Label>
|
||||
<select
|
||||
id="tableTheme"
|
||||
value={tableStyle.theme || "default"}
|
||||
onChange={(e) =>
|
||||
onNestedChange("tableStyle", "theme", e.target.value as "default" | "striped" | "bordered" | "minimal")
|
||||
}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="default">기본</option>
|
||||
<option value="striped">줄무늬</option>
|
||||
<option value="bordered">테두리</option>
|
||||
<option value="minimal">미니멀</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="headerStyle" className="text-xs">
|
||||
헤더 스타일
|
||||
</Label>
|
||||
<select
|
||||
id="headerStyle"
|
||||
value={tableStyle.headerStyle || "default"}
|
||||
onChange={(e) =>
|
||||
onNestedChange("tableStyle", "headerStyle", e.target.value as "default" | "dark" | "light")
|
||||
}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="default">기본</option>
|
||||
<option value="dark">다크</option>
|
||||
<option value="light">라이트</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rowHeight" className="text-xs">
|
||||
행 높이
|
||||
</Label>
|
||||
<select
|
||||
id="rowHeight"
|
||||
value={tableStyle.rowHeight || "normal"}
|
||||
onChange={(e) =>
|
||||
onNestedChange("tableStyle", "rowHeight", e.target.value as "compact" | "normal" | "comfortable")
|
||||
}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="compact">좁게</option>
|
||||
<option value="normal">보통</option>
|
||||
<option value="comfortable">넓게</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="alternateRows"
|
||||
checked={tableStyle.alternateRows ?? false}
|
||||
onCheckedChange={(checked) => onNestedChange("tableStyle", "alternateRows", checked)}
|
||||
/>
|
||||
<Label htmlFor="alternateRows" className="text-xs">
|
||||
교행 색상
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="hoverEffect"
|
||||
checked={tableStyle.hoverEffect ?? true}
|
||||
onCheckedChange={(checked) => onNestedChange("tableStyle", "hoverEffect", checked)}
|
||||
/>
|
||||
<Label htmlFor="hoverEffect" className="text-xs">
|
||||
호버 효과
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderStyle" className="text-xs">
|
||||
테두리 스타일
|
||||
</Label>
|
||||
<select
|
||||
id="borderStyle"
|
||||
value={tableStyle.borderStyle || "light"}
|
||||
onChange={(e) =>
|
||||
onNestedChange("tableStyle", "borderStyle", e.target.value as "none" | "light" | "heavy")
|
||||
}
|
||||
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||
>
|
||||
<option value="none">없음</option>
|
||||
<option value="light">얇게</option>
|
||||
<option value="heavy">굵게</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||
import { LayoutDefinition } from "@/types/layout";
|
||||
import { LayoutRegistry } from "../LayoutRegistry";
|
||||
import { ComponentData, LayoutComponent } from "@/types/screen";
|
||||
import { LayoutZone } from "@/types/layout";
|
||||
import React from "react";
|
||||
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
|
||||
|
||||
/**
|
||||
* 자동 등록 기능을 제공하는 베이스 레이아웃 렌더러
|
||||
*
|
||||
* 사용 방법:
|
||||
* 1. 이 클래스를 상속받습니다
|
||||
* 2. static layoutDefinition을 정의합니다
|
||||
* 3. 파일을 import하면 자동으로 등록됩니다
|
||||
*/
|
||||
export class AutoRegisteringLayoutRenderer {
|
||||
protected props: LayoutRendererProps;
|
||||
|
||||
constructor(props: LayoutRendererProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 정의 - 각 구현 클래스에서 반드시 정의해야 함
|
||||
*/
|
||||
static readonly layoutDefinition: LayoutDefinition;
|
||||
|
||||
/**
|
||||
* 렌더링 메서드 - 각 구현 클래스에서 오버라이드해야 함
|
||||
*/
|
||||
render(): React.ReactElement {
|
||||
throw new Error("render() method must be implemented by subclass");
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 컨테이너 스타일을 계산합니다.
|
||||
*/
|
||||
getLayoutContainerStyle(): React.CSSProperties {
|
||||
const { layout, style: propStyle } = this.props;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
width: layout.size.width,
|
||||
height: layout.size.height,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
...propStyle,
|
||||
};
|
||||
|
||||
// 레이아웃 커스텀 스타일 적용
|
||||
if (layout.style) {
|
||||
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 스타일을 CSS 스타일로 변환합니다.
|
||||
*/
|
||||
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
|
||||
const cssStyle: React.CSSProperties = {};
|
||||
|
||||
if (componentStyle.backgroundColor) {
|
||||
cssStyle.backgroundColor = componentStyle.backgroundColor;
|
||||
}
|
||||
if (componentStyle.borderColor) {
|
||||
cssStyle.borderColor = componentStyle.borderColor;
|
||||
}
|
||||
if (componentStyle.borderWidth) {
|
||||
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
|
||||
}
|
||||
if (componentStyle.borderStyle) {
|
||||
cssStyle.borderStyle = componentStyle.borderStyle;
|
||||
}
|
||||
if (componentStyle.borderRadius) {
|
||||
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
|
||||
}
|
||||
|
||||
return cssStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 존별 자식 컴포넌트들을 분류합니다.
|
||||
*/
|
||||
getZoneChildren(zoneId: string): ComponentData[] {
|
||||
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 존을 렌더링합니다.
|
||||
*/
|
||||
renderZone(
|
||||
zone: LayoutZone,
|
||||
zoneChildren: ComponentData[] = [],
|
||||
additionalProps: Record<string, any> = {},
|
||||
): React.ReactElement {
|
||||
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
|
||||
|
||||
// 존 스타일 계산 - 항상 구역 경계 표시
|
||||
const zoneStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
// 구역 경계 시각화 - 항상 표시
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
||||
transition: "all 0.2s ease",
|
||||
...this.getZoneStyle(zone),
|
||||
...additionalProps.style,
|
||||
};
|
||||
|
||||
// 디자인 모드일 때 더 강조된 스타일
|
||||
if (isDesignMode) {
|
||||
zoneStyle.border = "2px dashed #cbd5e1";
|
||||
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
||||
}
|
||||
|
||||
// 호버 효과를 위한 추가 스타일
|
||||
const dropZoneStyle: React.CSSProperties = {
|
||||
minHeight: isDesignMode ? "60px" : "40px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
||||
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
|
||||
color: "#64748b",
|
||||
fontSize: "12px",
|
||||
transition: "all 0.2s ease",
|
||||
padding: "8px",
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={zone.id}
|
||||
className={`layout-zone ${additionalProps.className || ""}`}
|
||||
style={zoneStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onZoneClick?.(zone.id, e);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const componentData = e.dataTransfer.getData("application/json");
|
||||
if (componentData) {
|
||||
try {
|
||||
const component = JSON.parse(componentData);
|
||||
onComponentDrop?.(zone.id, component, e);
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#3b82f6";
|
||||
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
||||
e.currentTarget.style.backgroundColor = isDesignMode
|
||||
? "rgba(241, 245, 249, 0.8)"
|
||||
: "rgba(248, 250, 252, 0.5)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
{...additionalProps}
|
||||
>
|
||||
{/* 존 라벨 */}
|
||||
<div
|
||||
className="zone-label"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-2px",
|
||||
left: "8px",
|
||||
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
|
||||
color: "white",
|
||||
fontSize: "10px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "0 0 4px 4px",
|
||||
fontWeight: "500",
|
||||
zIndex: 10,
|
||||
opacity: isDesignMode ? 1 : 0.7,
|
||||
}}
|
||||
>
|
||||
{zone.name || zone.id}
|
||||
</div>
|
||||
|
||||
{/* 드롭존 */}
|
||||
<div className="drop-zone" style={dropZoneStyle}>
|
||||
{zoneChildren.length > 0 ? (
|
||||
zoneChildren.map((child) => (
|
||||
<DynamicComponentRenderer
|
||||
key={child.id}
|
||||
component={child}
|
||||
allComponents={this.props.allComponents}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
|
||||
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 존의 스타일을 계산합니다.
|
||||
*/
|
||||
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
|
||||
const style: React.CSSProperties = {};
|
||||
|
||||
if (zone.size) {
|
||||
if (zone.size.width) {
|
||||
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
|
||||
}
|
||||
if (zone.size.height) {
|
||||
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
|
||||
}
|
||||
if (zone.size.minWidth) {
|
||||
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
|
||||
}
|
||||
if (zone.size.minHeight) {
|
||||
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 등록 상태 추적
|
||||
*/
|
||||
private static registeredLayouts = new Set<string>();
|
||||
|
||||
/**
|
||||
* 클래스가 정의될 때 자동으로 레지스트리에 등록
|
||||
*/
|
||||
static registerSelf(): void {
|
||||
const definition = this.layoutDefinition;
|
||||
|
||||
if (!definition) {
|
||||
console.error(`❌ ${this.name}: layoutDefinition이 정의되지 않았습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registeredLayouts.has(definition.id)) {
|
||||
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 레지스트리에 등록
|
||||
LayoutRegistry.registerLayout(definition);
|
||||
this.registeredLayouts.add(definition.id);
|
||||
|
||||
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
|
||||
|
||||
// 개발 모드에서 추가 정보 출력
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log(`📦 ${definition.id}:`, {
|
||||
name: definition.name,
|
||||
category: definition.category,
|
||||
zones: definition.defaultZones?.length || 0,
|
||||
tags: definition.tags?.join(", ") || "none",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ ${definition.id} 레이아웃 등록 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 등록 해제 (개발 모드에서 Hot Reload용)
|
||||
*/
|
||||
static unregisterSelf(): void {
|
||||
const definition = this.layoutDefinition;
|
||||
|
||||
if (definition && this.registeredLayouts.has(definition.id)) {
|
||||
LayoutRegistry.unregisterLayout(definition.id);
|
||||
this.registeredLayouts.delete(definition.id);
|
||||
console.log(`🗑️ 등록 해제: ${definition.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot Reload 지원 (개발 모드)
|
||||
*/
|
||||
static reloadSelf(): void {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
this.unregisterSelf();
|
||||
this.registerSelf();
|
||||
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 레이아웃 목록 조회
|
||||
*/
|
||||
static getRegisteredLayouts(): string[] {
|
||||
return Array.from(this.registeredLayouts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 정의 유효성 검사
|
||||
*/
|
||||
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||
const definition = this.layoutDefinition;
|
||||
|
||||
if (!definition) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: ["layoutDefinition이 정의되지 않았습니다."],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 필수 필드 검사
|
||||
if (!definition.id) errors.push("ID가 필요합니다.");
|
||||
if (!definition.name) errors.push("이름이 필요합니다.");
|
||||
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
|
||||
if (!definition.category) errors.push("카테고리가 필요합니다.");
|
||||
|
||||
// 권장사항 검사
|
||||
if (!definition.description || definition.description.length < 10) {
|
||||
warnings.push("설명은 10자 이상 권장됩니다.");
|
||||
}
|
||||
if (!definition.defaultZones || definition.defaultZones.length === 0) {
|
||||
warnings.push("기본 존 정의가 권장됩니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발 모드에서 Hot Module Replacement 지원
|
||||
*/
|
||||
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
||||
// HMR API가 있는 경우 등록
|
||||
if ((module as any).hot) {
|
||||
(module as any).hot.accept();
|
||||
|
||||
// 글로벌 Hot Reload 함수 등록
|
||||
(window as any).__reloadLayout__ = (layoutId: string) => {
|
||||
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
|
||||
console.log(`🔄 Available layouts for reload:`, layouts);
|
||||
|
||||
// TODO: 특정 레이아웃만 리로드하는 로직 구현
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,9 @@
|
|||
/**
|
||||
* card-layout 기본 설정
|
||||
*/
|
||||
export const Card-layoutLayoutConfig = {
|
||||
export const CardLayoutConfig = {
|
||||
defaultConfig: {
|
||||
card-layout: {
|
||||
// TODO: 레이아웃 전용 설정 정의
|
||||
// 예시:
|
||||
// spacing: 16,
|
||||
// orientation: "vertical",
|
||||
// allowResize: true,
|
||||
"card-layout": {
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -51,14 +46,12 @@ export const Card-layoutLayoutConfig = {
|
|||
}
|
||||
],
|
||||
|
||||
// 설정 스키마 (검증용)
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
card-layout: {
|
||||
"card-layout": {
|
||||
type: "object",
|
||||
properties: {
|
||||
// TODO: 설정 스키마 정의
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,26 +3,21 @@ import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
|||
/**
|
||||
* card-layout 설정 타입
|
||||
*/
|
||||
export interface Card-layoutConfig {
|
||||
export interface CardLayoutConfig {
|
||||
// TODO: 레이아웃 전용 설정 타입 정의
|
||||
// 예시:
|
||||
// spacing?: number;
|
||||
// orientation?: "vertical" | "horizontal";
|
||||
// allowResize?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* card-layout Props 타입
|
||||
*/
|
||||
export interface Card-layoutLayoutProps extends LayoutRendererProps {
|
||||
renderer: any; // Card-layoutLayoutRenderer 타입
|
||||
export interface CardLayoutLayoutProps extends LayoutRendererProps {
|
||||
renderer: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* card-layout 존 타입
|
||||
*/
|
||||
export interface Card-layoutZone {
|
||||
export interface CardLayoutZone {
|
||||
id: string;
|
||||
name: string;
|
||||
// TODO: 존별 전용 속성 정의
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
export const HeroSectionLayoutConfig = {
|
||||
defaultConfig: {
|
||||
hero-section: {
|
||||
"hero-section": {
|
||||
// TODO: 레이아웃 전용 설정 정의
|
||||
// 예시:
|
||||
// spacing: 16,
|
||||
|
|
@ -37,7 +37,7 @@ export const HeroSectionLayoutConfig = {
|
|||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hero-section: {
|
||||
"hero-section": {
|
||||
type: "object",
|
||||
properties: {
|
||||
// TODO: 설정 스키마 정의
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ export type ButtonActionType =
|
|||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||
| "approval"; // 결재 요청
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
|
|
@ -451,6 +452,9 @@ export class ButtonActionExecutor {
|
|||
case "event":
|
||||
return await this.handleEvent(config, context);
|
||||
|
||||
case "approval":
|
||||
return this.handleApproval(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -7598,6 +7602,37 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 요청 모달 열기
|
||||
*/
|
||||
private static handleApproval(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
try {
|
||||
const selectedRow = context.selectedRowsData?.[0] || context.formData || {};
|
||||
const targetTable = (config as any).approvalTargetTable || context.tableName || "";
|
||||
const recordIdField = (config as any).approvalRecordIdField || "id";
|
||||
const targetRecordId = selectedRow?.[recordIdField] || "";
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable,
|
||||
targetRecordId: targetRecordId ? String(targetRecordId) : "",
|
||||
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
|
||||
definitionId: (config as any).approvalDefinitionId || undefined,
|
||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||
buttonComponentId: context.formData?.buttonId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[handleApproval] 결재 요청 오류:", error);
|
||||
toast.error("결재 요청 모달을 열 수 없습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -7722,4 +7757,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
event: {
|
||||
type: "event",
|
||||
},
|
||||
approval: {
|
||||
type: "approval",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -976,6 +976,35 @@ export class ImprovedButtonActionExecutor {
|
|||
return await this.executeTransferDataAction(buttonConfig, formData, context);
|
||||
}
|
||||
|
||||
// 결재 요청 모달 열기
|
||||
if (buttonConfig.actionType === "approval") {
|
||||
const actionConfig = (buttonConfig as any).componentConfig?.action || buttonConfig;
|
||||
const selectedRow = context.selectedRows?.[0] || context.formData || formData || {};
|
||||
const targetTable = actionConfig.approvalTargetTable || "";
|
||||
const recordIdField = actionConfig.approvalRecordIdField || "id";
|
||||
const targetRecordId = selectedRow?.[recordIdField] || "";
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable,
|
||||
targetRecordId: targetRecordId ? String(targetRecordId) : "",
|
||||
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
|
||||
definitionId: actionConfig.approvalDefinitionId || undefined,
|
||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||
buttonComponentId: context.buttonId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "결재 요청 모달이 열렸습니다",
|
||||
executionTime: performance.now() - startTime,
|
||||
data: { actionType: "approval", targetTable, targetRecordId },
|
||||
};
|
||||
}
|
||||
|
||||
// 기존 액션들 (임시 구현)
|
||||
const result = {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* 결재함 플로우 E2E 테스트 스크립트
|
||||
* 실행: npx tsx scripts/approval-flow-test.ts
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const LOGIN_ID = "wace";
|
||||
const LOGIN_PW = "1234";
|
||||
const FALLBACK_PW = "qlalfqjsgh11"; // 마스터 패스워드 (1234 실패 시)
|
||||
|
||||
async function main() {
|
||||
const results: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 }, // 데스크톱 뷰 (사이드바 표시)
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// 콘솔 에러 수집
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
if (type === "error") {
|
||||
const text = msg.text();
|
||||
consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. http://localhost:9771 이동
|
||||
results.push("=== 1. http://localhost:9771 이동 ===");
|
||||
await page.goto(BASE_URL, { waitUntil: "networkidle", timeout: 15000 });
|
||||
results.push("OK: 페이지 로드 완료");
|
||||
|
||||
// 2. 로그인 여부 확인
|
||||
results.push("\n=== 2. 로그인 상태 확인 ===");
|
||||
const isLoginPage = await page.locator('#userId, input[name="userId"]').count() > 0;
|
||||
if (isLoginPage) {
|
||||
results.push("로그인 페이지 감지됨. 로그인 시도...");
|
||||
await page.fill('#userId', LOGIN_ID);
|
||||
await page.fill('#password', LOGIN_PW);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// 여전히 로그인 페이지면 마스터 패스워드로 재시도
|
||||
const stillLoginPage = await page.locator('#userId').count() > 0;
|
||||
if (stillLoginPage) {
|
||||
results.push("1234 로그인 실패. 마스터 패스워드로 재시도...");
|
||||
await page.fill('#userId', LOGIN_ID);
|
||||
await page.fill('#password', FALLBACK_PW);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
results.push("로그인 폼 제출 완료");
|
||||
} else {
|
||||
results.push("이미 로그인된 상태로 판단 (로그인 폼 없음)");
|
||||
}
|
||||
|
||||
// 3. 사용자 프로필 아바타 클릭 (사이드바 하단)
|
||||
results.push("\n=== 3. 사용자 프로필 아바타 클릭 ===");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 사이드바 하단 사용자 프로필 버튼 (border-t border-slate-200 내부의 button)
|
||||
const sidebarAvatarBtn = page.locator('aside div.border-t.border-slate-200 button').first();
|
||||
let avatarClicked = false;
|
||||
if ((await sidebarAvatarBtn.count()) > 0) {
|
||||
try {
|
||||
// force: true - Next.js dev overlay가 클릭을 가로채는 경우 우회
|
||||
await sidebarAvatarBtn.click({ timeout: 5000, force: true });
|
||||
avatarClicked = true;
|
||||
results.push("OK: 사이드바 하단 아바타 클릭 완료");
|
||||
await page.waitForTimeout(500); // 드롭다운 열림 대기
|
||||
} catch (e) {
|
||||
results.push(`WARN: 사이드바 아바타 클릭 실패 - ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarClicked) {
|
||||
// 모바일 헤더 아바타 또는 fallback
|
||||
const headerAvatar = page.locator('header button:has(div.rounded-full)').first();
|
||||
if ((await headerAvatar.count()) > 0) {
|
||||
await headerAvatar.click({ force: true });
|
||||
avatarClicked = true;
|
||||
results.push("OK: 헤더 아바타 클릭 (모바일 뷰?)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarClicked) {
|
||||
results.push("WARN: 아바타 클릭 실패. 직접 /admin/approvalBox로 이동하여 페이지 검증");
|
||||
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 4. "결재함" 메뉴 확인 (드롭다운이 열린 경우)
|
||||
results.push("\n=== 4. 결재함 메뉴 확인 ===");
|
||||
const approvalMenuItem = page.locator('[role="menuitem"]:has-text("결재함"), [data-radix-collection-item]:has-text("결재함")').first();
|
||||
const hasApprovalMenu = (await approvalMenuItem.count()) > 0;
|
||||
if (hasApprovalMenu) {
|
||||
results.push("OK: 결재함 메뉴가 보입니다.");
|
||||
} else {
|
||||
results.push("FAIL: 결재함 메뉴를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 5. 결재함 메뉴 클릭
|
||||
results.push("\n=== 5. 결재함 메뉴 클릭 ===");
|
||||
if (hasApprovalMenu) {
|
||||
await approvalMenuItem.click({ force: true });
|
||||
await page.waitForTimeout(3000);
|
||||
results.push("OK: 결재함 메뉴 클릭 완료");
|
||||
} else if (!avatarClicked) {
|
||||
results.push("(직접 이동으로 스킵 - 이미 approvalBox 페이지)");
|
||||
} else {
|
||||
results.push("WARN: 드롭다운에서 결재함 메뉴 미발견. 직접 이동...");
|
||||
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// 6. /admin/approvalBox 페이지 렌더링 확인
|
||||
results.push("\n=== 6. /admin/approvalBox 페이지 확인 ===");
|
||||
const currentUrl = page.url();
|
||||
const isApprovalBoxPage = currentUrl.includes("/admin/approvalBox");
|
||||
results.push(`현재 URL: ${currentUrl}`);
|
||||
results.push(isApprovalBoxPage ? "OK: approvalBox 페이지에 있습니다." : "FAIL: approvalBox 페이지가 아닙니다.");
|
||||
|
||||
// 제목 "결재함" 확인
|
||||
const titleEl = page.locator('h1:has-text("결재함")');
|
||||
const hasTitle = (await titleEl.count()) > 0;
|
||||
results.push(hasTitle ? "OK: 제목 '결재함' 확인됨" : "FAIL: 제목 '결재함' 없음");
|
||||
|
||||
// 탭 확인: 수신함, 상신함
|
||||
const receivedTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "수신함" });
|
||||
const sentTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "상신함" });
|
||||
const hasReceivedTab = (await receivedTab.count()) > 0;
|
||||
const hasSentTab = (await sentTab.count()) > 0;
|
||||
results.push(hasReceivedTab ? "OK: '수신함' 탭 확인됨" : "FAIL: '수신함' 탭 없음");
|
||||
results.push(hasSentTab ? "OK: '상신함' 탭 확인됨" : "FAIL: '상신함' 탭 없음");
|
||||
|
||||
// 7. 콘솔 에러 확인
|
||||
results.push("\n=== 7. 콘솔 에러 확인 ===");
|
||||
if (consoleErrors.length === 0) {
|
||||
results.push("OK: 콘솔 에러 없음");
|
||||
} else {
|
||||
results.push(`WARN: 콘솔 에러 ${consoleErrors.length}건 발견:`);
|
||||
consoleErrors.slice(0, 10).forEach((err, i) => {
|
||||
results.push(` [${i + 1}] ${err.substring(0, 200)}${err.length > 200 ? "..." : ""}`);
|
||||
});
|
||||
if (consoleErrors.length > 10) {
|
||||
results.push(` ... 외 ${consoleErrors.length - 10}건`);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크린샷 저장 (프로젝트 내)
|
||||
await page.screenshot({ path: "approval-box-result.png" }).catch(() => {});
|
||||
} catch (err: any) {
|
||||
results.push(`\nERROR: ${err.message}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("결재함 플로우 테스트 결과");
|
||||
console.log("=".repeat(60));
|
||||
results.forEach((r) => console.log(r));
|
||||
console.log("\n" + "=".repeat(60));
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -24,5 +24,12 @@
|
|||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"components/screen/ScreenDesigner_old.tsx",
|
||||
"components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx",
|
||||
"components/flow/FlowDataListModal.tsx",
|
||||
"test-scenarios"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
TimestampFields,
|
||||
BaseApiResponse,
|
||||
} from "./v2-core";
|
||||
import { FlowVisibilityConfig } from "./screen-management";
|
||||
|
||||
// ===== 버튼 제어 관련 =====
|
||||
|
||||
|
|
@ -58,23 +59,6 @@ export interface ExtendedButtonTypeConfig {
|
|||
borderColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 단계별 버튼 표시 설정
|
||||
*/
|
||||
export interface FlowVisibilityConfig {
|
||||
enabled: boolean;
|
||||
targetFlowComponentId: string;
|
||||
targetFlowId?: number;
|
||||
targetFlowName?: string;
|
||||
mode: "whitelist" | "blacklist" | "all";
|
||||
visibleSteps?: number[];
|
||||
hiddenSteps?: number[];
|
||||
layoutBehavior: "preserve-position" | "auto-compact";
|
||||
groupId?: string;
|
||||
groupDirection?: "horizontal" | "vertical";
|
||||
groupGap?: number;
|
||||
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 단순화된 버튼 데이터플로우 설정
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
// ===== 핵심 공통 타입들 =====
|
||||
export * from "./v2-core";
|
||||
import type { WebType, ButtonActionType, CompanyCode } from "./v2-core";
|
||||
import type { ComponentData } from "./screen-management";
|
||||
|
||||
// ===== 시스템별 전용 타입들 =====
|
||||
export * from "./screen-management";
|
||||
|
|
@ -258,86 +260,3 @@ export type SelectedRowData = Record<string, unknown>;
|
|||
*/
|
||||
export type TableData = Record<string, unknown>[];
|
||||
|
||||
// ===== 마이그레이션 도우미 =====
|
||||
|
||||
/**
|
||||
* 기존 screen.ts 타입을 새로운 통합 타입으로 마이그레이션하는 도우미
|
||||
*/
|
||||
export namespace Migration {
|
||||
/**
|
||||
* 기존 screen.ts의 WebType을 새로운 WebType으로 변환
|
||||
*/
|
||||
export const migrateWebType = (oldWebType: string): WebType => {
|
||||
// 기존 타입이 새로운 WebType에 포함되어 있는지 확인
|
||||
if (isWebType(oldWebType)) {
|
||||
return oldWebType as WebType;
|
||||
}
|
||||
|
||||
// 호환되지 않는 타입의 경우 기본값 반환
|
||||
console.warn(`Unknown WebType: ${oldWebType}, defaulting to 'text'`);
|
||||
return "text";
|
||||
};
|
||||
|
||||
/**
|
||||
* 기존 ButtonActionType을 새로운 ButtonActionType으로 변환
|
||||
*/
|
||||
export const migrateButtonActionType = (oldActionType: string): ButtonActionType => {
|
||||
if (isButtonActionType(oldActionType)) {
|
||||
return oldActionType as ButtonActionType;
|
||||
}
|
||||
|
||||
console.warn(`Unknown ButtonActionType: ${oldActionType}, defaulting to 'submit'`);
|
||||
return "submit";
|
||||
};
|
||||
|
||||
/**
|
||||
* Y/N 문자열을 boolean으로 변환 (DB 호환성)
|
||||
*/
|
||||
export const migrateYNToBoolean = (value: string | undefined): boolean => {
|
||||
return value === "Y";
|
||||
};
|
||||
|
||||
/**
|
||||
* boolean을 Y/N 문자열로 변환 (DB 호환성)
|
||||
*/
|
||||
export const migrateBooleanToYN = (value: boolean): string => {
|
||||
return value ? "Y" : "N";
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 타입 검증 도우미 =====
|
||||
|
||||
/**
|
||||
* 런타임에서 타입 안전성을 보장하는 검증 함수들
|
||||
*/
|
||||
export namespace TypeValidation {
|
||||
/**
|
||||
* 객체가 BaseComponent 인터페이스를 만족하는지 검증
|
||||
*/
|
||||
export const validateBaseComponent = (obj: unknown): obj is BaseComponent => {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
|
||||
const component = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof component.id === "string" &&
|
||||
typeof component.type === "string" &&
|
||||
isComponentType(component.type as string) &&
|
||||
typeof component.position === "object" &&
|
||||
typeof component.size === "object"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 객체가 WebTypeConfig를 만족하는지 검증
|
||||
*/
|
||||
export const validateWebTypeConfig = (obj: unknown): obj is WebTypeConfig => {
|
||||
return typeof obj === "object" && obj !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열이 유효한 CompanyCode인지 검증
|
||||
*/
|
||||
export const validateCompanyCode = (code: unknown): code is CompanyCode => {
|
||||
return typeof code === "string" && code.length > 0;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,12 +88,18 @@ export interface ExternalDBSourceNodeData {
|
|||
|
||||
// REST API 소스 노드
|
||||
export interface RestAPISourceNodeData {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
body?: any;
|
||||
responseFields: FieldDefinition[];
|
||||
displayName?: string;
|
||||
authentication?: {
|
||||
type: "none" | "bearer" | "basic" | "apikey";
|
||||
token?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
responseMapping?: string;
|
||||
}
|
||||
|
||||
// 조건 연산자 타입
|
||||
|
|
@ -510,21 +516,6 @@ export interface UpsertActionNodeData {
|
|||
};
|
||||
}
|
||||
|
||||
// REST API 소스 노드
|
||||
export interface RestAPISourceNodeData {
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
authentication?: {
|
||||
type: "none" | "bearer" | "basic" | "apikey";
|
||||
token?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
responseMapping?: string; // JSON 경로 (예: "data.items")
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 주석 노드
|
||||
export interface CommentNodeData {
|
||||
content: string;
|
||||
|
|
@ -788,7 +779,7 @@ export type EdgeType =
|
|||
| "conditionalTrue" // 조건 TRUE
|
||||
| "conditionalFalse"; // 조건 FALSE
|
||||
|
||||
export interface FlowEdge extends ReactFlowEdge {
|
||||
export interface FlowEdge extends Omit<ReactFlowEdge, 'type' | 'data'> {
|
||||
type?: EdgeType;
|
||||
data?: {
|
||||
dataType?: string;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export interface V2ColumnInfo {
|
|||
* 백엔드 호환용 컬럼 타입 정보 (기존 ColumnTypeInfo)
|
||||
*/
|
||||
export interface ColumnTypeInfo {
|
||||
tableName?: string;
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
|
|
@ -367,6 +368,9 @@ export const WEB_TYPE_OPTIONS = [
|
|||
{ value: "email", label: "email", description: "이메일 입력" },
|
||||
{ value: "tel", label: "tel", description: "전화번호 입력" },
|
||||
{ value: "url", label: "url", description: "URL 입력" },
|
||||
{ value: "checkbox-group", label: "checkbox-group", description: "체크박스 그룹" },
|
||||
{ value: "radio-horizontal", label: "radio-horizontal", description: "가로 라디오" },
|
||||
{ value: "radio-vertical", label: "radio-vertical", description: "세로 라디오" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,21 +23,40 @@ export type WebType =
|
|||
// 숫자 입력
|
||||
| "number"
|
||||
| "decimal"
|
||||
| "percentage"
|
||||
| "currency"
|
||||
// 날짜/시간 입력
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "month"
|
||||
| "year"
|
||||
| "time"
|
||||
| "daterange"
|
||||
// 선택 입력
|
||||
| "select"
|
||||
| "dropdown"
|
||||
| "radio"
|
||||
| "radio-horizontal"
|
||||
| "radio-vertical"
|
||||
| "checkbox"
|
||||
| "checkbox-group"
|
||||
| "boolean"
|
||||
| "multiselect"
|
||||
| "autocomplete"
|
||||
// 특수 입력
|
||||
| "code" // 공통코드 참조
|
||||
| "code-radio" // 공통코드 라디오
|
||||
| "code-autocomplete" // 공통코드 자동완성
|
||||
| "entity" // 엔티티 참조
|
||||
| "file" // 파일 업로드
|
||||
| "image" // 이미지 표시
|
||||
| "password" // 비밀번호
|
||||
| "button" // 버튼 컴포넌트
|
||||
| "category" // 카테고리
|
||||
| "component" // 컴포넌트 참조
|
||||
| "form" // 폼
|
||||
| "table" // 테이블
|
||||
| "array" // 배열
|
||||
// 레이아웃/컨테이너 타입
|
||||
| "container" // 컨테이너
|
||||
| "group" // 그룹
|
||||
|
|
@ -79,7 +98,9 @@ export type ButtonActionType =
|
|||
// 데이터 전달
|
||||
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
|
||||
// 즉시 저장
|
||||
| "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT
|
||||
| "quickInsert" // 선택한 데이터를 특정 테이블에 즉시 INSERT
|
||||
// 결재 워크플로우
|
||||
| "approval"; // 결재 요청을 생성합니다
|
||||
|
||||
/**
|
||||
* 컴포넌트 타입 정의
|
||||
|
|
@ -339,6 +360,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
|
|||
"control",
|
||||
"transferData",
|
||||
"quickInsert",
|
||||
"approval",
|
||||
];
|
||||
return actionTypes.includes(value as ButtonActionType);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* 메뉴 복사 자동화 스크립트
|
||||
*
|
||||
* 실행: npx ts-node scripts/menu-copy-automation.ts
|
||||
* 또는: npx playwright test scripts/menu-copy-automation.ts (playwright test 모드)
|
||||
*
|
||||
* 요구사항: playwright 설치 (npm install playwright)
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = path.join(__dirname, "../screenshots-menu-copy");
|
||||
|
||||
// 스크린샷 저장
|
||||
async function takeScreenshot(page: Page, stepName: string): Promise<string> {
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
}
|
||||
const filename = `${Date.now()}_${stepName}.png`;
|
||||
const filepath = path.join(SCREENSHOT_DIR, filename);
|
||||
await page.screenshot({ path: filepath, fullPage: true });
|
||||
console.log(`[스크린샷] ${stepName} -> ${filepath}`);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser: Browser | null = null;
|
||||
const screenshots: { step: string; path: string }[] = [];
|
||||
|
||||
try {
|
||||
console.log("=== 메뉴 복사 자동화 시작 ===\n");
|
||||
|
||||
browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// 1. 로그인
|
||||
console.log("1. 로그인 페이지 이동...");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" });
|
||||
await takeScreenshot(page, "01_login_page").then((p) =>
|
||||
screenshots.push({ step: "로그인 페이지", path: p })
|
||||
);
|
||||
|
||||
await page.fill('#userId', "admin");
|
||||
await page.fill('#password', "1234");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await takeScreenshot(page, "02_after_login").then((p) =>
|
||||
screenshots.push({ step: "로그인 후", path: p })
|
||||
);
|
||||
|
||||
// 로그인 실패 시 wace 계정 시도 (admin이 DB에 없을 수 있음)
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes("/login")) {
|
||||
console.log("admin 로그인 실패, wace 계정으로 재시도...");
|
||||
await page.fill('#userId', "wace");
|
||||
await page.fill('#password', "1234");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// 2. 메뉴 관리 페이지로 이동
|
||||
console.log("2. 메뉴 관리 페이지 이동...");
|
||||
await page.goto(`${BASE_URL}/admin/menu`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(2000);
|
||||
await takeScreenshot(page, "03_menu_page").then((p) =>
|
||||
screenshots.push({ step: "메뉴 관리 페이지", path: p })
|
||||
);
|
||||
|
||||
// 3. 회사 선택 - 탑씰 (COMPANY_7)
|
||||
console.log("3. 회사 선택: 탑씰 (COMPANY_7)...");
|
||||
const companyDropdown = page.locator('.company-dropdown button, button:has(svg)').first();
|
||||
await companyDropdown.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const topsealOption = page.getByText("탑씰", { exact: false }).first();
|
||||
await topsealOption.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await takeScreenshot(page, "04_company_selected").then((p) =>
|
||||
screenshots.push({ step: "탑씰 선택 후", path: p })
|
||||
);
|
||||
|
||||
// 4. "사용자" 메뉴 찾기 및 복사 버튼 클릭
|
||||
console.log("4. 사용자 메뉴 찾기 및 복사 버튼 클릭...");
|
||||
const userMenuRow = page.locator('tr').filter({ hasText: "사용자" }).first();
|
||||
await userMenuRow.waitFor({ timeout: 10000 });
|
||||
const copyButton = userMenuRow.getByRole("button", { name: "복사" });
|
||||
await copyButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await takeScreenshot(page, "05_copy_dialog_open").then((p) =>
|
||||
screenshots.push({ step: "복사 다이얼로그", path: p })
|
||||
);
|
||||
|
||||
// 5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)
|
||||
console.log("5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)...");
|
||||
const targetCompanyTrigger = page.locator('[id="company"]').or(page.getByRole("combobox")).first();
|
||||
await targetCompanyTrigger.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dubaiOption = page.getByText("두바이 강정 단단", { exact: false }).first();
|
||||
await dubaiOption.click();
|
||||
await page.waitForTimeout(500);
|
||||
await takeScreenshot(page, "06_target_company_selected").then((p) =>
|
||||
screenshots.push({ step: "대상 회사 선택 후", path: p })
|
||||
);
|
||||
|
||||
// 6. 복사 시작 버튼 클릭
|
||||
console.log("6. 복사 시작...");
|
||||
const copyStartButton = page.getByRole("button", { name: /복사 시작|확인/ }).first();
|
||||
await copyStartButton.click();
|
||||
|
||||
// 7. 복사 완료 대기 (최대 5분)
|
||||
console.log("7. 복사 완료 대기 (최대 5분)...");
|
||||
try {
|
||||
await page.waitForSelector('text=완료, text=성공, [role="status"]', { timeout: 300000 });
|
||||
await page.waitForTimeout(3000);
|
||||
} catch {
|
||||
console.log("타임아웃 또는 완료 메시지 대기 중...");
|
||||
}
|
||||
await takeScreenshot(page, "07_copy_result").then((p) =>
|
||||
screenshots.push({ step: "복사 결과", path: p })
|
||||
);
|
||||
|
||||
// 결과 확인
|
||||
const resultText = await page.locator("body").textContent();
|
||||
if (resultText?.includes("완료") || resultText?.includes("성공")) {
|
||||
console.log("\n=== 메뉴 복사 성공 ===");
|
||||
} else if (resultText?.includes("오류") || resultText?.includes("실패") || resultText?.includes("error")) {
|
||||
console.log("\n=== 에러 발생 가능 - 스크린샷 확인 필요 ===");
|
||||
}
|
||||
|
||||
console.log("\n=== 스크린샷 목록 ===");
|
||||
screenshots.forEach((s) => console.log(` - ${s.step}: ${s.path}`));
|
||||
} catch (error) {
|
||||
console.error("오류 발생:", error);
|
||||
if (browser) {
|
||||
const pages = (browser as any).contexts?.()?.[0]?.pages?.() || [];
|
||||
for (const p of pages) {
|
||||
try {
|
||||
await takeScreenshot(p, "error_state").then((path) =>
|
||||
screenshots.push({ step: "에러 상태", path })
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Loading…
Reference in New Issue