feature/v2-renewal #400

Merged
kjs merged 11 commits from feature/v2-renewal into main 2026-03-04 23:03:04 +09:00
42 changed files with 5355 additions and 524 deletions

6
.gitignore vendored
View File

@ -163,6 +163,12 @@ uploads/
# ===== 기타 ===== # ===== 기타 =====
claude.md claude.md
# Agent Pipeline 로컬 파일
_local/
.agent-pipeline/
.codeguard-baseline.json
scripts/browser-test-*.js
# AI 에이전트 테스트 산출물 # AI 에이전트 테스트 산출물
*-test-screenshots/ *-test-screenshots/
*-screenshots/ *-screenshots/

View File

@ -117,6 +117,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 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/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);
@ -351,11 +353,13 @@ app.listen(PORT, HOST, async () => {
runDashboardMigration, runDashboardMigration,
runTableHistoryActionMigration, runTableHistoryActionMigration,
runDtgManagementLogMigration, runDtgManagementLogMigration,
runApprovalSystemMigration,
} = await import("./database/runMigration"); } = await import("./database/runMigration");
await runDashboardMigration(); await runDashboardMigration();
await runTableHistoryActionMigration(); await runTableHistoryActionMigration();
await runDtgManagementLogMigration(); await runDtgManagementLogMigration();
await runApprovalSystemMigration();
} catch (error) { } catch (error) {
logger.error(`❌ 마이그레이션 실패:`, error); logger.error(`❌ 마이그레이션 실패:`, error);
} }

View File

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

View File

@ -2,6 +2,37 @@ import { PostgreSQLService } from "./PostgreSQLService";
import fs from "fs"; import fs from "fs";
import path from "path"; 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 * dashboard_elements custom_title, show_header

View File

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

View File

