diff --git a/.gitignore b/.gitignore index df53d44b..08276481 100644 --- a/.gitignore +++ b/.gitignore @@ -163,6 +163,12 @@ uploads/ # ===== 기타 ===== claude.md +# Agent Pipeline 로컬 파일 +_local/ +.agent-pipeline/ +.codeguard-baseline.json +scripts/browser-test-*.js + # AI 에이전트 테스트 산출물 *-test-screenshots/ *-screenshots/ diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index c7d93570..24ba1647 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -117,6 +117,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 +import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 @@ -318,6 +319,7 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 +app.use("/api/approval", approvalRoutes); // 결재 시스템 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -351,11 +353,13 @@ app.listen(PORT, HOST, async () => { runDashboardMigration, runTableHistoryActionMigration, runDtgManagementLogMigration, + runApprovalSystemMigration, } = await import("./database/runMigration"); await runDashboardMigration(); await runTableHistoryActionMigration(); await runDtgManagementLogMigration(); + await runApprovalSystemMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts new file mode 100644 index 00000000..84231245 --- /dev/null +++ b/backend-node/src/controllers/approvalController.ts @@ -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( + `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, 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( + `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 }] + 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( + "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] + ); + // 남은 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( + `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/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 컬럼 추가 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/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index e91124af..4a53b0ff 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -56,6 +56,8 @@ interface Menu { lang_key_desc: string | null; screen_code: string | null; menu_code: string | null; + menu_icon: string | null; + screen_group_id: number | null; } /** @@ -2106,26 +2108,28 @@ export class MenuCopyService { objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, menu_desc, writer, status, system_name, company_code, lang_key, lang_key_desc, screen_code, menu_code, - source_menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + source_menu_objid, menu_icon, screen_group_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [ newObjId, menu.menu_type, - newParentObjId, // 재매핑 + newParentObjId, menu.menu_name_kor, menu.menu_name_eng, menu.seq, menu.menu_url, menu.menu_desc, userId, - 'active', // 복제된 메뉴는 항상 활성화 상태 + 'active', menu.system_name, - targetCompanyCode, // 새 회사 코드 + targetCompanyCode, menu.lang_key, menu.lang_key_desc, - menu.screen_code, // 그대로 유지 + menu.screen_code, menu.menu_code, - sourceMenuObjid, // 원본 메뉴 ID (최상위만) + sourceMenuObjid, + menu.menu_icon, + menu.screen_group_id, ] ); diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts index 68529c47..9bda49d0 100644 --- a/backend-node/src/services/menuScreenSyncService.ts +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -334,8 +334,8 @@ export async function syncScreenGroupsToMenu( INSERT INTO menu_info ( objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc, - menu_url, screen_code - ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11) + menu_url, screen_code, menu_icon + ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12) RETURNING objid `; await client.query(insertMenuQuery, [ @@ -350,6 +350,7 @@ export async function syncScreenGroupsToMenu( group.description || null, menuUrl, screenCode, + group.icon || null, ]); // screen_groups에 menu_objid 업데이트 diff --git a/backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties b/backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties new file mode 100644 index 00000000..5fe7c7bc --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties @@ -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 \ No newline at end of file diff --git a/docs/결재시스템_구현_현황.md b/docs/결재시스템_구현_현황.md new file mode 100644 index 00000000..5681df54 --- /dev/null +++ b/docs/결재시스템_구현_현황.md @@ -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. 향후 개선 사항 + +- [ ] 결재 알림 (실시간 알림, 이메일 연동) +- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션) +- [ ] 결재 위임 기능 +- [ ] 결재 이력 조회 / 통계 대시보드 +- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장) +- [ ] 모바일 결재 처리 최적화 diff --git a/frontend/app/(main)/admin/approvalBox/page.tsx b/frontend/app/(main)/admin/approvalBox/page.tsx new file mode 100644 index 00000000..2a979829 --- /dev/null +++ b/frontend/app/(main)/admin/approvalBox/page.tsx @@ -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 = { + 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 {info.label}; +} + +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([]); + const [loading, setLoading] = useState(true); + const [detailOpen, setDetailOpen] = useState(false); + const [selectedRequest, setSelectedRequest] = useState(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 ( +
+ {loading ? ( +
+ +
+ ) : requests.length === 0 ? ( +
+ +

상신한 결재가 없습니다.

+
+ ) : ( +
+ + + + 제목 + 대상 테이블 + 진행 + 상태 + 요청일 + 보기 + + + + {requests.map((req) => ( + + {req.title} + {req.target_table} + + {req.current_step}/{req.total_steps} + + + {formatDate(req.created_at)} + + + + + ))} + +
+
+ )} + + {/* 상세 모달 */} + + + + 결재 상세 + + {selectedRequest?.title} + + + {detailLoading ? ( +
+ +
+ ) : selectedRequest && ( +
+
+
+ 상태 +
+
+
+ 진행 +

{selectedRequest.current_step}/{selectedRequest.total_steps}단계

+
+
+ 대상 테이블 +

{selectedRequest.target_table}

+
+
+ 요청일 +

{formatDate(selectedRequest.created_at)}

+
+
+ {selectedRequest.description && ( +
+ 사유 +

{selectedRequest.description}

+
+ )} + {/* 결재선 */} + {selectedRequest.lines && selectedRequest.lines.length > 0 && ( +
+ 결재선 +
+ {selectedRequest.lines + .sort((a, b) => a.step_order - b.step_order) + .map((line) => ( +
+
+ {line.step_order}차 + {line.approver_name || line.approver_id} + {line.approver_position && ( + ({line.approver_position}) + )} +
+
+ + {line.processed_at && ( + {formatDate(line.processed_at)} + )} +
+
+ ))} +
+
+ )} +
+ )} + + {selectedRequest?.status === "requested" && ( + + )} + + +
+
+
+ ); +} + +// ============================================================ +// 수신함 (내가 결재해야 할 것) +// ============================================================ +function ReceivedTab() { + const [pendingLines, setPendingLines] = useState([]); + const [loading, setLoading] = useState(true); + + const [processOpen, setProcessOpen] = useState(false); + const [selectedLine, setSelectedLine] = useState(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 ( +
+ {loading ? ( +
+ +
+ ) : pendingLines.length === 0 ? ( +
+ +

결재 대기 건이 없습니다.

+
+ ) : ( +
+ + + + 제목 + 요청자 + 대상 테이블 + 단계 + 요청일 + 처리 + + + + {pendingLines.map((line) => ( + + {line.title || "-"} + + {line.requester_name || "-"} + {line.requester_dept && ( + ({line.requester_dept}) + )} + + {line.target_table || "-"} + + {line.step_order}차 + + {formatDate(line.request_created_at || line.created_at)} + + + + + ))} + +
+
+ )} + + {/* 결재 처리 모달 */} + + + + 결재 처리 + + {selectedLine?.title} + + +
+
+
+ 요청자 +

{selectedLine?.requester_name || "-"}

+
+
+ 결재 단계 +

{selectedLine?.step_order}차 결재

+
+
+
+ +