From 65d5392c2602515d6b3cc9f801f2f250860132d9 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Mar 2026 16:58:02 +0900 Subject: [PATCH 1/8] . --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index a4c207df..4db1c3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -291,6 +291,12 @@ uploads/ claude.md +# Agent Pipeline 로컬 파일 +_local/ +.agent-pipeline/ +.codeguard-baseline.json +scripts/browser-test-*.js + # AI 에이전트 테스트 산출물 *-test-screenshots/ *-screenshots/ From 0d71e79c549ff29a568b41a5af717de4784f07d2 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Mar 2026 21:49:56 +0900 Subject: [PATCH 2/8] [agent-pipeline] pipe-20260303124213-d7zo round-2 --- backend-node/src/app.ts | 2 + .../src/controllers/approvalController.ts | 846 ++++++++++++++++++ backend-node/src/routes/approvalRoutes.ts | 38 + frontend/lib/api/approval.ts | 450 ++++++++++ 4 files changed, 1336 insertions(+) create mode 100644 backend-node/src/controllers/approvalController.ts create mode 100644 backend-node/src/routes/approvalRoutes.ts create mode 100644 frontend/lib/api/approval.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4b3d212a..1d1ad9a6 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -115,6 +115,7 @@ import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRo import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 +import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 @@ -310,6 +311,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테 app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 +app.use("/api/approval", approvalRoutes); // 결재 시스템 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts new file mode 100644 index 00000000..9a580d5d --- /dev/null +++ b/backend-node/src/controllers/approvalController.ts @@ -0,0 +1,846 @@ +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( + `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( + "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( + `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( + "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( + `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( + "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( + "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( + `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( + `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( + "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( + "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( + "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( + "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, 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 (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( + `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( + `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( + `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( + "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 }] + } = req.body; + + if (!title || !target_table || !target_record_id) { + return res.status(400).json({ success: false, message: "제목, 대상 테이블, 대상 레코드 ID는 필수입니다." }); + } + + 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 || ""; + + 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, + target_record_data ? JSON.stringify(target_record_data) : null, + approvers.length, + userId, userName, deptName, + screen_id, button_component_id, companyCode, + ] + ); + result = reqRows[0]; + + // 결재 라인 생성 (첫 번째 단계는 pending, 나머지는 waiting) + for (let i = 0; i < approvers.length; i++) { + const approver = approvers[i]; + 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 || `${i + 1}차 결재`, + i === 0 ? "pending" : "waiting", + 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( + "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( + "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( + "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] + ); + } else { + // 승인: 다음 단계 활성화 또는 최종 완료 + const nextStep = line.step_order + 1; + + if (nextStep <= request.total_steps) { + // 다음 결재자를 pending으로 변경 + 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( + `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 : "알 수 없는 오류", + }); + } + } +} diff --git a/backend-node/src/routes/approvalRoutes.ts b/backend-node/src/routes/approvalRoutes.ts new file mode 100644 index 00000000..3f2cd2f2 --- /dev/null +++ b/backend-node/src/routes/approvalRoutes.ts @@ -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; diff --git a/frontend/lib/api/approval.ts b/frontend/lib/api/approval.ts new file mode 100644 index 00000000..74c57f63 --- /dev/null +++ b/frontend/lib/api/approval.ts @@ -0,0 +1,450 @@ +/** + * 결재 시스템 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)["Authorization"] = `Bearer ${token}`; + } + return headers; +} + +// ============================================================ +// 공통 타입 정의 +// ============================================================ + +export interface ApiResponse { + 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; + 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; + screen_id?: number; + button_component_id?: string; + 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> { + 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> { + 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> { + 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 +): Promise> { + 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> { + 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> { + 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> { + 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[]; +}): Promise> { + 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[]; + } +): Promise> { + 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> { + 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; + requester_id?: string; + my_approvals?: boolean; + page?: number; + limit?: number; +}): Promise> { + 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?.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> { + 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> { + 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> { + 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> { + 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> { + 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 }; + } +} From d9d18c19221c15308eb36c76ff738c05c2c8c829 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Mar 2026 21:53:46 +0900 Subject: [PATCH 3/8] [agent-pipeline] pipe-20260303124213-d7zo round-3 --- backend-node/src/app.ts | 2 ++ backend-node/src/database/runMigration.ts | 31 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1d1ad9a6..9d286648 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -345,11 +345,13 @@ app.listen(PORT, HOST, async () => { runDashboardMigration, runTableHistoryActionMigration, runDtgManagementLogMigration, + runApprovalSystemMigration, } = await import("./database/runMigration"); await runDashboardMigration(); await runTableHistoryActionMigration(); await runDtgManagementLogMigration(); + await runApprovalSystemMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index a1bb2ec5..07f714d6 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -2,6 +2,37 @@ import { PostgreSQLService } from "./PostgreSQLService"; import fs from "fs"; import path from "path"; +/** + * 결재 시스템 테이블 마이그레이션 + * approval_definitions, approval_line_templates, approval_line_template_steps, + * approval_requests, approval_lines 테이블 생성 + */ +export async function runApprovalSystemMigration() { + try { + console.log("🔄 결재 시스템 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/100_create_approval_system.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ 결재 시스템 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 결재 시스템 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 테이블이 이미 존재합니다."); + } + } +} + /** * 데이터베이스 마이그레이션 실행 * dashboard_elements 테이블에 custom_title, show_header 컬럼 추가 From 89af35093519429f1acb5fcb336c0a30d9931f1e Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Mar 2026 22:00:52 +0900 Subject: [PATCH 4/8] [agent-pipeline] pipe-20260303124213-d7zo round-4 --- frontend/app/(main)/approval/page.tsx | 426 ++++++++++++++++++ frontend/app/(main)/layout.tsx | 2 + .../approval/ApprovalGlobalListener.tsx | 52 +++ .../approval/ApprovalRequestModal.tsx | 420 +++++++++++++++++ .../config-panels/ButtonConfigPanel.tsx | 95 ++++ frontend/types/v2-core.ts | 5 +- 6 files changed, 999 insertions(+), 1 deletion(-) create mode 100644 frontend/app/(main)/approval/page.tsx create mode 100644 frontend/components/approval/ApprovalGlobalListener.tsx create mode 100644 frontend/components/approval/ApprovalRequestModal.tsx diff --git a/frontend/app/(main)/approval/page.tsx b/frontend/app/(main)/approval/page.tsx new file mode 100644 index 00000000..26af713c --- /dev/null +++ b/frontend/app/(main)/approval/page.tsx @@ -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 = { + requested: { label: "요청됨", variant: "secondary" }, + in_progress: { label: "진행 중", variant: "default" }, + approved: { label: "승인됨", variant: "outline" }, + rejected: { label: "반려됨", variant: "destructive" }, + cancelled: { label: "취소됨", variant: "secondary" }, +}; + +const lineStatusConfig: Record = { + waiting: { label: "대기", icon: }, + pending: { label: "진행 중", icon: }, + approved: { label: "승인", icon: }, + rejected: { label: "반려", icon: }, + skipped: { label: "건너뜀", icon: }, +}; + +// 결재 상세 모달 +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 ( + + + + + + {request.title} + + + + {statusInfo.label} + + 요청자: {request.requester_name || request.requester_id} + {request.requester_dept ? ` (${request.requester_dept})` : ""} + + + +
+ {/* 결재 사유 */} + {request.description && ( +
+

결재 사유

+

{request.description}

+
+ )} + + {/* 결재선 */} +
+

결재선

+
+ {(request.lines || []).map((line) => { + const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null }; + return ( +
+
+ {lineStatus.icon} +
+

+ {line.approver_label || `${line.step_order}차 결재`} — {line.approver_name || line.approver_id} +

+ {line.approver_position && ( +

{line.approver_position}

+ )} + {line.comment && ( +

+ 의견: {line.comment} +

+ )} +
+
+ {lineStatus.label} +
+ ); + })} +
+
+ + {/* 승인/반려 입력 (대기 상태일 때만) */} + {pendingLineId && ( +
+

결재 의견 (선택사항)

+