@ -56,6 +56,8 @@ interface Menu {
lang_key_desc: string | null; lang_key_desc: string | null;
screen_code: string | null; screen_code: string | null;
menu_code: string | null; menu_code: string | null;
menu_icon: string | null;
screen_group_id: number | null;
} }
/** /**
@ -2106,26 +2108,28 @@ export class MenuCopyService {
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, status, system_name, seq, menu_url, menu_desc, writer, status, system_name,
company_code, lang_key, lang_key_desc, screen_code, menu_code, company_code, lang_key, lang_key_desc, screen_code, menu_code,
source_menu_objid 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)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[ [
newObjId, newObjId,
menu.menu_type, menu.menu_type,
newParentObjId, // 재매핑 newParentObjId,
menu.menu_name_kor, menu.menu_name_kor,
menu.menu_name_eng, menu.menu_name_eng,
menu.seq, menu.seq,
menu.menu_url, menu.menu_url,
menu.menu_desc, menu.menu_desc,
userId, userId,
'active', // 복제된 메뉴는 항상 활성화 상태 'active',
menu.system_name, menu.system_name,
targetCompanyCode, // 새 회사 코드 targetCompanyCode,
menu.lang_key, menu.lang_key,
menu.lang_key_desc, menu.lang_key_desc,
menu.screen_code, // 그대로 유지 menu.screen_code,
menu.menu_code, menu.menu_code,
sourceMenuObjid, // 원본 메뉴 ID (최상위만) sourceMenuObjid,
menu.menu_icon,
menu.screen_group_id,
] ]
); );

View File

@ -334,8 +334,8 @@ export async function syncScreenGroupsToMenu(
INSERT INTO menu_info ( INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng, objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc, seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
menu_url, screen_code menu_url, screen_code, menu_icon
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12)
RETURNING objid RETURNING objid
`; `;
await client.query(insertMenuQuery, [ await client.query(insertMenuQuery, [
@ -350,6 +350,7 @@ export async function syncScreenGroupsToMenu(
group.description || null, group.description || null,
menuUrl, menuUrl,
screenCode, screenCode,
group.icon || null,
]); ]);
// screen_groups에 menu_objid 업데이트 // screen_groups에 menu_objid 업데이트

View 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

View File

@ -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. 향후 개선 사항
- [ ] 결재 알림 (실시간 알림, 이메일 연동)
- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션)
- [ ] 결재 위임 기능
- [ ] 결재 이력 조회 / 통계 대시보드
- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장)
- [ ] 모바일 결재 처리 최적화

View File

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

View File

@ -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>
&quot;{deleteTarget?.definition_name}&quot;() ?
<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>
&quot;{deleteTarget?.template_name}&quot;() ?
</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>
);
}

View File

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

View File

@ -1,12 +1,14 @@
import { AuthProvider } from "@/contexts/AuthContext"; import { AuthProvider } from "@/contexts/AuthContext";
import { MenuProvider } from "@/contexts/MenuContext"; import { MenuProvider } from "@/contexts/MenuContext";
import { AppLayout } from "@/components/layout/AppLayout"; import { AppLayout } from "@/components/layout/AppLayout";
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
export default function MainLayout({ children }: { children: React.ReactNode }) { export default function MainLayout({ children }: { children: React.ReactNode }) {
return ( return (
<AuthProvider> <AuthProvider>
<MenuProvider> <MenuProvider>
<AppLayout>{children}</AppLayout> <AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
</MenuProvider> </MenuProvider>
</AuthProvider> </AuthProvider>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import {
LogOut, LogOut,
User, User,
Building2, Building2,
FileCheck,
} from "lucide-react"; } from "lucide-react";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
@ -524,6 +525,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span></span> <span></span>
@ -692,6 +698,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span></span> <span></span>

View File

@ -8,7 +8,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } 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 { interface UserDropdownProps {
user: any; user: any;
@ -20,6 +21,8 @@ interface UserDropdownProps {
* *
*/ */
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) { export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
const router = useRouter();
if (!user) return null; if (!user) return null;
return ( return (
@ -79,6 +82,11 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}> <DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span></span> <span></span>

View File

@ -577,8 +577,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
const getActionDisplayName = (actionType: ButtonActionType): string => { const getActionDisplayName = (actionType: ButtonActionType): string => {
const displayNames: Record<ButtonActionType, string> = { const displayNames: Record<ButtonActionType, string> = {
save: "저장", save: "저장",
cancel: "취소",
delete: "삭제", delete: "삭제",
edit: "수정", edit: "수정",
copy: "복사",
add: "추가", add: "추가",
search: "검색", search: "검색",
reset: "초기화", reset: "초기화",
@ -589,6 +591,9 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
newWindow: "새 창", newWindow: "새 창",
navigate: "페이지 이동", navigate: "페이지 이동",
control: "제어", control: "제어",
transferData: "데이터 전달",
quickInsert: "즉시 저장",
approval: "결재",
}; };
return displayNames[actionType] || actionType; return displayNames[actionType] || actionType;
}; };

View File

@ -18,6 +18,7 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection"; import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
// 🆕 제목 블록 타입 // 🆕 제목 블록 타입
interface TitleBlock { interface TitleBlock {
@ -107,6 +108,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({}); const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = 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 [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false); const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
@ -689,6 +694,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
fetchScreens(); fetchScreens();
}, [currentScreenCompanyCode]); }, [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(() => { useEffect(() => {
const fetchTableColumns = async () => { const fetchTableColumns = async () => {
@ -831,6 +855,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 고급 기능 */} {/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem> <SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem> <SelectItem value="control"> </SelectItem>
<SelectItem value="approval"> </SelectItem>
{/* 특수 기능 (필요 시 사용) */} {/* 특수 기능 (필요 시 사용) */}
<SelectItem value="barcode_scan"> </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" && ( {localInputs.actionType === "event" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4"> <div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">

View File

@ -420,10 +420,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</div> </div>
{entityJoinTables.map((joinTable, idx) => { {entityJoinTables.map((joinTable, idx) => {
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성 const uniqueKey = `entity-join-${joinTable.tableName}-${joinTable.joinConfig?.sourceColumn || ''}-${idx}`;
const uniqueKey = joinTable.joinConfig?.sourceColumn
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
: `entity-join-${joinTable.tableName}-${idx}`;
const isExpanded = expandedJoinTables.has(joinTable.tableName); const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링 // 검색어로 필터링
const filteredColumns = searchTerm const filteredColumns = searchTerm

View File

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

View File

@ -117,6 +117,7 @@ import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
/** /**
* *

View File

@ -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">
&quot;{line.comment}&quot;
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export const ApprovalStepWrapper: React.FC<ApprovalStepComponentProps> = (props) => {
return <ApprovalStepComponent {...props} />;
};

View File

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

View File

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

View File

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

View File

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

View File

@ -585,7 +585,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
toast.dismiss(); toast.dismiss();
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 // 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)) { if (!silentActions.includes(actionConfig.type)) {
currentLoadingToastRef.current = toast.loading( currentLoadingToastRef.current = toast.loading(
actionConfig.type === "save" actionConfig.type === "save"

View File

@ -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: 특정 레이아웃만 리로드하는 로직 구현
};
}
}

View File

@ -1,14 +1,9 @@
/** /**
* card-layout * card-layout
*/ */
export const Card-layoutLayoutConfig = { export const CardLayoutConfig = {
defaultConfig: { defaultConfig: {
card-layout: { "card-layout": {
// TODO: 레이아웃 전용 설정 정의
// 예시:
// spacing: 16,
// orientation: "vertical",
// allowResize: true,
}, },
}, },
@ -51,14 +46,12 @@ export const Card-layoutLayoutConfig = {
} }
], ],
// 설정 스키마 (검증용)
configSchema: { configSchema: {
type: "object", type: "object",
properties: { properties: {
card-layout: { "card-layout": {
type: "object", type: "object",
properties: { properties: {
// TODO: 설정 스키마 정의
}, },
additionalProperties: false, additionalProperties: false,
}, },

View File

@ -3,26 +3,21 @@ import { LayoutRendererProps } from "../BaseLayoutRenderer";
/** /**
* card-layout * card-layout
*/ */
export interface Card-layoutConfig { export interface CardLayoutConfig {
// TODO: 레이아웃 전용 설정 타입 정의 // TODO: 레이아웃 전용 설정 타입 정의
// 예시:
// spacing?: number;
// orientation?: "vertical" | "horizontal";
// allowResize?: boolean;
} }
/** /**
* card-layout Props * card-layout Props
*/ */
export interface Card-layoutLayoutProps extends LayoutRendererProps { export interface CardLayoutLayoutProps extends LayoutRendererProps {
renderer: any; // Card-layoutLayoutRenderer 타입 renderer: any;
} }
/** /**
* card-layout * card-layout
*/ */
export interface Card-layoutZone { export interface CardLayoutZone {
id: string; id: string;
name: string; name: string;
// TODO: 존별 전용 속성 정의
} }

View File

@ -3,7 +3,7 @@
*/ */
export const HeroSectionLayoutConfig = { export const HeroSectionLayoutConfig = {
defaultConfig: { defaultConfig: {
hero-section: { "hero-section": {
// TODO: 레이아웃 전용 설정 정의 // TODO: 레이아웃 전용 설정 정의
// 예시: // 예시:
// spacing: 16, // spacing: 16,
@ -37,7 +37,7 @@ export const HeroSectionLayoutConfig = {
configSchema: { configSchema: {
type: "object", type: "object",
properties: { properties: {
hero-section: { "hero-section": {
type: "object", type: "object",
properties: { properties: {
// TODO: 설정 스키마 정의 // TODO: 설정 스키마 정의

View File

@ -56,7 +56,8 @@ export type ButtonActionType =
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간) | "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT) | "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등) | "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
| "approval"; // 결재 요청
/** /**
* *
@ -451,6 +452,9 @@ export class ButtonActionExecutor {
case "event": case "event":
return await this.handleEvent(config, context); return await this.handleEvent(config, context);
case "approval":
return this.handleApproval(config, context);
default: default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`); console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false; return false;
@ -7598,6 +7602,37 @@ export class ButtonActionExecutor {
return false; 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: { event: {
type: "event", type: "event",
}, },
approval: {
type: "approval",
},
}; };

View File

@ -976,6 +976,35 @@ export class ImprovedButtonActionExecutor {
return await this.executeTransferDataAction(buttonConfig, formData, context); 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 = { const result = {
success: true, success: true,

View File

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

View File

@ -24,5 +24,12 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "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"
]
} }

View File

@ -12,6 +12,7 @@ import {
TimestampFields, TimestampFields,
BaseApiResponse, BaseApiResponse,
} from "./v2-core"; } from "./v2-core";
import { FlowVisibilityConfig } from "./screen-management";
// ===== 버튼 제어 관련 ===== // ===== 버튼 제어 관련 =====
@ -58,23 +59,6 @@ export interface ExtendedButtonTypeConfig {
borderColor?: string; 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";
}
/** /**
* 🔥 * 🔥

View File

@ -7,6 +7,8 @@
// ===== 핵심 공통 타입들 ===== // ===== 핵심 공통 타입들 =====
export * from "./v2-core"; export * from "./v2-core";
import type { WebType, ButtonActionType, CompanyCode } from "./v2-core";
import type { ComponentData } from "./screen-management";
// ===== 시스템별 전용 타입들 ===== // ===== 시스템별 전용 타입들 =====
export * from "./screen-management"; export * from "./screen-management";
@ -258,86 +260,3 @@ export type SelectedRowData = Record<string, unknown>;
*/ */
export type TableData = 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;
};
}

View File

@ -88,12 +88,18 @@ export interface ExternalDBSourceNodeData {
// REST API 소스 노드 // REST API 소스 노드
export interface RestAPISourceNodeData { export interface RestAPISourceNodeData {
method: "GET" | "POST" | "PUT" | "DELETE"; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
url: string; url: string;
headers?: Record<string, string>; headers?: Record<string, string>;
body?: string; body?: any;
responseFields: FieldDefinition[]; responseFields: FieldDefinition[];
displayName?: string; 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 { export interface CommentNodeData {
content: string; content: string;
@ -788,7 +779,7 @@ export type EdgeType =
| "conditionalTrue" // 조건 TRUE | "conditionalTrue" // 조건 TRUE
| "conditionalFalse"; // 조건 FALSE | "conditionalFalse"; // 조건 FALSE
export interface FlowEdge extends ReactFlowEdge { export interface FlowEdge extends Omit<ReactFlowEdge, 'type' | 'data'> {
type?: EdgeType; type?: EdgeType;
data?: { data?: {
dataType?: string; dataType?: string;

View File

@ -81,6 +81,7 @@ export interface V2ColumnInfo {
* ( ColumnTypeInfo) * ( ColumnTypeInfo)
*/ */
export interface ColumnTypeInfo { export interface ColumnTypeInfo {
tableName?: string;
columnName: string; columnName: string;
displayName: string; displayName: string;
dataType: string; dataType: string;
@ -367,6 +368,9 @@ export const WEB_TYPE_OPTIONS = [
{ value: "email", label: "email", description: "이메일 입력" }, { value: "email", label: "email", description: "이메일 입력" },
{ value: "tel", label: "tel", description: "전화번호 입력" }, { value: "tel", label: "tel", description: "전화번호 입력" },
{ value: "url", label: "url", description: "URL 입력" }, { 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; ] as const;
/** /**

View File

@ -23,21 +23,40 @@ export type WebType =
// 숫자 입력 // 숫자 입력
| "number" | "number"
| "decimal" | "decimal"
| "percentage"
| "currency"
// 날짜/시간 입력 // 날짜/시간 입력
| "date" | "date"
| "datetime" | "datetime"
| "month"
| "year"
| "time"
| "daterange"
// 선택 입력 // 선택 입력
| "select" | "select"
| "dropdown" | "dropdown"
| "radio" | "radio"
| "radio-horizontal"
| "radio-vertical"
| "checkbox" | "checkbox"
| "checkbox-group"
| "boolean" | "boolean"
| "multiselect"
| "autocomplete"
// 특수 입력 // 특수 입력
| "code" // 공통코드 참조 | "code" // 공통코드 참조
| "code-radio" // 공통코드 라디오
| "code-autocomplete" // 공통코드 자동완성
| "entity" // 엔티티 참조 | "entity" // 엔티티 참조
| "file" // 파일 업로드 | "file" // 파일 업로드
| "image" // 이미지 표시 | "image" // 이미지 표시
| "password" // 비밀번호
| "button" // 버튼 컴포넌트 | "button" // 버튼 컴포넌트
| "category" // 카테고리
| "component" // 컴포넌트 참조
| "form" // 폼
| "table" // 테이블
| "array" // 배열
// 레이아웃/컨테이너 타입 // 레이아웃/컨테이너 타입
| "container" // 컨테이너 | "container" // 컨테이너
| "group" // 그룹 | "group" // 그룹
@ -79,7 +98,9 @@ export type ButtonActionType =
// 데이터 전달 // 데이터 전달
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달 | "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
// 즉시 저장 // 즉시 저장
| "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT | "quickInsert" // 선택한 데이터를 특정 테이블에 즉시 INSERT
// 결재 워크플로우
| "approval"; // 결재 요청을 생성합니다
/** /**
* *
@ -339,6 +360,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
"control", "control",
"transferData", "transferData",
"quickInsert", "quickInsert",
"approval",
]; ];
return actionTypes.includes(value as ButtonActionType); return actionTypes.includes(value as ButtonActionType);
}; };