Compare commits

..

24 Commits

Author SHA1 Message Date
kjs 1917b7253d Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-05 13:31:19 +09:00
kjs 9f9b130738 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-05 13:31:13 +09:00
DDD1542 d43f0821ed refactor: Update authentication handling in authRoutes and useAuth hook
- Replaced the middleware `checkAuthStatus` with the `AuthController.checkAuthStatus` method in the authentication routes for improved clarity and structure.
- Simplified token validation logic in the `useAuth` hook by removing unnecessary checks for expired tokens, allowing the API client to handle token refresh automatically.
- Enhanced logging for authentication checks to provide clearer insights into the authentication flow and potential issues.
- Adjusted the handling of user authentication status to ensure consistency and reliability in user state management.

This refactor streamlines the authentication process and improves the overall maintainability of the authentication logic.
2026-03-05 11:51:05 +09:00
DDD1542 4b8f2b7839 feat: Update screen reference handling in V2 layouts
- Enhanced the `ScreenManagementService` to include updates for V2 layouts in the `screen_layouts_v2` table.
- Implemented logic to remap `screenId`, `targetScreenId`, `modalScreenId`, and other related IDs in layout data.
- Added logging for the number of layouts updated in both V1 and V2, improving traceability of the update process.
- This update ensures that screen references are correctly maintained across different layout versions, enhancing the overall functionality of the screen management system.
2026-03-05 11:30:31 +09:00
DDD1542 4f639dec34 feat: Implement screen group screens duplication in menu copy service
- Added a new method `copyScreenGroupScreens` to handle the duplication of screen group screens during the menu copy process.
- Implemented logic to create a mapping of screen group IDs from the source to the target company.
- Enhanced the existing menu copy functionality to include the copying of screen group screens, ensuring that the screen-role and display order are preserved.
- Added logging for better traceability of the duplication process.

This update improves the menu copy service by allowing for a more comprehensive duplication of associated screen group screens, enhancing the overall functionality of the menu management system.
2026-03-05 10:09:37 +09:00
DDD1542 772514c270 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-03-05 09:59:23 +09:00
DDD1542 b9080d03f6 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-03-05 01:30:07 +09:00
DDD1542 8d0f2dbb27 fix: 멀티 레이어 화면 복제 버그 수정
- menuCopyService: 모든 레이어 순회 처리, ON CONFLICT 제약조건 수정
- screenManagementService: layer_id 하드코딩 제거, 멀티 레이어 순회
- screen_conditional_zones 복제 로직 추가 (zoneIdMap 생성)
- condition_config의 zone_id 재매핑 로직 추가
- 레이어 수 변경 감지를 위한 변경 비교 로직 보강

Made-with: Cursor
2026-03-05 01:29:56 +09:00
kjs fcb122c58b Merge branch 'main' into feature/v2-renewal 2026-03-04 23:43:18 +09:00
DDD1542 e11a7b1237 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-03-04 23:42:45 +09:00
DDD1542 366cfcde60 feat: Enhance menu copy and screen sync services with new fields
- Added `menu_icon` and `screen_group_id` fields to the Menu interface in `menuCopyService.ts` to support additional menu attributes.
- Updated the SQL insert statements in both `menuCopyService.ts` and `menuScreenSyncService.ts` to include the new fields, ensuring that menu icons and screen group IDs are properly handled during menu creation and synchronization.
- These enhancements improve the flexibility and functionality of the menu management system.
2026-03-04 23:42:43 +09:00
kjs 5b6b4be73c Merge pull request 'feature/v2-renewal' (#400) from feature/v2-renewal into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/400
2026-03-04 23:03:02 +09:00
kjs b40f6c28dc Merge branch 'main' into feature/v2-renewal 2026-03-04 23:02:27 +09:00
DDD1542 d31568b1bd feat: Enhance menu and screen synchronization services with new fields
- Added `menu_icon` and `screen_group_id` fields to the `Menu` interface in `menuCopyService.ts` to support additional menu attributes.
- Updated the SQL insert statements in both `menuCopyService.ts` and `menuScreenSyncService.ts` to include the new fields, ensuring that menu icons and screen group IDs are properly handled during menu creation and synchronization.
- These enhancements improve the flexibility and functionality of the menu management system, allowing for richer menu configurations.
2026-03-04 22:46:02 +09:00
DDD1542 3a3e4e8926 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-03-04 22:45:40 +09:00
kjs 818cc80514 Merge pull request 'jskim-node' (#399) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/399
2026-03-04 21:17:11 +09:00
DDD1542 f6a2668bdc feat: Enhance approval request handling and user management
- Updated the approval request controller to include target_record_id in query parameters for improved filtering.
- Refactored the approval request creation logic to merge approval_mode into target_record_data, allowing for better handling of approval processes.
- Enhanced the user search functionality in the approval request modal to accommodate additional user attributes such as position and department.
- Improved error handling messages for clarity regarding required fields in the approval request modal.
- Added new menu item for accessing the approval box directly from user dropdown and app layout.

Made-with: Cursor
2026-03-04 18:26:16 +09:00
DDD1542 c22b468599 feat: Enhance approval request modal functionality
- Added user search capability with debouncing to improve performance and user experience.
- Updated approver management to utilize user data, including user ID, name, position, and department.
- Refactored local ID generation for approvers to a more concise function.
- Integrated approval request handling in button actions, allowing for modal opening with relevant data.
- Improved UI elements for better clarity and user guidance in the approval process.

Made-with: Cursor
2026-03-04 11:19:57 +09:00
DDD1542 6a30038785 fix: baseline TS 에러 정리 및 런타임 에러 수정
- SelectItem value="" -> "none" 변환 (shadcn Select 런타임 에러 수정)
- TablesPanel 중복 React key 수정
- 하이픈 포함 식별자를 따옴표로 감싸기 (hero-section, card-layout)
- 깨진 레거시 파일 제거 (AutoRegisteringLayoutRenderer.ts)
- 중복 인터페이스 통합 (RestAPISourceNodeData, FlowVisibilityConfig)
- WebType에 누락된 타입 추가 (checkbox-group, radio-horizontal 등)
- 사용하지 않는 namespace 제거 (Migration, TypeValidation)
- tsconfig.json에 깨진 레거시 파일 exclude 추가

Made-with: Cursor
2026-03-04 01:13:33 +09:00
DDD1542 89af350935 [agent-pipeline] pipe-20260303124213-d7zo round-4 2026-03-03 22:00:52 +09:00
DDD1542 d9d18c1922 [agent-pipeline] pipe-20260303124213-d7zo round-3 2026-03-03 21:53:46 +09:00
DDD1542 0d71e79c54 [agent-pipeline] pipe-20260303124213-d7zo round-2 2026-03-03 21:49:56 +09:00
DDD1542 d7ef26d679 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-03-03 17:13:22 +09:00
DDD1542 65d5392c26 . 2026-03-03 16:58:02 +09:00
64 changed files with 10372 additions and 676 deletions

6
.gitignore vendored
View File

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

View File

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

View File

@ -3690,6 +3690,8 @@ export async function copyMenu(
? { ? {
removeText: req.body.screenNameConfig.removeText, removeText: req.body.screenNameConfig.removeText,
addPrefix: req.body.screenNameConfig.addPrefix, addPrefix: req.body.screenNameConfig.addPrefix,
replaceFrom: req.body.screenNameConfig.replaceFrom,
replaceTo: req.body.screenNameConfig.replaceTo,
} }
: undefined; : undefined;

View File

@ -0,0 +1,892 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne, transaction } from "../database/db";
// ============================================================
// 결재 정의 (Approval Definitions) CRUD
// ============================================================
export class ApprovalDefinitionController {
// 결재 유형 목록 조회
static async getDefinitions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { is_active, search } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (is_active) {
conditions.push(`is_active = $${idx}`);
params.push(is_active);
idx++;
}
if (search) {
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const rows = await query<any>(
`SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`,
params
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("결재 유형 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 상세 조회
static async getDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const row = await queryOne<any>(
"SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!row) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
return res.json({ success: true, data: row });
} catch (error) {
console.error("결재 유형 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 생성
static async createDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
definition_name,
definition_name_eng,
description,
default_template_id,
max_steps = 5,
allow_self_approval = false,
allow_cancel = true,
is_active = "Y",
} = req.body;
if (!definition_name) {
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
}
const userId = req.user?.userId || "system";
const [row] = await query<any>(
`INSERT INTO approval_definitions (
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
company_code, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
RETURNING *`,
[
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
companyCode, userId,
]
);
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
} catch (error) {
console.error("결재 유형 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 수정
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
const {
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
} = req.body;
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(req.user?.userId || "system");
params.push(id, companyCode);
const [row] = await query<any>(
`UPDATE approval_definitions SET ${fields.join(", ")}
WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
params
);
return res.json({ success: true, data: row, message: "결재 유형이 수정되었습니다." });
} catch (error) {
console.error("결재 유형 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 삭제
static async deleteDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
} catch (error) {
console.error("결재 유형 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재선 템플릿 (Approval Line Templates) CRUD
// ============================================================
export class ApprovalTemplateController {
// 템플릿 목록 조회
static async getTemplates(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { definition_id, is_active } = req.query;
const conditions: string[] = ["t.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (definition_id) {
conditions.push(`t.definition_id = $${idx++}`);
params.push(definition_id);
}
if (is_active) {
conditions.push(`t.is_active = $${idx++}`);
params.push(is_active);
}
const rows = await query<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY t.template_id ASC`,
params
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("결재선 템플릿 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 상세 조회 (단계 포함)
static async getTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const template = await queryOne<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE t.template_id = $1 AND t.company_code = $2`,
[id, companyCode]
);
if (!template) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const steps = await query<any>(
"SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
[id, companyCode]
);
return res.json({ success: true, data: { ...template, steps } });
} catch (error) {
console.error("결재선 템플릿 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 생성 (단계 포함 트랜잭션)
static async createTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body;
if (!template_name) {
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
}
const userId = req.user?.userId || "system";
let result: any;
await transaction(async (client) => {
const { rows } = await client.query(
`INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`,
[template_name, description, definition_id, is_active, companyCode, userId]
);
result = rows[0];
// 단계 일괄 삽입
if (Array.isArray(steps) && steps.length > 0) {
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
result.template_id,
step.step_order,
step.approver_type || "user",
step.approver_user_id || null,
step.approver_position || null,
step.approver_dept_code || null,
step.approver_label || null,
companyCode,
]
);
}
}
});
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
} catch (error) {
console.error("결재선 템플릿 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 수정
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const { template_name, description, definition_id, is_active, steps } = req.body;
const userId = req.user?.userId || "system";
let result: any;
await transaction(async (client) => {
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(userId, id, companyCode);
const { rows } = await client.query(
`UPDATE approval_line_templates SET ${fields.join(", ")}
WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
params
);
result = rows[0];
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
if (Array.isArray(steps)) {
await client.query(
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
);
}
}
});
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
} catch (error) {
console.error("결재선 템플릿 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 삭제
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
} catch (error) {
console.error("결재선 템플릿 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재 요청 (Approval Requests) CRUD
// ============================================================
export class ApprovalRequestController {
// 결재 요청 목록 조회
static async getRequests(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
const conditions: string[] = ["r.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (status) {
conditions.push(`r.status = $${idx++}`);
params.push(status);
}
if (target_table) {
conditions.push(`r.target_table = $${idx++}`);
params.push(target_table);
}
if (target_record_id) {
conditions.push(`r.target_record_id = $${idx++}`);
params.push(target_record_id);
}
if (requester_id) {
conditions.push(`r.requester_id = $${idx++}`);
params.push(requester_id);
}
// 내 결재 대기 목록: 현재 사용자가 결재자인 라인만 조회
if (my_approvals === "true") {
conditions.push(
`EXISTS (SELECT 1 FROM approval_lines l WHERE l.request_id = r.request_id AND l.approver_id = $${idx++} AND l.status = 'pending' AND l.company_code = r.company_code)`
);
params.push(userId);
}
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
params.push(parseInt(limit as string), offset);
const rows = await query<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY r.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params
);
// 전체 건수 조회
const countParams = params.slice(0, params.length - 2);
const [countRow] = await query<any>(
`SELECT COUNT(*) as total FROM approval_requests r
WHERE ${conditions.join(" AND ")}`,
countParams
);
return res.json({
success: true,
data: rows,
total: parseInt(countRow?.total || "0"),
page: parseInt(page as string),
limit: parseInt(limit as string),
});
} catch (error) {
console.error("결재 요청 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 상세 조회 (라인 포함)
static async getRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const request = await queryOne<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE r.request_id = $1 AND r.company_code = $2`,
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
const lines = await query<any>(
"SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
[id, companyCode]
);
return res.json({ success: true, data: { ...request, lines } });
} catch (error) {
console.error("결재 요청 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 생성 (결재 라인 자동 생성)
static async createRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
title, description, definition_id, target_table, target_record_id,
target_record_data, screen_id, button_component_id,
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
approval_mode, // "sequential" | "parallel"
} = req.body;
if (!title || !target_table) {
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
}
if (!Array.isArray(approvers) || approvers.length === 0) {
return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." });
}
const userId = req.user?.userId || "system";
const userName = req.user?.userName || "";
const deptName = req.user?.deptName || "";
const isParallel = approval_mode === "parallel";
const totalSteps = approvers.length;
// approval_mode를 target_record_data에 병합 저장
const mergedRecordData = {
...(target_record_data || {}),
approval_mode: approval_mode || "sequential",
};
let result: any;
await transaction(async (client) => {
// 결재 요청 생성
const { rows: reqRows } = await client.query(
`INSERT INTO approval_requests (
title, description, definition_id, target_table, target_record_id,
target_record_data, status, current_step, total_steps,
requester_id, requester_name, requester_dept,
screen_id, button_component_id, company_code
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
title, description, definition_id, target_table, target_record_id || null,
JSON.stringify(mergedRecordData),
totalSteps,
userId, userName, deptName,
screen_id, button_component_id, companyCode,
]
);
result = reqRows[0];
// 결재 라인 생성
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
for (let i = 0; i < approvers.length; i++) {
const approver = approvers[i];
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
await client.query(
`INSERT INTO approval_lines (
request_id, step_order, approver_id, approver_name, approver_position,
approver_dept, approver_label, status, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
result.request_id,
i + 1,
approver.approver_id,
approver.approver_name || null,
approver.approver_position || null,
approver.approver_dept || null,
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
lineStatus,
companyCode,
]
);
}
// 상태를 in_progress로 업데이트
await client.query(
"UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1",
[result.request_id]
);
result.status = "in_progress";
});
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
} catch (error) {
console.error("결재 요청 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 회수 (cancel)
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const request = await queryOne<any>(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
if (request.requester_id !== userId) {
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
}
if (!["requested", "in_progress"].includes(request.status)) {
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
}
await query<any>(
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재 요청이 회수되었습니다." });
} catch (error) {
console.error("결재 요청 회수 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 회수 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재 라인 처리 (Approval Lines - 승인/반려)
// ============================================================
export class ApprovalLineController {
// 결재 처리 (승인/반려)
static async processApproval(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { lineId } = req.params;
const { action, comment } = req.body; // action: 'approved' | 'rejected'
if (!["approved", "rejected"].includes(action)) {
return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." });
}
const line = await queryOne<any>(
"SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2",
[lineId, companyCode]
);
if (!line) {
return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." });
}
if (line.approver_id !== userId) {
return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." });
}
if (line.status !== "pending") {
return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." });
}
await transaction(async (client) => {
// 현재 라인 처리
await client.query(
`UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW()
WHERE line_id = $3`,
[action, comment || null, lineId]
);
const { rows: reqRows } = await client.query(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[line.request_id, companyCode]
);
const request = reqRows[0];
if (!request) return;
if (action === "rejected") {
// 반려: 전체 요청 반려 처리
await client.query(
`UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3`,
[userId, comment || null, line.request_id]
);
// 남은 pending/waiting 라인도 skipped 처리
await client.query(
`UPDATE approval_lines SET status = 'skipped'
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`,
[line.request_id, lineId]
);
} else {
// 승인: 동시결재 vs 다단결재 분기
const recordData = request.target_record_data;
const isParallelMode = recordData?.approval_mode === "parallel";
if (isParallelMode) {
// 동시결재: 남은 pending 라인이 있는지 확인
const { rows: remainingLines } = await client.query(
`SELECT COUNT(*) as cnt FROM approval_lines
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
[line.request_id, lineId, companyCode]
);
const remaining = parseInt(remainingLines[0]?.cnt || "0");
if (remaining === 0) {
// 모든 동시 결재자 승인 완료 → 최종 승인
await client.query(
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3`,
[userId, comment || null, line.request_id]
);
}
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
} else {
// 다단결재: 다음 단계 활성화 또는 최종 완료
const nextStep = line.step_order + 1;
if (nextStep <= request.total_steps) {
await client.query(
`UPDATE approval_lines SET status = 'pending'
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
[line.request_id, nextStep, companyCode]
);
await client.query(
`UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`,
[nextStep, line.request_id]
);
} else {
await client.query(
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3`,
[userId, comment || null, line.request_id]
);
}
}
}
});
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
} catch (error) {
console.error("결재 처리 오류:", error);
return res.status(500).json({
success: false,
message: "결재 처리 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 내 결재 대기 목록 조회
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const rows = await query<any>(
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
FROM approval_lines l
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2
ORDER BY r.created_at ASC`,
[userId, companyCode]
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("내 결재 대기 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "내 결재 대기 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}

View File

@ -2,6 +2,37 @@ import { PostgreSQLService } from "./PostgreSQLService";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
/**
*
* approval_definitions, approval_line_templates, approval_line_template_steps,
* approval_requests, approval_lines
*/
export async function runApprovalSystemMigration() {
try {
console.log("🔄 결재 시스템 마이그레이션 시작...");
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/100_create_approval_system.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 결재 시스템 마이그레이션 완료!");
} catch (error) {
console.error("❌ 결재 시스템 마이그레이션 실패:", error);
if (error instanceof Error && error.message.includes("already exists")) {
console.log(" 테이블이 이미 존재합니다.");
}
}
}
/** /**
* *
* dashboard_elements custom_title, show_header * dashboard_elements custom_title, show_header

View File

@ -0,0 +1,38 @@
import express from "express";
import {
ApprovalDefinitionController,
ApprovalTemplateController,
ApprovalRequestController,
ApprovalLineController,
} from "../controllers/approvalController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
router.use(authenticateToken);
// ==================== 결재 유형 (Definitions) ====================
router.get("/definitions", ApprovalDefinitionController.getDefinitions);
router.get("/definitions/:id", ApprovalDefinitionController.getDefinition);
router.post("/definitions", ApprovalDefinitionController.createDefinition);
router.put("/definitions/:id", ApprovalDefinitionController.updateDefinition);
router.delete("/definitions/:id", ApprovalDefinitionController.deleteDefinition);
// ==================== 결재선 템플릿 (Templates) ====================
router.get("/templates", ApprovalTemplateController.getTemplates);
router.get("/templates/:id", ApprovalTemplateController.getTemplate);
router.post("/templates", ApprovalTemplateController.createTemplate);
router.put("/templates/:id", ApprovalTemplateController.updateTemplate);
router.delete("/templates/:id", ApprovalTemplateController.deleteTemplate);
// ==================== 결재 요청 (Requests) ====================
router.get("/requests", ApprovalRequestController.getRequests);
router.get("/requests/:id", ApprovalRequestController.getRequest);
router.post("/requests", ApprovalRequestController.createRequest);
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
// ==================== 결재 라인 처리 (Lines) ====================
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
export default router;

View File

@ -2,7 +2,6 @@
// Phase 2-1B: 핵심 인증 API 구현 // Phase 2-1B: 핵심 인증 API 구현
import { Router } from "express"; import { Router } from "express";
import { checkAuthStatus } from "../middleware/authMiddleware";
import { AuthController } from "../controllers/authController"; import { AuthController } from "../controllers/authController";
const router = Router(); const router = Router();
@ -12,7 +11,7 @@ const router = Router();
* API * API
* Java ApiLoginController.checkAuthStatus() * Java ApiLoginController.checkAuthStatus()
*/ */
router.get("/status", checkAuthStatus); router.get("/status", AuthController.checkAuthStatus);
/** /**
* POST /api/auth/login * POST /api/auth/login

View File

@ -56,6 +56,8 @@ interface Menu {
lang_key_desc: string | null; lang_key_desc: string | null;
screen_code: string | null; screen_code: string | null;
menu_code: string | null; menu_code: string | null;
menu_icon: string | null;
screen_group_id: number | null;
} }
/** /**
@ -371,7 +373,8 @@ export class MenuCopyService {
private async collectScreens( private async collectScreens(
menuObjids: number[], menuObjids: number[],
sourceCompanyCode: string, sourceCompanyCode: string,
client: PoolClient client: PoolClient,
menus?: Menu[]
): Promise<Set<number>> { ): Promise<Set<number>> {
logger.info( logger.info(
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}` `📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
@ -392,9 +395,25 @@ export class MenuCopyService {
screenIds.add(assignment.screen_id); screenIds.add(assignment.screen_id);
} }
logger.info(`📌 직접 할당 화면: ${screenIds.size}`); // 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴)
if (menus) {
const screenIdPattern = /\/screens\/(\d+)/;
for (const menu of menus) {
if (menu.menu_url) {
const match = menu.menu_url.match(screenIdPattern);
if (match) {
const urlScreenId = parseInt(match[1], 10);
if (!isNaN(urlScreenId) && urlScreenId > 0) {
screenIds.add(urlScreenId);
}
}
}
}
}
// 2) 화면 내부에서 참조되는 화면 (재귀) logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}`);
// 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색
const queue = Array.from(screenIds); const queue = Array.from(screenIds);
while (queue.length > 0) { while (queue.length > 0) {
@ -403,17 +422,29 @@ export class MenuCopyService {
if (visited.has(screenId)) continue; if (visited.has(screenId)) continue;
visited.add(screenId); visited.add(screenId);
// 화면 레이아웃 조회 const referencedScreens: number[] = [];
// V1 레이아웃에서 참조 화면 추출
const layoutsResult = await client.query<ScreenLayout>( const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1`, `SELECT * FROM screen_layouts WHERE screen_id = $1`,
[screenId] [screenId]
); );
referencedScreens.push(
// 참조 화면 추출 ...this.extractReferencedScreens(layoutsResult.rows)
const referencedScreens = this.extractReferencedScreens(
layoutsResult.rows
); );
// V2 레이아웃에서 참조 화면 추출
const layoutsV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, sourceCompanyCode]
);
for (const row of layoutsV2Result.rows) {
if (row.layout_data) {
this.extractScreenIdsFromObject(row.layout_data, referencedScreens);
}
}
if (referencedScreens.length > 0) { if (referencedScreens.length > 0) {
logger.info( logger.info(
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}` ` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
@ -895,6 +926,8 @@ export class MenuCopyService {
screenNameConfig?: { screenNameConfig?: {
removeText?: string; removeText?: string;
addPrefix?: string; addPrefix?: string;
replaceFrom?: string;
replaceTo?: string;
}, },
additionalCopyOptions?: AdditionalCopyOptions additionalCopyOptions?: AdditionalCopyOptions
): Promise<MenuCopyResult> { ): Promise<MenuCopyResult> {
@ -937,7 +970,8 @@ export class MenuCopyService {
const screenIds = await this.collectScreens( const screenIds = await this.collectScreens(
menus.map((m) => m.objid), menus.map((m) => m.objid),
sourceCompanyCode, sourceCompanyCode,
client client,
menus
); );
const flowIds = await this.collectFlows(screenIds, client); const flowIds = await this.collectFlows(screenIds, client);
@ -1093,6 +1127,16 @@ export class MenuCopyService {
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑"); logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
await this.updateMenuUrls(menuIdMap, screenIdMap, client); await this.updateMenuUrls(menuIdMap, screenIdMap, client);
// === 6.7단계: screen_group_screens 복제 ===
logger.info("\n🏷 [6.7단계] screen_group_screens 복제");
await this.copyScreenGroupScreens(
screenIds,
screenIdMap,
sourceCompanyCode,
targetCompanyCode,
client
);
// === 7단계: 테이블 타입 설정 복사 === // === 7단계: 테이블 타입 설정 복사 ===
if (additionalCopyOptions?.copyTableTypeColumns) { if (additionalCopyOptions?.copyTableTypeColumns) {
logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
@ -1417,6 +1461,8 @@ export class MenuCopyService {
screenNameConfig?: { screenNameConfig?: {
removeText?: string; removeText?: string;
addPrefix?: string; addPrefix?: string;
replaceFrom?: string;
replaceTo?: string;
}, },
numberingRuleIdMap?: Map<string, string>, numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number> menuIdMap?: Map<number, number>
@ -1516,6 +1562,13 @@ export class MenuCopyService {
// 3) 화면명 변환 적용 // 3) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name; let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) { if (screenNameConfig) {
if (screenNameConfig.replaceFrom?.trim()) {
transformedScreenName = transformedScreenName.replace(
new RegExp(screenNameConfig.replaceFrom.trim(), "g"),
screenNameConfig.replaceTo?.trim() || ""
);
transformedScreenName = transformedScreenName.trim();
}
if (screenNameConfig.removeText?.trim()) { if (screenNameConfig.removeText?.trim()) {
transformedScreenName = transformedScreenName.replace( transformedScreenName = transformedScreenName.replace(
new RegExp(screenNameConfig.removeText.trim(), "g"), new RegExp(screenNameConfig.removeText.trim(), "g"),
@ -1533,20 +1586,21 @@ export class MenuCopyService {
// === 기존 복사본이 있는 경우: 업데이트 === // === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreenId = existingCopy.screen_id; const existingScreenId = existingCopy.screen_id;
// 원본 V2 레이아웃 조회 // 원본 V2 레이아웃 조회 (모든 레이어)
const sourceLayoutV2Result = await client.query<{ layout_data: any }>( const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
[originalScreenId] [originalScreenId]
); );
// 대상 V2 레이아웃 조회 // 대상 V2 레이아웃 조회 (모든 레이어)
const targetLayoutV2Result = await client.query<{ layout_data: any }>( const targetLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
[existingScreenId] [existingScreenId]
); );
// 변경 여부 확인 (V2 레이아웃 비교) // 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨
const hasChanges = this.hasLayoutChangesV2( const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length;
const hasChanges = layerCountDiffers || this.hasLayoutChangesV2(
sourceLayoutV2Result.rows[0]?.layout_data, sourceLayoutV2Result.rows[0]?.layout_data,
targetLayoutV2Result.rows[0]?.layout_data targetLayoutV2Result.rows[0]?.layout_data
); );
@ -1650,7 +1704,7 @@ export class MenuCopyService {
} }
} }
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) === // === 2단계: screen_conditional_zones + screen_layouts_v2 처리 (멀티 레이어 지원) ===
logger.info( logger.info(
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` `\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
); );
@ -1662,23 +1716,90 @@ export class MenuCopyService {
isUpdate, isUpdate,
} of screenDefsToProcess) { } of screenDefsToProcess) {
try { try {
// 원본 V2 레이아웃 조회 const sourceCompanyCode = screenDef.company_code;
const layoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, // 원본 V2 레이아웃 전체 조회 (모든 레이어)
const layoutV2Result = await client.query<{
layout_data: any;
layer_id: number;
layer_name: string;
condition_config: any;
}>(
`SELECT layout_data, layer_id, layer_name, condition_config
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[originalScreenId, sourceCompanyCode]
);
if (layoutV2Result.rows.length === 0) {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
continue;
}
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
const componentIdMap = new Map<string, string>();
const timestamp = Date.now();
let compIdx = 0;
for (const layer of layoutV2Result.rows) {
const components = layer.layout_data?.components || [];
for (const comp of components) {
if (!componentIdMap.has(comp.id)) {
const newId = `comp_${timestamp}_${compIdx++}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(comp.id, newId);
}
}
}
// screen_conditional_zones 복제 + zoneIdMap 생성
const zoneIdMap = new Map<number, number>();
const zonesResult = await client.query(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
[originalScreenId] [originalScreenId]
); );
const layoutData = layoutV2Result.rows[0]?.layout_data; if (isUpdate) {
const components = layoutData?.components || []; await client.query(
`DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`,
[targetScreenId, targetCompanyCode]
);
}
if (layoutData && components.length > 0) { for (const zone of zonesResult.rows) {
// component_id 매핑 생성 (원본 → 새 ID) const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
const componentIdMap = new Map<string, string>(); const newZone = await client.query<{ zone_id: number }>(
const timestamp = Date.now(); `INSERT INTO screen_conditional_zones
components.forEach((comp: any, idx: number) => { (screen_id, company_code, zone_name, x, y, width, height,
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; trigger_component_id, trigger_operator)
componentIdMap.set(comp.id, newComponentId); VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
}); RETURNING zone_id`,
[targetScreenId, targetCompanyCode, zone.zone_name,
zone.x, zone.y, zone.width, zone.height,
newTriggerCompId, zone.trigger_operator]
);
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
}
if (zonesResult.rows.length > 0) {
logger.info(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개 (zoneIdMap: ${zoneIdMap.size}개)`);
}
// 업데이트인 경우 기존 레이아웃 삭제 (레이어 수 변경 대응)
if (isUpdate) {
await client.query(
`DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`,
[targetScreenId, targetCompanyCode]
);
}
// 각 레이어별 처리
let totalComponents = 0;
for (const layer of layoutV2Result.rows) {
const layoutData = layer.layout_data;
const components = layoutData?.components || [];
if (!layoutData || components.length === 0) continue;
totalComponents += components.length;
// V2 레이아웃 데이터 복사 및 참조 업데이트 // V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutDataV2( const updatedLayoutData = this.updateReferencesInLayoutDataV2(
@ -1690,20 +1811,34 @@ export class MenuCopyService {
menuIdMap menuIdMap
); );
// V2 레이아웃 저장 (UPSERT) // condition_config의 zone_id 재매핑
await client.query( let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) if (updatedConditionConfig?.zone_id) {
VALUES ($1, $2, $3, NOW(), NOW()) const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
ON CONFLICT (screen_id, company_code) if (newZoneId) {
DO UPDATE SET layout_data = $3, updated_at = NOW()`, updatedConditionConfig.zone_id = newZoneId;
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)] }
); }
const action = isUpdate ? "업데이트" : "복사"; // V2 레이아웃 저장 (레이어별 INSERT)
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`); await client.query(
} else { `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`); VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
[
targetScreenId,
targetCompanyCode,
layer.layer_id,
layer.layer_name,
JSON.stringify(updatedLayoutData),
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
]
);
} }
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`, `❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
@ -1983,6 +2118,26 @@ export class MenuCopyService {
logger.info(`📂 메뉴 복사 중: ${menus.length}`); logger.info(`📂 메뉴 복사 중: ${menus.length}`);
// screen_group_id 재매핑 맵 생성 (source company → target company)
const screenGroupIdMap = new Map<number, number>();
const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[];
if (sourceGroupIds.length > 0) {
const sourceGroups = await client.query<{ id: number; group_name: string }>(
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
[sourceGroupIds]
);
for (const sg of sourceGroups.rows) {
const targetGroup = await client.query<{ id: number }>(
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
[sg.group_name, targetCompanyCode]
);
if (targetGroup.rows.length > 0) {
screenGroupIdMap.set(sg.id, targetGroup.rows[0].id);
}
}
logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}`);
}
// 위상 정렬 (부모 먼저 삽입) // 위상 정렬 (부모 먼저 삽입)
const sortedMenus = this.topologicalSortMenus(menus); const sortedMenus = this.topologicalSortMenus(menus);
@ -2106,26 +2261,28 @@ export class MenuCopyService {
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, status, system_name, seq, menu_url, menu_desc, writer, status, system_name,
company_code, lang_key, lang_key_desc, screen_code, menu_code, company_code, lang_key, lang_key_desc, screen_code, menu_code,
source_menu_objid source_menu_objid, menu_icon, screen_group_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[ [
newObjId, newObjId,
menu.menu_type, menu.menu_type,
newParentObjId, // 재매핑 newParentObjId,
menu.menu_name_kor, menu.menu_name_kor,
menu.menu_name_eng, menu.menu_name_eng,
menu.seq, menu.seq,
menu.menu_url, menu.menu_url,
menu.menu_desc, menu.menu_desc,
userId, userId,
'active', // 복제된 메뉴는 항상 활성화 상태 menu.status || 'active',
menu.system_name, menu.system_name,
targetCompanyCode, // 새 회사 코드 targetCompanyCode,
menu.lang_key, menu.lang_key,
menu.lang_key_desc, menu.lang_key_desc,
menu.screen_code, // 그대로 유지 menu.screen_code,
menu.menu_code, menu.menu_code,
sourceMenuObjid, // 원본 메뉴 ID (최상위만) sourceMenuObjid,
menu.menu_icon,
menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null,
] ]
); );
@ -2246,8 +2403,9 @@ export class MenuCopyService {
} }
/** /**
* URL ( ID ) * URL + screen_code ( ID )
* menu_url에 /screens/{screenId} ID를 ID로 * menu_url에 /screens/{screenId} ID를 ID로
* menu_info.screen_code도 screen_definitions.screen_code로
*/ */
private async updateMenuUrls( private async updateMenuUrls(
menuIdMap: Map<number, number>, menuIdMap: Map<number, number>,
@ -2255,56 +2413,197 @@ export class MenuCopyService {
client: PoolClient client: PoolClient
): Promise<void> { ): Promise<void> {
if (menuIdMap.size === 0 || screenIdMap.size === 0) { if (menuIdMap.size === 0 || screenIdMap.size === 0) {
logger.info("📭 메뉴 URL 업데이트 대상 없음"); logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
return; return;
} }
const newMenuObjids = Array.from(menuIdMap.values()); const newMenuObjids = Array.from(menuIdMap.values());
// 복제된 메뉴 중 menu_url이 있는 것 조회 // 복제된 메뉴 조회
const menusWithUrl = await client.query<{ const menusToUpdate = await client.query<{
objid: number; objid: number;
menu_url: string; menu_url: string | null;
screen_code: string | null;
}>( }>(
`SELECT objid, menu_url FROM menu_info `SELECT objid, menu_url, screen_code FROM menu_info
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`, WHERE objid = ANY($1)`,
[newMenuObjids] [newMenuObjids]
); );
if (menusWithUrl.rows.length === 0) { if (menusToUpdate.rows.length === 0) {
logger.info("📭 menu_url 업데이트 대상 없음"); logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
return; return;
} }
let updatedCount = 0; // screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회
const screenIdPattern = /\/screens\/(\d+)/; const newScreenIds = Array.from(screenIdMap.values());
const screenCodeMap = new Map<string, string>();
for (const menu of menusWithUrl.rows) { if (newScreenIds.length > 0) {
const match = menu.menu_url.match(screenIdPattern); const screenCodesResult = await client.query<{
if (!match) continue; screen_id: number;
screen_code: string;
const originalScreenId = parseInt(match[1], 10); source_screen_id: number;
const newScreenId = screenIdMap.get(originalScreenId); }>(
`SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id
if (newScreenId && newScreenId !== originalScreenId) { FROM screen_definitions sd_new
const newMenuUrl = menu.menu_url.replace( WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`,
`/screens/${originalScreenId}`, [newScreenIds]
`/screens/${newScreenId}` );
); for (const row of screenCodesResult.rows) {
if (row.source_screen_id) {
await client.query( // 원본의 screen_code 조회
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`, const origResult = await client.query<{ screen_code: string }>(
[newMenuUrl, menu.objid] `SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
); [row.source_screen_id]
);
logger.info( if (origResult.rows[0]?.screen_code) {
` 🔗 메뉴 URL 업데이트: ${menu.menu_url}${newMenuUrl}` screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code);
); }
updatedCount++; }
} }
} }
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}`); let updatedUrlCount = 0;
let updatedCodeCount = 0;
const screenIdPattern = /\/screens\/(\d+)/;
for (const menu of menusToUpdate.rows) {
let newMenuUrl = menu.menu_url;
let newScreenCode = menu.screen_code;
let changed = false;
// menu_url 재매핑
if (menu.menu_url) {
const match = menu.menu_url.match(screenIdPattern);
if (match) {
const originalScreenId = parseInt(match[1], 10);
const newScreenId = screenIdMap.get(originalScreenId);
if (newScreenId && newScreenId !== originalScreenId) {
newMenuUrl = menu.menu_url.replace(
`/screens/${originalScreenId}`,
`/screens/${newScreenId}`
);
changed = true;
updatedUrlCount++;
logger.info(
` 🔗 메뉴 URL 업데이트: ${menu.menu_url}${newMenuUrl}`
);
}
}
// /screen/{screen_code} 형식도 처리
const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/);
if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) {
const origCode = screenCodeUrlMatch[1];
const newCode = screenCodeMap.get(origCode);
if (newCode && newCode !== origCode) {
newMenuUrl = `/screen/${newCode}`;
changed = true;
updatedUrlCount++;
logger.info(
` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url}${newMenuUrl}`
);
}
}
}
// screen_code 재매핑
if (menu.screen_code) {
const mappedCode = screenCodeMap.get(menu.screen_code);
if (mappedCode && mappedCode !== menu.screen_code) {
newScreenCode = mappedCode;
changed = true;
updatedCodeCount++;
logger.info(
` 🏷️ screen_code 업데이트: ${menu.screen_code}${newScreenCode}`
);
}
}
if (changed) {
await client.query(
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
[newMenuUrl, newScreenCode, menu.objid]
);
}
}
logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}`);
}
/**
* screen_group_screens (- )
*/
private async copyScreenGroupScreens(
screenIds: Set<number>,
screenIdMap: Map<number, number>,
sourceCompanyCode: string,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
if (screenIds.size === 0 || screenIdMap.size === 0) {
logger.info("📭 screen_group_screens 복제 대상 없음");
return;
}
// 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리)
await client.query(
`DELETE FROM screen_group_screens WHERE company_code = $1`,
[targetCompanyCode]
);
// 소스 회사의 screen_group_screens 조회
const sourceScreenIds = Array.from(screenIds);
const sourceResult = await client.query<{
group_id: number;
screen_id: number;
screen_role: string;
display_order: number;
is_default: string;
}>(
`SELECT group_id, screen_id, screen_role, display_order, is_default
FROM screen_group_screens
WHERE company_code = $1 AND screen_id = ANY($2)`,
[sourceCompanyCode, sourceScreenIds]
);
if (sourceResult.rows.length === 0) {
logger.info("📭 소스에 screen_group_screens 없음");
return;
}
// screen_group ID 매핑 (source group_name → target group_id)
const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))];
const sourceGroups = await client.query<{ id: number; group_name: string }>(
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
[sourceGroupIds]
);
const groupIdMap = new Map<number, number>();
for (const sg of sourceGroups.rows) {
const targetGroup = await client.query<{ id: number }>(
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
[sg.group_name, targetCompanyCode]
);
if (targetGroup.rows.length > 0) {
groupIdMap.set(sg.id, targetGroup.rows[0].id);
}
}
let insertedCount = 0;
for (const row of sourceResult.rows) {
const newGroupId = groupIdMap.get(row.group_id);
const newScreenId = screenIdMap.get(row.screen_id);
if (!newGroupId || !newScreenId) continue;
await client.query(
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, 'system')
ON CONFLICT DO NOTHING`,
[newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode]
);
insertedCount++;
}
logger.info(`✅ screen_group_screens 복제: ${insertedCount}`);
} }
/** /**

View File

@ -124,7 +124,10 @@ export async function syncScreenGroupsToMenu(
// 모든 메뉴의 objid 집합 (삭제 확인용) // 모든 메뉴의 objid 집합 (삭제 확인용)
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid))); const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴) // 3. objid 충돌 방지: 순차 카운터 사용
let nextObjid = Date.now();
// 4. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
// 없으면 생성 // 없으면 생성
let userMenuRootObjid: number | null = null; let userMenuRootObjid: number | null = null;
const rootMenuQuery = ` const rootMenuQuery = `
@ -138,19 +141,18 @@ export async function syncScreenGroupsToMenu(
if (rootMenuResult.rows.length > 0) { if (rootMenuResult.rows.length > 0) {
userMenuRootObjid = Number(rootMenuResult.rows[0].objid); userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
} else { } else {
// 루트 메뉴가 없으면 생성 const rootObjid = nextObjid++;
const newObjid = Date.now();
const createRootQuery = ` const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active') VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
RETURNING objid RETURNING objid
`; `;
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); const createRootResult = await client.query(createRootQuery, [rootObjid, companyCode, userId]);
userMenuRootObjid = Number(createRootResult.rows[0].objid); userMenuRootObjid = Number(createRootResult.rows[0].objid);
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid }); logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
} }
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해) // 5. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
const groupToMenuMap: Map<number, number> = new Map(); const groupToMenuMap: Map<number, number> = new Map();
// screen_groups의 부모 이름 조회를 위한 매핑 // screen_groups의 부모 이름 조회를 위한 매핑
@ -280,7 +282,7 @@ export async function syncScreenGroupsToMenu(
} else { } else {
// 새 메뉴 생성 // 새 메뉴 생성
const newObjid = Date.now() + groupId; // 고유 ID 보장 const newObjid = nextObjid++;
// 부모 메뉴 objid 결정 // 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수) // 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
@ -334,8 +336,8 @@ export async function syncScreenGroupsToMenu(
INSERT INTO menu_info ( INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng, objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc, seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
menu_url, screen_code menu_url, screen_code, menu_icon
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12)
RETURNING objid RETURNING objid
`; `;
await client.query(insertMenuQuery, [ await client.query(insertMenuQuery, [
@ -350,6 +352,7 @@ export async function syncScreenGroupsToMenu(
group.description || null, group.description || null,
menuUrl, menuUrl,
screenCode, screenCode,
group.icon || null,
]); ]);
// screen_groups에 menu_objid 업데이트 // screen_groups에 menu_objid 업데이트

View File

@ -3482,8 +3482,74 @@ export class ScreenManagementService {
} }
console.log( console.log(
`screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, `V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
); );
// V2 레이아웃(screen_layouts_v2)도 동일하게 처리
const v2LayoutsResult = await client.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE screen_id IN (${placeholders})
AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`,
targetScreenIds,
);
console.log(
`🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}`,
);
let v2Updated = 0;
for (const v2Layout of v2LayoutsResult.rows) {
let layoutData = v2Layout.layout_data;
if (!layoutData) continue;
let v2HasChanges = false;
const updateV2References = (obj: any): void => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) updateV2References(item);
return;
}
for (const key of Object.keys(obj)) {
const value = obj[key];
if (
(key === "screenId" || key === "targetScreenId" || key === "modalScreenId" ||
key === "leftScreenId" || key === "rightScreenId" ||
key === "addModalScreenId" || key === "editModalScreenId")
) {
const numVal = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numVal) && numVal > 0) {
const newId = screenMap.get(numVal);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId);
v2HasChanges = true;
console.log(`🔗 V2 ${key} 매핑: ${numVal}${newId}`);
}
}
}
if (typeof value === "object" && value !== null) {
updateV2References(value);
}
}
};
updateV2References(layoutData);
if (v2HasChanges) {
await client.query(
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
);
v2Updated++;
}
}
console.log(
`✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`,
);
result.updated += v2Updated;
}); });
return result; return result;
@ -4210,39 +4276,65 @@ export class ScreenManagementService {
const newScreen = newScreenResult.rows[0]; const newScreen = newScreenResult.rows[0];
// 4. 원본 화면의 V2 레이아웃 조회 // 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어)
let sourceLayoutV2Result = await client.query<{ layout_data: any }>( let sourceLayoutV2Result = await client.query<{
`SELECT layout_data FROM screen_layouts_v2 layout_data: any;
WHERE screen_id = $1 AND company_code = $2`, layer_id: number;
layer_name: string;
condition_config: any;
}>(
`SELECT layout_data, layer_id, layer_name, condition_config
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[sourceScreenId, sourceScreen.company_code], [sourceScreenId, sourceScreen.company_code],
); );
// 없으면 공통(*) 레이아웃 조회 // 없으면 공통(*) 레이아웃 조회
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data; if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") {
if (!layoutData && sourceScreen.company_code !== "*") { sourceLayoutV2Result = await client.query<{
const fallbackResult = await client.query<{ layout_data: any }>( layout_data: any;
`SELECT layout_data FROM screen_layouts_v2 layer_id: number;
WHERE screen_id = $1 AND company_code = '*'`, layer_name: string;
condition_config: any;
}>(
`SELECT layout_data, layer_id, layer_name, condition_config
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id`,
[sourceScreenId], [sourceScreenId],
); );
layoutData = fallbackResult.rows[0]?.layout_data;
} }
const components = layoutData?.components || []; // 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
const componentIdMap = new Map<string, string>();
for (const layer of sourceLayoutV2Result.rows) {
const components = layer.layout_data?.components || [];
for (const comp of components) {
if (!componentIdMap.has(comp.id)) {
componentIdMap.set(comp.id, generateId());
}
}
}
const hasComponents = componentIdMap.size > 0;
// 첫 번째 레이어의 layoutData (flowId/ruleId 수집용 - 모든 레이어에서 수집)
const allLayoutDatas = sourceLayoutV2Result.rows.map((r: any) => r.layout_data).filter(Boolean);
// 5. 노드 플로우 복사 (회사가 다른 경우) // 5. 노드 플로우 복사 (회사가 다른 경우)
let flowIdMap = new Map<number, number>(); let flowIdMap = new Map<number, number>();
if ( if (
components.length > 0 && hasComponents &&
sourceScreen.company_code !== targetCompanyCode sourceScreen.company_code !== targetCompanyCode
) { ) {
// V2 레이아웃에서 flowId 수집 const flowIds = new Set<number>();
const flowIds = this.collectFlowIdsFromLayoutData(layoutData); for (const ld of allLayoutDatas) {
const ids = this.collectFlowIdsFromLayoutData(ld);
ids.forEach((id: number) => flowIds.add(id));
}
if (flowIds.size > 0) { if (flowIds.size > 0) {
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`); console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`);
// 노드 플로우 복사 및 매핑 생성
flowIdMap = await this.copyNodeFlowsForScreen( flowIdMap = await this.copyNodeFlowsForScreen(
flowIds, flowIds,
sourceScreen.company_code, sourceScreen.company_code,
@ -4255,16 +4347,17 @@ export class ScreenManagementService {
// 5.1. 채번 규칙 복사 (회사가 다른 경우) // 5.1. 채번 규칙 복사 (회사가 다른 경우)
let ruleIdMap = new Map<string, string>(); let ruleIdMap = new Map<string, string>();
if ( if (
components.length > 0 && hasComponents &&
sourceScreen.company_code !== targetCompanyCode sourceScreen.company_code !== targetCompanyCode
) { ) {
// V2 레이아웃에서 채번 규칙 ID 수집 const ruleIds = new Set<string>();
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData); for (const ld of allLayoutDatas) {
const ids = this.collectNumberingRuleIdsFromLayoutData(ld);
ids.forEach((id: string) => ruleIds.add(id));
}
if (ruleIds.size > 0) { if (ruleIds.size > 0) {
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`); console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`);
// 채번 규칙 복사 및 매핑 생성
ruleIdMap = await this.copyNumberingRulesForScreen( ruleIdMap = await this.copyNumberingRulesForScreen(
ruleIds, ruleIds,
sourceScreen.company_code, sourceScreen.company_code,
@ -4274,39 +4367,89 @@ export class ScreenManagementService {
} }
} }
// 6. V2 레이아웃이 있다면 복사 // 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
if (layoutData && components.length > 0) { const zoneIdMap = new Map<number, number>();
if (hasComponents) {
try { try {
// componentId 매핑 생성 const zonesResult = await client.query(
const componentIdMap = new Map<string, string>(); `SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
for (const comp of components) { [sourceScreenId]
componentIdMap.set(comp.id, generateId()); );
for (const zone of zonesResult.rows) {
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
const newZone = await client.query<{ zone_id: number }>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height,
trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING zone_id`,
[newScreen.screen_id, targetCompanyCode, zone.zone_name,
zone.x, zone.y, zone.width, zone.height,
newTriggerCompId, zone.trigger_operator]
);
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
} }
// V2 레이아웃 데이터 복사 및 참조 업데이트 if (zonesResult.rows.length > 0) {
const updatedLayoutData = this.updateReferencesInLayoutData( console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}`);
layoutData, }
{ } catch (error) {
componentIdMap, console.error("조건부 영역 복사 중 오류:", error);
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined, }
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined, }
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
},
);
// V2 레이아웃 저장 (UPSERT) - layer_id 포함 // 6. V2 레이아웃 복사 (모든 레이어 순회)
await client.query( if (sourceLayoutV2Result.rows.length > 0 && hasComponents) {
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at) try {
VALUES ($1, $2, 1, $3, NOW(), NOW()) let totalComponents = 0;
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
for (const layer of sourceLayoutV2Result.rows) {
const layoutData = layer.layout_data;
const components = layoutData?.components || [];
if (!layoutData || components.length === 0) continue;
totalComponents += components.length;
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData,
{
componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
},
);
// condition_config의 zone_id 재매핑
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
if (updatedConditionConfig?.zone_id) {
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
if (newZoneId) {
updatedConditionConfig.zone_id = newZoneId;
}
}
// V2 레이아웃 저장 (레이어별 INSERT)
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
[
newScreen.screen_id,
targetCompanyCode,
layer.layer_id,
layer.layer_name,
JSON.stringify(updatedLayoutData),
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
],
);
}
console.log(` ↳ V2 레이아웃 복사: ${sourceLayoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
} catch (error) { } catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error); console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
} }
} }
@ -4533,9 +4676,60 @@ export class ScreenManagementService {
} }
console.log( console.log(
` ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, `V1: ${updateCount}개 레이아웃 업데이트 완료`,
); );
return updateCount;
// V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑
const v2Layouts = await query<any>(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE screen_id = $1
AND layout_data IS NOT NULL`,
[screenId],
);
let v2UpdateCount = 0;
for (const v2Layout of v2Layouts) {
const layoutData = v2Layout.layout_data;
if (!layoutData?.components) continue;
let v2Changed = false;
const updateV2Refs = (obj: any): void => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; }
for (const key of Object.keys(obj)) {
const value = obj[key];
if (
(key === "targetScreenId" || key === "screenId" || key === "modalScreenId" ||
key === "leftScreenId" || key === "rightScreenId" ||
key === "addModalScreenId" || key === "editModalScreenId")
) {
const numVal = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numVal) && screenIdMapping.has(numVal)) {
obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString();
v2Changed = true;
}
}
if (typeof value === "object" && value !== null) updateV2Refs(value);
}
};
updateV2Refs(layoutData);
if (v2Changed) {
await query(
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
);
v2UpdateCount++;
}
}
const total = updateCount + v2UpdateCount;
console.log(
`✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`,
);
return total;
} }
/** /**

View File

@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -12,7 +12,7 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- PORT=8080 - PORT=8080
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRES_IN=24h - JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:9771 - CORS_ORIGIN=http://localhost:9771

View File

@ -0,0 +1,342 @@
# 결재 시스템 구현 현황
## 1. 개요
어떤 화면/테이블에서든 결재 버튼을 추가하여 다단계(순차) 및 다중(병렬) 결재를 처리할 수 있는 범용 결재 시스템.
### 핵심 특징
- **범용성**: 특정 테이블에 종속되지 않고 어떤 화면에서든 사용 가능
- **멀티테넌시**: 모든 데이터가 `company_code`로 격리
- **사용자 주도**: 결재 요청 시 결재 모드/결재자를 직접 설정 (관리자 사전 세팅 불필요)
- **컴포넌트 연동**: 버튼 액션 타입 + 결재 단계 시각화 컴포넌트 제공
---
## 2. 아키텍처
```
[버튼 클릭 (approval 액션)]
[ButtonActionExecutor] → CustomEvent('open-approval-modal') 발송
[ApprovalGlobalListener] → 이벤트 수신
[ApprovalRequestModal] → 결재 모드/결재자 선택 UI
[POST /api/approval/requests] → 결재 요청 생성
[approval_requests + approval_lines 테이블에 저장]
[결재함 / 결재 단계 컴포넌트에서 조회 및 처리]
```
---
## 3. 데이터베이스
### 마이그레이션 파일
- `db/migrations/100_create_approval_system.sql`
### 테이블 구조
| 테이블 | 용도 | 주요 컬럼 |
|--------|------|-----------|
| `approval_definitions` | 결재 유형 정의 (구매결재, 문서결재 등) | definition_id, definition_name, max_steps, company_code |
| `approval_line_templates` | 결재선 템플릿 (미리 저장된 결재선) | template_id, template_name, definition_id, company_code |
| `approval_line_template_steps` | 템플릿별 결재 단계 | step_id, template_id, step_order, approver_user_id, company_code |
| `approval_requests` | 실제 결재 요청 건 | request_id, title, target_table, target_record_id, status, requester_id, company_code |
| `approval_lines` | 결재 건별 각 단계 결재자 | line_id, request_id, step_order, approver_id, status, comment, company_code |
### 결재 상태 흐름
```
[requested] → [in_progress] → [approved] (모든 단계 승인)
→ [rejected] (어느 단계에서든 반려)
→ [cancelled] (요청자가 취소)
```
#### approval_requests.status
| 상태 | 의미 |
|------|------|
| `requested` | 결재 요청됨 (1단계 결재자 처리 대기) |
| `in_progress` | 결재 진행 중 (2단계 이상 진행) |
| `approved` | 최종 승인 완료 |
| `rejected` | 반려됨 |
| `cancelled` | 요청자에 의해 취소 |
#### approval_lines.status
| 상태 | 의미 |
|------|------|
| `waiting` | 아직 차례가 아님 |
| `pending` | 현재 결재 차례 (처리 대기) |
| `approved` | 승인 완료 |
| `rejected` | 반려 |
| `skipped` | 이전 단계 반려로 스킵됨 |
---
## 4. 백엔드 API
### 파일 위치
- **컨트롤러**: `backend-node/src/controllers/approvalController.ts`
- **라우트**: `backend-node/src/routes/approvalRoutes.ts`
### API 엔드포인트
#### 결재 유형 (Definitions)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/definitions` | 결재 유형 목록 |
| GET | `/api/approval/definitions/:id` | 결재 유형 상세 |
| POST | `/api/approval/definitions` | 결재 유형 생성 |
| PUT | `/api/approval/definitions/:id` | 결재 유형 수정 |
| DELETE | `/api/approval/definitions/:id` | 결재 유형 삭제 |
#### 결재선 템플릿 (Templates)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/templates` | 템플릿 목록 |
| GET | `/api/approval/templates/:id` | 템플릿 상세 (단계 포함) |
| POST | `/api/approval/templates` | 템플릿 생성 |
| PUT | `/api/approval/templates/:id` | 템플릿 수정 |
| DELETE | `/api/approval/templates/:id` | 템플릿 삭제 |
#### 결재 요청 (Requests)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/requests` | 결재 요청 목록 (필터 가능) |
| GET | `/api/approval/requests/:id` | 결재 요청 상세 (결재 라인 포함) |
| POST | `/api/approval/requests` | 결재 요청 생성 |
| POST | `/api/approval/requests/:id/cancel` | 결재 취소 |
#### 결재 라인 처리 (Lines)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/my-pending` | 내 미처리 결재 목록 |
| POST | `/api/approval/lines/:lineId/process` | 승인/반려 처리 |
### 결재 요청 생성 시 입력
```typescript
interface CreateApprovalRequestInput {
title: string; // 결재 제목
description?: string; // 결재 설명
target_table: string; // 대상 테이블명 (예: sales_order_mng)
target_record_id?: string; // 대상 레코드 ID (선택)
approval_mode?: "sequential" | "parallel"; // 결재 모드
approvers: { // 결재자 목록
approver_id: string;
approver_name?: string;
approver_position?: string;
approver_dept?: string;
}[];
}
```
### 결재 처리 로직
#### 순차 결재 (sequential)
1. 첫 번째 결재자 `status = 'pending'`, 나머지 `'waiting'`
2. 1단계 승인 → 2단계 `'pending'`으로 변경
3. 모든 단계 승인 → `approval_requests.status = 'approved'`
4. 어느 단계에서 반려 → 이후 단계 `'skipped'`, 요청 `'rejected'`
#### 병렬 결재 (parallel)
1. 모든 결재자 `status = 'pending'` (동시 처리)
2. 모든 결재자 승인 → `'approved'`
3. 한 명이라도 반려 → `'rejected'`
---
## 5. 프론트엔드
### 5.1 결재 요청 모달
**파일**: `frontend/components/approval/ApprovalRequestModal.tsx`
- 결재 모드 선택 (다단 결재 / 다중 결재)
- 결재자 검색 (사용자 API 검색, 한글/영문/ID 검색 가능)
- 결재자 추가/삭제, 순서 변경 (순차 결재 시)
- 대상 테이블/레코드 ID 자동 세팅
### 5.2 결재 글로벌 리스너
**파일**: `frontend/components/approval/ApprovalGlobalListener.tsx`
- `open-approval-modal` CustomEvent를 전역으로 수신
- 이벤트의 `detail`에서 `targetTable`, `targetRecordId`, `formData` 추출
- `ApprovalRequestModal` 열기
### 5.3 결재함 페이지
**파일**: `frontend/app/(main)/admin/approvalBox/page.tsx`
- 탭 구성: 보낸 결재 / 받은 결재 / 완료된 결재
- 결재 상태별 필터링
- 결재 상세 조회 및 승인/반려 처리
**진입점**: 사용자 프로필 드롭다운 > "결재함"
### 5.4 결재 단계 시각화 컴포넌트 (v2-approval-step)
**파일 위치**: `frontend/lib/registry/components/v2-approval-step/`
| 파일 | 역할 |
|------|------|
| `types.ts` | ApprovalStepConfig 타입 정의 |
| `ApprovalStepComponent.tsx` | 결재 단계 시각화 UI (가로형 스테퍼 / 세로형 타임라인) |
| `ApprovalStepConfigPanel.tsx` | 설정 패널 (대상 테이블/컬럼 Combobox, 표시 옵션) |
| `ApprovalStepRenderer.tsx` | 컴포넌트 레지스트리 등록 |
| `index.ts` | 컴포넌트 정의 (이름, 태그, 기본값 등) |
#### 설정 항목
| 설정 | 설명 |
|------|------|
| 대상 테이블 | 결재를 걸 데이터가 있는 테이블 (Combobox 검색) |
| 레코드 ID 필드명 | 테이블의 PK 컬럼 (Combobox 검색) |
| 표시 모드 | 가로형 스테퍼 / 세로형 타임라인 |
| 부서/직급 표시 | 결재자의 부서/직급 정보 표시 여부 |
| 결재 코멘트 표시 | 승인/반려 시 입력한 코멘트 표시 여부 |
| 처리 시각 표시 | 결재 처리 시각 표시 여부 |
| 콤팩트 모드 | 작게 표시 |
### 5.5 API 클라이언트
**파일**: `frontend/lib/api/approval.ts`
| 함수 | 용도 |
|------|------|
| `getApprovalDefinitions()` | 결재 유형 목록 조회 |
| `getApprovalTemplates()` | 결재선 템플릿 목록 조회 |
| `getApprovalRequests()` | 결재 요청 목록 조회 (필터 지원) |
| `getApprovalRequest(id)` | 결재 요청 상세 조회 |
| `createApprovalRequest(data)` | 결재 요청 생성 |
| `cancelApprovalRequest(id)` | 결재 취소 |
| `getMyPendingApprovals()` | 내 미처리 결재 목록 |
| `processApprovalLine(lineId, data)` | 승인/반려 처리 |
### 5.6 버튼 액션 연동
#### 관련 파일
| 파일 | 수정 내용 |
|------|-----------|
| `frontend/lib/utils/buttonActions.ts` | `ButtonActionType``"approval"` 추가, `handleApproval` 구현 |
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | `approval` 액션 핸들러 추가 |
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `silentActions``"approval"` 추가 |
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 결재 액션 설정 UI (대상 테이블 자동 세팅) |
#### 동작 흐름
1. 버튼 설정에서 액션 타입 = `"approval"` 선택
2. 대상 테이블 자동 설정 (현재 화면 테이블)
3. 버튼 클릭 시 `CustomEvent('open-approval-modal')` 발송
4. `ApprovalGlobalListener`가 수신하여 `ApprovalRequestModal` 오픈
---
## 6. 멀티테넌시 적용
| 영역 | 적용 |
|------|------|
| DB 테이블 | 모든 테이블에 `company_code NOT NULL` 포함 |
| 인덱스 | `company_code` 컬럼에 인덱스 생성 |
| SELECT | `WHERE company_code = $N` 필수 |
| INSERT | `company_code` 값 포함 필수 |
| UPDATE/DELETE | `WHERE` 절에 `company_code` 조건 포함 |
| 최고관리자 | `company_code = '*'` → 모든 데이터 조회 가능 |
| JOIN | `ON` 절에 `company_code` 매칭 포함 |
---
## 7. 전체 파일 목록
### 데이터베이스
```
db/migrations/100_create_approval_system.sql
```
### 백엔드
```
backend-node/src/controllers/approvalController.ts
backend-node/src/routes/approvalRoutes.ts
```
### 프론트엔드 - 결재 모달/리스너
```
frontend/components/approval/ApprovalRequestModal.tsx
frontend/components/approval/ApprovalGlobalListener.tsx
```
### 프론트엔드 - 결재함 페이지
```
frontend/app/(main)/admin/approvalBox/page.tsx
```
### 프론트엔드 - 결재 단계 컴포넌트
```
frontend/lib/registry/components/v2-approval-step/types.ts
frontend/lib/registry/components/v2-approval-step/ApprovalStepComponent.tsx
frontend/lib/registry/components/v2-approval-step/ApprovalStepConfigPanel.tsx
frontend/lib/registry/components/v2-approval-step/ApprovalStepRenderer.tsx
frontend/lib/registry/components/v2-approval-step/index.ts
```
### 프론트엔드 - API 클라이언트
```
frontend/lib/api/approval.ts
```
### 프론트엔드 - 버튼 액션 연동 (수정된 파일)
```
frontend/lib/utils/buttonActions.ts
frontend/lib/utils/improvedButtonActionExecutor.ts
frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
frontend/components/screen/config-panels/ButtonConfigPanel.tsx
```
### 프론트엔드 - 레이아웃 (수정된 파일)
```
frontend/components/layout/UserDropdown.tsx (결재함 메뉴 추가)
frontend/components/layout/AppLayout.tsx (결재함 메뉴 추가)
frontend/lib/registry/components/index.ts (v2-approval-step 렌더러 import)
```
---
## 8. 사용 방법
### 결재 버튼 추가
1. 화면 디자이너에서 버튼 컴포넌트 추가
2. 버튼 설정 > 액션 타입 = `결재` 선택
3. 대상 테이블이 자동 설정됨 (수동 변경 가능)
4. 저장
### 결재 요청하기
1. 데이터 행 선택 (선택적)
2. 결재 버튼 클릭
3. 결재 모달에서:
- 결재 제목 입력
- 결재 모드 선택 (다단 결재 / 다중 결재)
- 결재자 검색하여 추가
4. 결재 요청 클릭
### 결재 처리하기
1. 프로필 드롭다운 > 결재함 클릭
2. 받은 결재 탭에서 대기 중인 결재 확인
3. 상세 보기 > 승인 또는 반려
### 결재 단계 표시하기
1. 화면 디자이너에서 `결재 단계` 컴포넌트 추가
2. 설정에서 대상 테이블 / 레코드 ID 필드 선택
3. 표시 모드 (가로/세로) 및 옵션 설정
4. 저장 → 행 선택 시 해당 레코드의 결재 단계가 표시됨
---
## 9. 향후 개선 사항
- [ ] 결재 알림 (실시간 알림, 이메일 연동)
- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션)
- [ ] 결재 위임 기능
- [ ] 결재 이력 조회 / 통계 대시보드
- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장)
- [ ] 모바일 결재 처리 최적화

View File

@ -0,0 +1,419 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye,
} from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
getApprovalRequests,
getApprovalRequest,
getMyPendingApprovals,
processApprovalLine,
cancelApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
requested: { label: "요청", variant: "outline" },
in_progress: { label: "진행중", variant: "default" },
approved: { label: "승인", variant: "default" },
rejected: { label: "반려", variant: "destructive" },
cancelled: { label: "회수", variant: "secondary" },
waiting: { label: "대기", variant: "outline" },
pending: { label: "결재대기", variant: "default" },
skipped: { label: "건너뜀", variant: "secondary" },
};
function StatusBadge({ status }: { status: string }) {
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
return <Badge variant={info.variant}>{info.label}</Badge>;
}
function formatDate(dateStr?: string) {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
// ============================================================
// 상신함 (내가 올린 결재)
// ============================================================
function SentTab() {
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
const [loading, setLoading] = useState(true);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const fetchRequests = useCallback(async () => {
setLoading(true);
const res = await getApprovalRequests({ my_approvals: false });
if (res.success && res.data) setRequests(res.data);
setLoading(false);
}, []);
useEffect(() => { fetchRequests(); }, [fetchRequests]);
const openDetail = async (req: ApprovalRequest) => {
setDetailLoading(true);
setDetailOpen(true);
const res = await getApprovalRequest(req.request_id);
if (res.success && res.data) {
setSelectedRequest(res.data);
} else {
setSelectedRequest(req);
}
setDetailLoading(false);
};
const handleCancel = async () => {
if (!selectedRequest) return;
const res = await cancelApprovalRequest(selectedRequest.request_id);
if (res.success) {
toast.success("결재가 회수되었습니다.");
setDetailOpen(false);
fetchRequests();
} else {
toast.error(res.error || "회수 실패");
}
};
return (
<div className="space-y-4">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<Send className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[140px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((req) => (
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
<TableCell className="h-14 text-center text-sm">
{req.current_step}/{req.total_steps}
</TableCell>
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
<TableCell className="h-14 text-center">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 상세 모달 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedRequest?.title}
</DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : selectedRequest && (
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"> </span>
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
</div>
</div>
{selectedRequest.description && (
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 text-sm">{selectedRequest.description}</p>
</div>
)}
{/* 결재선 */}
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
<div>
<span className="text-muted-foreground text-xs"></span>
<div className="mt-2 space-y-2">
{selectedRequest.lines
.sort((a, b) => a.step_order - b.step_order)
.map((line) => (
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{line.step_order}</Badge>
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
{line.approver_position && (
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
)}
</div>
<div className="flex items-center gap-2">
<StatusBadge status={line.status} />
{line.processed_at && (
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{selectedRequest?.status === "requested" && (
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
)}
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 수신함 (내가 결재해야 할 것)
// ============================================================
function ReceivedTab() {
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
const [loading, setLoading] = useState(true);
const [processOpen, setProcessOpen] = useState(false);
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const fetchPending = useCallback(async () => {
setLoading(true);
const res = await getMyPendingApprovals();
if (res.success && res.data) setPendingLines(res.data);
setLoading(false);
}, []);
useEffect(() => { fetchPending(); }, [fetchPending]);
const openProcess = (line: ApprovalLine) => {
setSelectedLine(line);
setComment("");
setProcessOpen(true);
};
const handleProcess = async (action: "approved" | "rejected") => {
if (!selectedLine) return;
setIsProcessing(true);
const res = await processApprovalLine(selectedLine.line_id, {
action,
comment: comment.trim() || undefined,
});
setIsProcessing(false);
if (res.success) {
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
setProcessOpen(false);
fetchPending();
} else {
toast.error(res.error || "처리 실패");
}
};
return (
<div className="space-y-4">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : pendingLines.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[140px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingLines.map((line) => (
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
<TableCell className="h-14 text-sm">
{line.requester_name || "-"}
{line.requester_dept && (
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
)}
</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant="outline">{line.step_order}</Badge>
</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
<TableCell className="h-14 text-center">
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 결재 처리 모달 */}
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedLine?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"> </span>
<p className="mt-1 font-medium">{selectedLine?.step_order} </p>
</div>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="결재 의견을 입력하세요 (선택사항)"
className="min-h-[80px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="destructive"
onClick={() => handleProcess("rejected")}
disabled={isProcessing}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<XCircle className="h-4 w-4" />
</Button>
<Button
onClick={() => handleProcess("approved")}
disabled={isProcessing}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 메인 페이지
// ============================================================
export default function ApprovalBoxPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground text-sm">
.
</p>
</div>
<Tabs defaultValue="received" className="space-y-4">
<TabsList>
<TabsTrigger value="received" className="gap-2">
<Inbox className="h-4 w-4" />
( )
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" />
( )
</TabsTrigger>
</TabsList>
<TabsContent value="received">
<ReceivedTab />
</TabsContent>
<TabsContent value="sent">
<SentTab />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,788 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { Plus, Edit, Trash2, Search, Users, FileText, Loader2 } from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
type ApprovalDefinition,
type ApprovalLineTemplate,
type ApprovalLineTemplateStep,
getApprovalDefinitions,
createApprovalDefinition,
updateApprovalDefinition,
deleteApprovalDefinition,
getApprovalTemplates,
getApprovalTemplate,
createApprovalTemplate,
updateApprovalTemplate,
deleteApprovalTemplate,
} from "@/lib/api/approval";
// ============================================================
// 결재 유형 관리 탭
// ============================================================
function DefinitionsTab() {
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editingDef, setEditingDef] = useState<ApprovalDefinition | null>(null);
const [formData, setFormData] = useState({
definition_name: "",
definition_name_eng: "",
description: "",
max_steps: 3,
allow_self_approval: false,
allow_cancel: true,
is_active: "Y",
});
const [deleteTarget, setDeleteTarget] = useState<ApprovalDefinition | null>(null);
const fetchDefinitions = useCallback(async () => {
setLoading(true);
const res = await getApprovalDefinitions({ search: searchTerm || undefined });
if (res.success && res.data) {
setDefinitions(res.data);
}
setLoading(false);
}, [searchTerm]);
useEffect(() => {
fetchDefinitions();
}, [fetchDefinitions]);
const openCreate = () => {
setEditingDef(null);
setFormData({
definition_name: "",
definition_name_eng: "",
description: "",
max_steps: 3,
allow_self_approval: false,
allow_cancel: true,
is_active: "Y",
});
setEditOpen(true);
};
const openEdit = (def: ApprovalDefinition) => {
setEditingDef(def);
setFormData({
definition_name: def.definition_name,
definition_name_eng: def.definition_name_eng || "",
description: def.description || "",
max_steps: def.max_steps,
allow_self_approval: def.allow_self_approval,
allow_cancel: def.allow_cancel,
is_active: def.is_active,
});
setEditOpen(true);
};
const handleSave = async () => {
if (!formData.definition_name.trim()) {
toast.warning("결재 유형명을 입력해주세요.");
return;
}
let res;
if (editingDef) {
res = await updateApprovalDefinition(editingDef.definition_id, formData);
} else {
res = await createApprovalDefinition(formData);
}
if (res.success) {
toast.success(editingDef ? "수정되었습니다." : "등록되었습니다.");
setEditOpen(false);
fetchDefinitions();
} else {
toast.error(res.error || "저장 실패");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
const res = await deleteApprovalDefinition(deleteTarget.definition_id);
if (res.success) {
toast.success("삭제되었습니다.");
setDeleteTarget(null);
fetchDefinitions();
} else {
toast.error(res.error || "삭제 실패");
}
};
const filtered = definitions.filter(
(d) =>
d.definition_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(d.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="space-y-4">
{/* 검색 + 등록 */}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="유형명 또는 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<span className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{filtered.length}</span>
</span>
</div>
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 테이블 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((def) => (
<TableRow key={def.definition_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{def.definition_name}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{def.description || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">{def.max_steps}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={def.allow_self_approval ? "default" : "secondary"}>
{def.allow_self_approval ? "허용" : "불가"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={def.allow_cancel ? "default" : "secondary"}>
{def.allow_cancel ? "허용" : "불가"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={def.is_active === "Y" ? "default" : "outline"}>
{def.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(def)}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(def)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 등록/수정 모달 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingDef ? "결재 유형 수정" : "결재 유형 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.definition_name}
onChange={(e) => setFormData((p) => ({ ...p, definition_name: e.target.value }))}
placeholder="예: 일반 결재, 긴급 결재"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.definition_name_eng}
onChange={(e) => setFormData((p) => ({ ...p, definition_name_eng: e.target.value }))}
placeholder="예: General Approval"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
placeholder="유형에 대한 설명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={1}
max={10}
value={formData.max_steps}
onChange={(e) => setFormData((p) => ({ ...p, max_steps: Number(e.target.value) }))}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.allow_self_approval}
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_self_approval: v }))}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.allow_cancel}
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_cancel: v }))}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.is_active === "Y"}
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{editingDef ? "수정" : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.definition_name}&quot;() ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ============================================================
// 결재선 템플릿 관리 탭
// ============================================================
function TemplatesTab() {
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editingTpl, setEditingTpl] = useState<ApprovalLineTemplate | null>(null);
const [formData, setFormData] = useState({
template_name: "",
description: "",
definition_id: null as number | null,
is_active: "Y",
steps: [] as Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[],
});
const [deleteTarget, setDeleteTarget] = useState<ApprovalLineTemplate | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
const [tplRes, defRes] = await Promise.all([
getApprovalTemplates(),
getApprovalDefinitions({ is_active: "Y" }),
]);
if (tplRes.success && tplRes.data) setTemplates(tplRes.data);
if (defRes.success && defRes.data) setDefinitions(defRes.data);
setLoading(false);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditingTpl(null);
setFormData({
template_name: "",
description: "",
definition_id: null,
is_active: "Y",
steps: [{ step_order: 1, approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
});
setEditOpen(true);
};
const openEdit = async (tpl: ApprovalLineTemplate) => {
const res = await getApprovalTemplate(tpl.template_id);
if (!res.success || !res.data) {
toast.error("템플릿 정보를 불러올 수 없습니다.");
return;
}
const detail = res.data;
setEditingTpl(detail);
setFormData({
template_name: detail.template_name,
description: detail.description || "",
definition_id: detail.definition_id || null,
is_active: detail.is_active,
steps: (detail.steps || []).map((s) => ({
step_order: s.step_order,
approver_type: s.approver_type,
approver_user_id: s.approver_user_id,
approver_position: s.approver_position,
approver_dept_code: s.approver_dept_code,
approver_label: s.approver_label,
})),
});
setEditOpen(true);
};
const addStep = () => {
setFormData((p) => ({
...p,
steps: [
...p.steps,
{
step_order: p.steps.length + 1,
approver_type: "user" as const,
approver_user_id: "",
approver_label: `${p.steps.length + 1}차 결재자`,
},
],
}));
};
const removeStep = (idx: number) => {
setFormData((p) => ({
...p,
steps: p.steps.filter((_, i) => i !== idx).map((s, i) => ({ ...s, step_order: i + 1 })),
}));
};
const updateStep = (idx: number, field: string, value: string) => {
setFormData((p) => ({
...p,
steps: p.steps.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
}));
};
const handleSave = async () => {
if (!formData.template_name.trim()) {
toast.warning("템플릿명을 입력해주세요.");
return;
}
if (formData.steps.length === 0) {
toast.warning("결재 단계를 최소 1개 추가해주세요.");
return;
}
const payload = {
template_name: formData.template_name,
description: formData.description || undefined,
definition_id: formData.definition_id || undefined,
is_active: formData.is_active,
steps: formData.steps,
};
let res;
if (editingTpl) {
res = await updateApprovalTemplate(editingTpl.template_id, payload);
} else {
res = await createApprovalTemplate(payload);
}
if (res.success) {
toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다.");
setEditOpen(false);
fetchData();
} else {
toast.error(res.error || "저장 실패");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
const res = await deleteApprovalTemplate(deleteTarget.template_id);
if (res.success) {
toast.success("삭제되었습니다.");
setDeleteTarget(null);
fetchData();
} else {
toast.error(res.error || "삭제 실패");
}
};
const filtered = templates.filter(
(t) =>
t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(t.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="space-y-4">
{/* 검색 + 등록 */}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="템플릿명 또는 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<span className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{filtered.length}</span>
</span>
</div>
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
릿
</Button>
</div>
{/* 테이블 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> 릿 .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold">릿</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((tpl) => (
<TableRow key={tpl.template_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{tpl.template_name}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{tpl.description || "-"}</TableCell>
<TableCell className="h-14 text-sm">{tpl.definition_name || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant="secondary">{tpl.steps?.length || 0}</Badge>
</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={tpl.is_active === "Y" ? "default" : "outline"}>
{tpl.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(tpl)}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(tpl)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 등록/수정 모달 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingTpl ? "결재선 템플릿 수정" : "결재선 템플릿 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
<div>
<Label className="text-xs sm:text-sm">릿 *</Label>
<Input
value={formData.template_name}
onChange={(e) => setFormData((p) => ({ ...p, template_name: e.target.value }))}
placeholder="예: 일반 3단계 결재선"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
placeholder="템플릿에 대한 설명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.definition_id ? String(formData.definition_id) : "none"}
onValueChange={(v) => setFormData((p) => ({ ...p, definition_id: v === "none" ? null : Number(v) }))}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="결재 유형 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{definitions.map((d) => (
<SelectItem key={d.definition_id} value={String(d.definition_id)}>
{d.definition_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.is_active === "Y"}
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
/>
</div>
{/* 결재 단계 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<Button variant="outline" size="sm" onClick={addStep} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
{formData.steps.length === 0 && (
<p className="text-muted-foreground py-4 text-center text-xs">
.
</p>
)}
{formData.steps.map((step, idx) => (
<div key={idx} className="bg-muted/30 space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{step.step_order}</span>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={() => removeStep(idx)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Select value={step.approver_type} onValueChange={(v) => updateStep(idx, "approver_type", v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"> </SelectItem>
<SelectItem value="position"> </SelectItem>
<SelectItem value="dept"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={step.approver_label || ""}
onChange={(e) => updateStep(idx, "approver_label", e.target.value)}
placeholder="예: 팀장"
className="h-7 text-xs"
/>
</div>
</div>
{step.approver_type === "user" && (
<div>
<Label className="text-[10px]"> ID</Label>
<Input
value={step.approver_user_id || ""}
onChange={(e) => updateStep(idx, "approver_user_id", e.target.value)}
placeholder="고정 결재자 ID (비워두면 요청 시 지정)"
className="h-7 text-xs"
/>
</div>
)}
{step.approver_type === "position" && (
<div>
<Label className="text-[10px]"></Label>
<Input
value={step.approver_position || ""}
onChange={(e) => updateStep(idx, "approver_position", e.target.value)}
placeholder="예: 부장, 이사"
className="h-7 text-xs"
/>
</div>
)}
{step.approver_type === "dept" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
value={step.approver_dept_code || ""}
onChange={(e) => updateStep(idx, "approver_dept_code", e.target.value)}
placeholder="예: DEPT001"
className="h-7 text-xs"
/>
</div>
)}
</div>
))}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{editingTpl ? "수정" : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> 릿 </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.template_name}&quot;() ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ============================================================
// 메인 페이지
// ============================================================
export default function ApprovalManagementPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm">
릿 .
</p>
</div>
{/* 탭 */}
<Tabs defaultValue="definitions" className="space-y-4">
<TabsList>
<TabsTrigger value="definitions" className="gap-2">
<FileText className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="templates" className="gap-2">
<Users className="h-4 w-4" />
릿
</TabsTrigger>
</TabsList>
<TabsContent value="definitions">
<DefinitionsTab />
</TabsContent>
<TabsContent value="templates">
<TemplatesTab />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,426 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
import {
getApprovalRequests,
getApprovalRequest,
getMyPendingApprovals,
processApprovalLine,
cancelApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
// 상태 배지 색상
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
requested: { label: "요청됨", variant: "secondary" },
in_progress: { label: "진행 중", variant: "default" },
approved: { label: "승인됨", variant: "outline" },
rejected: { label: "반려됨", variant: "destructive" },
cancelled: { label: "취소됨", variant: "secondary" },
};
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
};
// 결재 상세 모달
interface ApprovalDetailModalProps {
request: ApprovalRequest | null;
open: boolean;
onClose: () => void;
onRefresh: () => void;
pendingLineId?: number; // 내가 처리해야 할 결재 라인 ID
}
function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId }: ApprovalDetailModalProps) {
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
useEffect(() => {
if (!open) setComment("");
}, [open]);
const handleProcess = async (action: "approved" | "rejected") => {
if (!pendingLineId) return;
setIsProcessing(true);
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
setIsProcessing(false);
if (res.success) {
onRefresh();
onClose();
}
};
const handleCancel = async () => {
if (!request) return;
setIsCancelling(true);
const res = await cancelApprovalRequest(request.request_id);
setIsCancelling(false);
if (res.success) {
onRefresh();
onClose();
}
};
if (!request) return null;
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileCheck2 className="h-5 w-5" />
{request.title}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
<Badge variant={statusInfo.variant} className="mr-2">
{statusInfo.label}
</Badge>
: {request.requester_name || request.requester_id}
{request.requester_dept ? ` (${request.requester_dept})` : ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 결재 사유 */}
{request.description && (
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium"> </p>
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm">{request.description}</p>
</div>
)}
{/* 결재선 */}
<div>
<p className="text-muted-foreground mb-2 text-xs font-medium"></p>
<div className="space-y-2">
{(request.lines || []).map((line) => {
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
return (
<div
key={line.line_id}
className="flex items-start justify-between rounded-md border p-3"
>
<div className="flex items-center gap-2">
{lineStatus.icon}
<div>
<p className="text-xs font-medium sm:text-sm">
{line.approver_label || `${line.step_order}차 결재`} {line.approver_name || line.approver_id}
</p>
{line.approver_position && (
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
)}
{line.comment && (
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
: {line.comment}
</p>
)}
</div>
</div>
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
</div>
);
})}
</div>
</div>
{/* 승인/반려 입력 (대기 상태일 때만) */}
{pendingLineId && (
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium"> ()</p>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="결재 의견을 입력하세요"
className="min-h-[60px] text-xs sm:text-sm"
/>
</div>
)}
</div>
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
{/* 요청자만 취소 가능 (요청됨/진행 중 상태) */}
{(request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
className="h-8 text-xs"
>
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
</Button>
)}
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
{pendingLineId && (
<>
<Button
variant="destructive"
onClick={() => handleProcess("rejected")}
disabled={isProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
</Button>
<Button
onClick={() => handleProcess("approved")}
disabled={isProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 결재 대기 행 (ApprovalLine 기반)
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
const createdAt = line.request_created_at || line.created_at;
const formattedDate = createdAt
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
: "-";
return (
<button
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
onClick={onClick}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
{line.requester_name && (
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
: {line.requester_name}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="flex items-center gap-1 text-[10px] sm:text-xs">
{statusInfo.icon}
{statusInfo.label}
</span>
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
</div>
</div>
</button>
);
}
// 결재 요청 행 (ApprovalRequest 기반)
function ApprovalRequestRow({ request, onClick }: { request: ApprovalRequest; onClick: () => void }) {
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
const formattedDate = request.created_at
? new Date(request.created_at).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
: "-";
return (
<button
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
onClick={onClick}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">{request.title}</p>
{request.requester_name && (
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
: {request.requester_name}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Badge variant={statusInfo.variant} className="text-[10px]">
{statusInfo.label}
</Badge>
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
</div>
</div>
</button>
);
}
// 빈 상태 컴포넌트
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<FileCheck2 className="h-7 w-7 text-muted-foreground" />
</div>
<p className="text-muted-foreground text-sm">{message}</p>
</div>
);
}
// 메인 결재함 페이지
export default function ApprovalPage() {
const [activeTab, setActiveTab] = useState("pending");
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
const [myRequests, setMyRequests] = useState<ApprovalRequest[]>([]);
const [completedRequests, setCompletedRequests] = useState<ApprovalRequest[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 상세 모달
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
const [selectedPendingLineId, setSelectedPendingLineId] = useState<number | undefined>();
const [detailModalOpen, setDetailModalOpen] = useState(false);
const loadData = useCallback(async () => {
setIsLoading(true);
const [pendingRes, myRes, completedRes] = await Promise.all([
getMyPendingApprovals(),
// my_approvals 없이 호출 → 백엔드에서 현재 사용자의 요청 건 반환
getApprovalRequests(),
getApprovalRequests({ status: "approved" }),
]);
if (pendingRes.success && pendingRes.data) setPendingLines(pendingRes.data);
if (myRes.success && myRes.data) setMyRequests(myRes.data);
if (completedRes.success && completedRes.data) setCompletedRequests(completedRes.data);
setIsLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleOpenDetail = async (requestId: number, pendingLineId?: number) => {
const res = await getApprovalRequest(requestId);
if (res.success && res.data) {
setSelectedRequest(res.data);
setSelectedPendingLineId(pendingLineId);
setDetailModalOpen(true);
}
};
const handleOpenFromLine = async (line: ApprovalLine) => {
if (!line.request_id) return;
await handleOpenDetail(line.request_id, line.line_id);
};
return (
<div className="container mx-auto max-w-3xl p-4 sm:p-6">
<div className="mb-6">
<h1 className="text-xl font-bold sm:text-2xl"></h1>
<p className="text-muted-foreground mt-1 text-sm"> .</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4 grid w-full grid-cols-3">
<TabsTrigger value="pending" className="text-xs sm:text-sm">
{pendingLines.length > 0 && (
<Badge variant="destructive" className="ml-1 h-4 min-w-[16px] px-1 text-[10px]">
{pendingLines.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="my-requests" className="text-xs sm:text-sm">
</TabsTrigger>
<TabsTrigger value="completed" className="text-xs sm:text-sm">
</TabsTrigger>
</TabsList>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* 대기함: 내가 결재해야 할 건 */}
<TabsContent value="pending">
{pendingLines.length === 0 ? (
<EmptyState message="결재 대기 중인 건이 없습니다." />
) : (
<div className="space-y-2">
{pendingLines.map((line) => (
<ApprovalLineRow
key={line.line_id}
line={line}
onClick={() => handleOpenFromLine(line)}
/>
))}
</div>
)}
</TabsContent>
{/* 요청함: 내가 요청한 건 */}
<TabsContent value="my-requests">
{myRequests.length === 0 ? (
<EmptyState message="요청한 결재 건이 없습니다." />
) : (
<div className="space-y-2">
{myRequests.map((req) => (
<ApprovalRequestRow
key={req.request_id}
request={req}
onClick={() => handleOpenDetail(req.request_id)}
/>
))}
</div>
)}
</TabsContent>
{/* 완료함 */}
<TabsContent value="completed">
{completedRequests.length === 0 ? (
<EmptyState message="완료된 결재 건이 없습니다." />
) : (
<div className="space-y-2">
{completedRequests.map((req) => (
<ApprovalRequestRow
key={req.request_id}
request={req}
onClick={() => handleOpenDetail(req.request_id)}
/>
))}
</div>
)}
</TabsContent>
</>
)}
</Tabs>
{/* 결재 상세 모달 */}
<ApprovalDetailModal
request={selectedRequest}
open={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
onRefresh={loadData}
pendingLineId={selectedPendingLineId}
/>
</div>
);
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,52 @@
"use client";
import React, { useState, useEffect } from "react";
import { ApprovalRequestModal, type ApprovalModalEventDetail } from "./ApprovalRequestModal";
/**
*
*
* CustomEvent('open-approval-modal') ApprovalRequestModal을 .
*
* :
* window.dispatchEvent(new CustomEvent('open-approval-modal', {
* detail: {
* targetTable: 'purchase_orders',
* targetRecordId: '123',
* targetRecordData: { ... },
* definitionId: 1,
* screenId: 10,
* buttonComponentId: 'btn-approval-001',
* }
* }));
*/
export const ApprovalGlobalListener: React.FC = () => {
const [open, setOpen] = useState(false);
const [eventDetail, setEventDetail] = useState<ApprovalModalEventDetail | null>(null);
useEffect(() => {
const handleOpenModal = (e: Event) => {
const customEvent = e as CustomEvent<ApprovalModalEventDetail>;
setEventDetail(customEvent.detail || null);
setOpen(true);
};
window.addEventListener("open-approval-modal", handleOpenModal);
return () => {
window.removeEventListener("open-approval-modal", handleOpenModal);
};
}, []);
return (
<ApprovalRequestModal
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) setEventDetail(null);
}}
eventDetail={eventDetail}
/>
);
};
export default ApprovalGlobalListener;

View File

@ -0,0 +1,483 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
import { toast } from "sonner";
import { createApprovalRequest } from "@/lib/api/approval";
import { getUserList } from "@/lib/api/user";
// 결재 방식
type ApprovalMode = "sequential" | "parallel";
interface ApproverRow {
id: string;
user_id: string;
user_name: string;
position_name: string;
dept_name: string;
}
export interface ApprovalModalEventDetail {
targetTable: string;
targetRecordId: string;
targetRecordData?: Record<string, any>;
definitionId?: number;
screenId?: number;
buttonComponentId?: string;
}
interface ApprovalRequestModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
eventDetail?: ApprovalModalEventDetail | null;
}
interface UserSearchResult {
userId: string;
userName: string;
positionName?: string;
deptName?: string;
deptCode?: string;
email?: string;
user_id?: string;
user_name?: string;
position_name?: string;
dept_name?: string;
}
function genId(): string {
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
open,
onOpenChange,
eventDetail,
}) => {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 사용자 검색 상태
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
// 모달 닫힐 때 초기화
useEffect(() => {
if (!open) {
setTitle("");
setDescription("");
setApprovalMode("sequential");
setApprovers([]);
setError(null);
setSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
}
}, [open]);
// 사용자 검색 (디바운스)
const searchUsers = useCallback(async (query: string) => {
if (!query.trim() || query.trim().length < 1) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const res = await getUserList({ search: query.trim(), limit: 20 });
const data = res?.data || res || [];
const rawUsers: any[] = Array.isArray(data) ? data : [];
const users: UserSearchResult[] = rawUsers.map((u: any) => ({
userId: u.userId || u.user_id || "",
userName: u.userName || u.user_name || "",
positionName: u.positionName || u.position_name || "",
deptName: u.deptName || u.dept_name || "",
deptCode: u.deptCode || u.dept_code || "",
email: u.email || "",
}));
const existingIds = new Set(approvers.map((a) => a.user_id));
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, [approvers]);
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(() => {
searchUsers(searchQuery);
}, 300);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, [searchQuery, searchUsers]);
const addApprover = (user: UserSearchResult) => {
setApprovers((prev) => [
...prev,
{
id: genId(),
user_id: user.userId,
user_name: user.userName,
position_name: user.positionName || "",
dept_name: user.deptName || "",
},
]);
setSearchQuery("");
setSearchResults([]);
setSearchOpen(false);
};
const removeApprover = (id: string) => {
setApprovers((prev) => prev.filter((a) => a.id !== id));
};
const moveApprover = (idx: number, direction: "up" | "down") => {
setApprovers((prev) => {
const next = [...prev];
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= next.length) return prev;
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
return next;
});
};
const handleSubmit = async () => {
if (!title.trim()) {
setError("결재 제목을 입력해주세요.");
return;
}
if (approvers.length === 0) {
setError("결재자를 1명 이상 추가해주세요.");
return;
}
if (!eventDetail?.targetTable) {
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
return;
}
setIsSubmitting(true);
setError(null);
const res = await createApprovalRequest({
title: title.trim(),
description: description.trim() || undefined,
target_table: eventDetail.targetTable,
target_record_id: eventDetail.targetRecordId || undefined,
target_record_data: eventDetail.targetRecordData,
approval_mode: approvalMode,
screen_id: eventDetail.screenId,
button_component_id: eventDetail.buttonComponentId,
approvers: approvers.map((a, idx) => ({
approver_id: a.user_id,
approver_name: a.user_name,
approver_position: a.position_name || undefined,
approver_dept: a.dept_name || undefined,
approver_label:
approvalMode === "sequential"
? `${idx + 1}차 결재`
: "동시 결재",
})),
});
setIsSubmitting(false);
if (res.success) {
toast.success("결재 요청이 완료되었습니다.");
onOpenChange(false);
} else {
setError(res.error || res.message || "결재 요청에 실패했습니다.");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
{/* 결재 제목 */}
<div>
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="approval-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="결재 제목을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 결재 사유 */}
<div>
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
</Label>
<Textarea
id="approval-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="결재 사유를 입력하세요 (선택사항)"
className="min-h-[60px] text-xs sm:text-sm"
/>
</div>
{/* 결재 방식 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setApprovalMode("sequential")}
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
approvalMode === "sequential"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:bg-muted/50"
}`}
>
<ArrowDown className="h-4 w-4 shrink-0" />
<div>
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</button>
<button
type="button"
onClick={() => setApprovalMode("parallel")}
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
approvalMode === "parallel"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:bg-muted/50"
}`}
>
<Layers className="h-4 w-4 shrink-0" />
<div>
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</button>
</div>
</div>
{/* 결재자 추가 (사용자 검색) */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<span className="text-muted-foreground text-[10px]">
{approvers.length}
</span>
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSearchOpen(true);
}}
onFocus={() => setSearchOpen(true)}
placeholder="이름 또는 사번으로 검색..."
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
/>
{/* 검색 결과 드롭다운 */}
{searchOpen && searchQuery.trim() && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
{isSearching ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : searchResults.length === 0 ? (
<div className="p-4 text-center">
<p className="text-muted-foreground text-xs"> .</p>
</div>
) : (
<div className="max-h-48 overflow-y-auto">
{searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => addApprover(user)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Users className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
{user.userName}
<span className="text-muted-foreground ml-1 text-[10px]">
({user.userId})
</span>
</p>
<p className="text-muted-foreground truncate text-[10px]">
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
</p>
</div>
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
</button>
))}
</div>
)}
</div>
)}
</div>
{/* 클릭 외부 영역 닫기 */}
{searchOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setSearchOpen(false)}
/>
)}
{/* 선택된 결재자 목록 */}
{approvers.length === 0 ? (
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
</p>
) : (
<div className="mt-3 space-y-2">
{approvers.map((approver, idx) => (
<div
key={approver.id}
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
>
{/* 순서 표시 */}
{approvalMode === "sequential" ? (
<div className="flex shrink-0 flex-col items-center gap-0.5">
<button
type="button"
onClick={() => moveApprover(idx, "up")}
disabled={idx === 0}
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
>
<GripVertical className="h-3 w-3 rotate-90" />
</button>
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
{idx + 1}
</Badge>
<button
type="button"
onClick={() => moveApprover(idx, "down")}
disabled={idx === approvers.length - 1}
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
>
<GripVertical className="h-3 w-3 rotate-90" />
</button>
</div>
) : (
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
</Badge>
)}
{/* 사용자 정보 */}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{approver.user_name}
<span className="text-muted-foreground ml-1 text-[10px]">
({approver.user_id})
</span>
</p>
<p className="text-muted-foreground truncate text-[10px]">
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
</p>
</div>
{/* 제거 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => removeApprover(approver.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
{/* 결재 흐름 시각화 */}
{approvalMode === "sequential" && approvers.length > 1 && (
<p className="text-muted-foreground text-center text-[10px]">
{approvers.map((a) => a.user_name).join(" → ")}
</p>
)}
</div>
)}
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-destructive/10 rounded-md p-2">
<p className="text-destructive text-xs">{error}</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || approvers.length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
`결재 상신 (${approvers.length}명)`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApprovalRequestModal;

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection"; import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
// 🆕 제목 블록 타입 // 🆕 제목 블록 타입
interface TitleBlock { interface TitleBlock {
@ -107,6 +108,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({}); const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({}); const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
// 결재 유형 목록 상태
const [approvalDefinitions, setApprovalDefinitions] = useState<ApprovalDefinition[]>([]);
const [approvalDefinitionsLoading, setApprovalDefinitionsLoading] = useState(false);
// 🆕 그룹화 컬럼 선택용 상태 // 🆕 그룹화 컬럼 선택용 상태
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]); const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false); const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
@ -689,6 +694,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
fetchScreens(); fetchScreens();
}, [currentScreenCompanyCode]); }, [currentScreenCompanyCode]);
// 결재 유형 목록 가져오기 (approval 액션일 때)
useEffect(() => {
if (localInputs.actionType !== "approval") return;
const fetchApprovalDefinitions = async () => {
setApprovalDefinitionsLoading(true);
try {
const res = await getApprovalDefinitions({ is_active: "Y" });
if (res.success && res.data) {
setApprovalDefinitions(res.data);
}
} catch {
// 조용히 실패
} finally {
setApprovalDefinitionsLoading(false);
}
};
fetchApprovalDefinitions();
}, [localInputs.actionType]);
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때) // 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
useEffect(() => { useEffect(() => {
const fetchTableColumns = async () => { const fetchTableColumns = async () => {
@ -831,6 +855,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 고급 기능 */} {/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem> <SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem> <SelectItem value="control"> </SelectItem>
<SelectItem value="approval"> </SelectItem>
{/* 특수 기능 (필요 시 사용) */} {/* 특수 기능 (필요 시 사용) */}
<SelectItem value="barcode_scan"> </SelectItem> <SelectItem value="barcode_scan"> </SelectItem>
@ -3730,6 +3755,79 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/> />
)} )}
{/* 결재 요청(approval) 액션 설정 */}
{localInputs.actionType === "approval" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
. .
</p>
<div>
<Label htmlFor="approval-definition" className="text-xs sm:text-sm">
</Label>
<Select
value={String(component.componentConfig?.action?.approvalDefinitionId || "")}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.approvalDefinitionId", value === "none" ? null : Number(value));
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={approvalDefinitionsLoading ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> ( )</SelectItem>
{approvalDefinitions.map((def) => (
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
{def.definition_name}
{def.description ? ` - ${def.description}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
릿
</p>
</div>
<div>
<Label htmlFor="approval-target-table" className="text-xs sm:text-sm">
</Label>
<Input
id="approval-target-table"
placeholder={currentTableName || "예: purchase_orders"}
value={component.componentConfig?.action?.approvalTargetTable || currentTableName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
readOnly={!!currentTableName && !component.componentConfig?.action?.approvalTargetTable}
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
{currentTableName
? `현재 화면 테이블 "${currentTableName}" 자동 적용됨`
: "결재 대상 레코드가 저장된 테이블명"}
</p>
</div>
<div>
<Label htmlFor="approval-record-id-field" className="text-xs sm:text-sm">
ID
</Label>
<Input
id="approval-record-id-field"
placeholder="예: id, purchase_id"
value={component.componentConfig?.action?.approvalRecordIdField || "id"}
onChange={(e) => onUpdateProperty("componentConfig.action.approvalRecordIdField", e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
PK
</p>
</div>
</div>
)}
{/* 🆕 이벤트 발송 액션 설정 */} {/* 🆕 이벤트 발송 액션 설정 */}
{localInputs.actionType === "event" && ( {localInputs.actionType === "event" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4"> <div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">

View File

@ -0,0 +1,17 @@
"use client";
import React from "react";
export interface ActionTabProps {
config: any;
onChange: (key: string, value: any) => void;
children: React.ReactNode;
}
/**
* : 클릭 , , ,
* UI는 ButtonConfigPanel에서 children으로
*/
export const ActionTab: React.FC<ActionTabProps> = ({ children }) => {
return <div className="space-y-4">{children}</div>;
};

View File

@ -0,0 +1,40 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export interface BasicTabProps {
config: any;
onChange: (key: string, value: any) => void;
localText?: string;
onTextChange?: (value: string) => void;
}
export const BasicTab: React.FC<BasicTabProps> = ({
config,
onChange,
localText,
onTextChange,
}) => {
const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼");
const handleChange = (newValue: string) => {
onTextChange?.(newValue);
onChange("componentConfig.text", newValue);
};
return (
<div className="space-y-4">
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,872 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { ComponentData } from "@/types/screen";
export interface DataTabProps {
config: any;
onChange: (key: string, value: any) => void;
component: ComponentData;
allComponents: ComponentData[];
currentTableName?: string;
availableTables: Array<{ name: string; label: string }>;
mappingTargetColumns: Array<{ name: string; label: string }>;
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
currentTableColumns: Array<{ name: string; label: string }>;
mappingSourcePopoverOpen: Record<string, boolean>;
setMappingSourcePopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
mappingTargetPopoverOpen: Record<string, boolean>;
setMappingTargetPopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
activeMappingGroupIndex: number;
setActiveMappingGroupIndex: React.Dispatch<React.SetStateAction<number>>;
loadMappingColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
setMappingSourceColumnsMap: React.Dispatch<
React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>
>;
}
export const DataTab: React.FC<DataTabProps> = ({
config,
onChange,
component,
allComponents,
currentTableName,
availableTables,
mappingTargetColumns,
mappingSourceColumnsMap,
currentTableColumns,
mappingSourcePopoverOpen,
setMappingSourcePopoverOpen,
mappingTargetPopoverOpen,
setMappingTargetPopoverOpen,
activeMappingGroupIndex,
setActiveMappingGroupIndex,
loadMappingColumns,
setMappingSourceColumnsMap,
}) => {
const actionType = config.action?.type;
const onUpdateProperty = (path: string, value: any) => onChange(path, value);
if (actionType === "quickInsert") {
return (
<div className="space-y-4">
<QuickInsertConfigSection
component={component}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
</div>
);
}
if (actionType !== "transferData") {
return (
<div className="text-muted-foreground py-8 text-center text-sm">
.
</div>
);
}
return (
<div className="space-y-4">
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.sourceComponentId || ""}
onValueChange={(value) =>
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__auto__">
<div className="flex items-center gap-2">
<span className="text-xs font-medium"> ( )</span>
<span className="text-muted-foreground text-[10px]">(auto)</span>
</div>
</SelectItem>
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
&quot; &quot;
</p>
</div>
<div>
<Label htmlFor="target-type">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetType || "component"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component"> </SelectItem>
<SelectItem value="splitPanel"> </SelectItem>
<SelectItem value="screen" disabled>
( )
</SelectItem>
</SelectContent>
</Select>
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<p className="text-muted-foreground mt-1 text-[10px]">
. , .
</p>
)}
</div>
{config.action?.dataTransfer?.targetType === "component" && (
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetComponentId || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
const selectedComp = allComponents.find((c: any) => c.id === value);
if (selectedComp && (selectedComp as any)._layerId) {
onUpdateProperty(
"componentConfig.action.dataTransfer.targetLayerId",
(selectedComp as any)._layerId,
);
} else {
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">, </p>
</div>
)}
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<div>
<Label> ID ()</Label>
<Input
value={config.action?.dataTransfer?.targetComponentId || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
}
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs">
ID를 , .
</p>
</div>
)}
<div>
<Label htmlFor="transfer-mode"> </Label>
<Select
value={config.action?.dataTransfer?.mode || "append"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append"> (Append)</SelectItem>
<SelectItem value="replace"> (Replace)</SelectItem>
<SelectItem value="merge"> (Merge)</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="clear-after-transfer"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="clear-after-transfer"
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
onCheckedChange={(checked) =>
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="confirm-before-transfer"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="confirm-before-transfer"
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
onCheckedChange={(checked) =>
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
}
/>
</div>
{config.action?.dataTransfer?.confirmBeforeTransfer && (
<div>
<Label htmlFor="confirm-message"> </Label>
<Input
id="confirm-message"
placeholder="선택한 항목을 전달하시겠습니까?"
value={config.action?.dataTransfer?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center gap-2">
<Label htmlFor="min-selection" className="text-xs">
</Label>
<Input
id="min-selection"
type="number"
placeholder="0"
value={config.action?.dataTransfer?.validation?.minSelection || ""}
onChange={(e) =>
onUpdateProperty(
"componentConfig.action.dataTransfer.validation.minSelection",
parseInt(e.target.value) || 0,
)
}
className="h-8 w-20 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="max-selection" className="text-xs">
</Label>
<Input
id="max-selection"
type="number"
placeholder="제한없음"
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
onChange={(e) =>
onUpdateProperty(
"componentConfig.action.dataTransfer.validation.maxSelection",
parseInt(e.target.value) || undefined,
)
}
className="h-8 w-20 text-xs"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label> ()</Label>
<p className="text-muted-foreground text-xs">
</p>
<div className="space-y-2 rounded-md border p-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
onValueChange={(value) => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: value, fieldName: "" });
} else {
newSources[0] = { ...newSources[0], componentId: value };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">
<span className="text-muted-foreground"> </span>
</SelectItem>
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
type.includes(t),
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
, ( )
</p>
</div>
<div>
<Label htmlFor="additional-field-name" className="text-xs">
()
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{(() => {
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
const found = cols.find((c) => c.name === fieldName);
return found ? `${found.label || found.name}` : fieldName;
})()}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: "" });
} else {
newSources[0] = { ...newSources[0], fieldName: "" };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground"> ( )</span>
</CommandItem>
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
<CommandItem
key={col.name}
value={`${col.label || ""} ${col.name}`}
onSelect={() => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: col.name });
} else {
newSources[0] = { ...newSources[0], fieldName: col.name };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name
? "opacity-100"
: "opacity-0",
)}
/>
<span className="font-medium">{col.label || col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
</div>
<div className="space-y-3">
<Label> </Label>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "타겟 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
...currentMappings,
{ sourceTable: "", mappingRules: [] },
]);
setActiveMappingGroupIndex(currentMappings.length);
}}
disabled={!config.action?.dataTransfer?.targetTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-[10px]">
, . .
</p>
{!config.action?.dataTransfer?.targetTable ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> .</p>
</div>
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> . .</p>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
<div key={gIdx} className="flex items-center gap-0.5">
<Button
type="button"
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
size="sm"
className="h-6 text-[10px]"
onClick={() => setActiveMappingGroupIndex(gIdx)}
>
{group.sourceTable
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
: `그룹 ${gIdx + 1}`}
{group.mappingRules?.length > 0 && (
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
{group.mappingRules.length}
</span>
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-5 w-5"
onClick={() => {
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
mappings.splice(gIdx, 1);
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
if (activeMappingGroupIndex >= mappings.length) {
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{(() => {
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
const activeGroup = multiMappings[activeMappingGroupIndex];
if (!activeGroup) return null;
const activeSourceTable = activeGroup.sourceTable || "";
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
const activeRules: any[] = activeGroup.mappingRules || [];
const updateGroupField = (field: string, value: any) => {
const mappings = [...multiMappings];
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
};
return (
<div className="space-y-2 rounded-md border p-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{activeSourceTable
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
: "소스 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={async () => {
updateGroupField("sourceTable", table.name);
if (!mappingSourceColumnsMap[table.name]) {
const cols = await loadMappingColumns(table.name);
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
}
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-5 text-[10px]"
onClick={() => {
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
}}
disabled={!activeSourceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{!activeSourceTable ? (
<p className="text-muted-foreground text-[10px]"> .</p>
) : activeRules.length === 0 ? (
<p className="text-muted-foreground text-[10px]"> ( )</p>
) : (
activeRules.map((rule: any, rIdx: number) => {
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
return (
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
<div className="flex-1">
<Popover
open={mappingSourcePopoverOpen[popoverKeyS] || false}
onOpenChange={(open) =>
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{rule.sourceField
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
rule.sourceField
: "소스 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{activeSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
updateGroupField("mappingRules", newRules);
setMappingSourcePopoverOpen((prev) => ({
...prev,
[popoverKeyS]: false,
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-muted-foreground text-xs"></span>
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[popoverKeyT] || false}
onOpenChange={(open) =>
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
updateGroupField("mappingRules", newRules);
setMappingTargetPopoverOpen((prev) => ({
...prev,
[popoverKeyT]: false,
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7"
onClick={() => {
const newRules = [...activeRules];
newRules.splice(rIdx, 1);
updateGroupField("mappingRules", newRules);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
</div>
);
})()}
</div>
)}
</div>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3.
</p>
</div>
</div>
</div>
);
};

View File

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

View File

@ -161,13 +161,14 @@ export const useAuth = () => {
setLoading(true); setLoading(true);
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (!token || TokenManager.isTokenExpired(token)) { if (!token) {
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`); AuthLogger.log("AUTH_CHECK_FAIL", "refreshUserData: 토큰 없음");
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false); setLoading(false);
return; return;
} }
// 만료된 토큰이라도 apiClient 요청 인터셉터가 자동 갱신하므로 여기서 차단하지 않음
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작"); AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
@ -177,6 +178,10 @@ export const useAuth = () => {
}); });
try { try {
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]); const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
if (userInfo) { if (userInfo) {
@ -184,19 +189,12 @@ export const useAuth = () => {
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN"; const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
const finalAuthStatus = { const finalAuthStatus = {
isLoggedIn: authStatusData.isLoggedIn, isLoggedIn: true,
isAdmin: authStatusData.isAdmin || isAdminFromUser, isAdmin: authStatusData.isAdmin || isAdminFromUser,
}; };
setAuthStatus(finalAuthStatus); setAuthStatus(finalAuthStatus);
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`); AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
if (!finalAuthStatus.isLoggedIn) {
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
}
} else { } else {
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도"); AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
try { try {
@ -412,18 +410,19 @@ export const useAuth = () => {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && !TokenManager.isTokenExpired(token)) { if (token) {
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`); // 유효/만료 모두 refreshUserData로 처리
// apiClient 요청 인터셉터가 만료 토큰을 자동 갱신하므로 여기서 삭제하지 않음
const isExpired = TokenManager.isTokenExpired(token);
AuthLogger.log(
"AUTH_CHECK_START",
`초기 인증 확인: 토큰 ${isExpired ? "만료됨 → 갱신 시도" : "유효"} (경로: ${window.location.pathname})`,
);
setAuthStatus({ setAuthStatus({
isLoggedIn: true, isLoggedIn: true,
isAdmin: false, isAdmin: false,
}); });
refreshUserData(); refreshUserData();
} else if (token && TokenManager.isTokenExpired(token)) {
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
TokenManager.removeToken();
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
} else { } else {
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`); AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });

View File

@ -0,0 +1,453 @@
/**
* API
* : /api/approval/*
*/
// API URL 동적 설정
const getApiBaseUrl = (): string => {
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api";
}
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return "http://localhost:8080/api";
}
}
return "/api";
};
const API_BASE = getApiBaseUrl();
function getAuthToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("authToken") || sessionStorage.getItem("authToken");
}
function getAuthHeaders(): HeadersInit {
const token = getAuthToken();
const headers: HeadersInit = { "Content-Type": "application/json" };
if (token) {
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
}
return headers;
}
// ============================================================
// 공통 타입 정의
// ============================================================
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
total?: number;
page?: number;
limit?: number;
}
export interface ApprovalDefinition {
definition_id: number;
definition_name: string;
definition_name_eng?: string;
description?: string;
default_template_id?: number;
max_steps: number;
allow_self_approval: boolean;
allow_cancel: boolean;
is_active: string;
company_code: string;
created_by?: string;
created_at: string;
updated_by?: string;
updated_at: string;
}
export interface ApprovalLineTemplate {
template_id: number;
template_name: string;
description?: string;
definition_id?: number;
definition_name?: string;
is_active: string;
company_code: string;
created_by?: string;
created_at: string;
updated_by?: string;
updated_at: string;
steps?: ApprovalLineTemplateStep[];
}
export interface ApprovalLineTemplateStep {
step_id: number;
template_id: number;
step_order: number;
approver_type: "user" | "position" | "dept";
approver_user_id?: string;
approver_position?: string;
approver_dept_code?: string;
approver_label?: string;
company_code: string;
}
export interface ApprovalRequest {
request_id: number;
title: string;
description?: string;
definition_id?: number;
definition_name?: string;
target_table: string;
target_record_id: string;
target_record_data?: Record<string, any>;
status: "requested" | "in_progress" | "approved" | "rejected" | "cancelled";
current_step: number;
total_steps: number;
requester_id: string;
requester_name?: string;
requester_dept?: string;
completed_at?: string;
final_approver_id?: string;
final_comment?: string;
screen_id?: number;
button_component_id?: string;
company_code: string;
created_at: string;
updated_at: string;
lines?: ApprovalLine[];
}
export interface ApprovalLine {
line_id: number;
request_id: number;
step_order: number;
approver_id: string;
approver_name?: string;
approver_position?: string;
approver_dept?: string;
approver_label?: string;
status: "waiting" | "pending" | "approved" | "rejected" | "skipped";
comment?: string;
processed_at?: string;
company_code: string;
created_at: string;
// 요청 정보 (my-pending 조회 시 포함)
title?: string;
target_table?: string;
target_record_id?: string;
requester_name?: string;
requester_dept?: string;
request_created_at?: string;
}
export interface CreateApprovalRequestInput {
title: string;
description?: string;
definition_id?: number;
target_table: string;
target_record_id?: string;
target_record_data?: Record<string, any>;
screen_id?: number;
button_component_id?: string;
approval_mode?: "sequential" | "parallel";
approvers: {
approver_id: string;
approver_name?: string;
approver_position?: string;
approver_dept?: string;
approver_label?: string;
}[];
}
// ============================================================
// 결재 유형 (Definitions) API
// ============================================================
export async function getApprovalDefinitions(params?: {
is_active?: string;
search?: string;
}): Promise<ApiResponse<ApprovalDefinition[]>> {
try {
const qs = new URLSearchParams();
if (params?.is_active) qs.append("is_active", params.is_active);
if (params?.search) qs.append("search", params.search);
const response = await fetch(
`${API_BASE}/approval/definitions${qs.toString() ? `?${qs}` : ""}`,
{ headers: getAuthHeaders(), credentials: "include" }
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getApprovalDefinition(id: number): Promise<ApiResponse<ApprovalDefinition>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createApprovalDefinition(data: {
definition_name: string;
definition_name_eng?: string;
description?: string;
default_template_id?: number;
max_steps?: number;
allow_self_approval?: boolean;
allow_cancel?: boolean;
is_active?: string;
}): Promise<ApiResponse<ApprovalDefinition>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateApprovalDefinition(
id: number,
data: Partial<ApprovalDefinition>
): Promise<ApiResponse<ApprovalDefinition>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteApprovalDefinition(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 결재선 템플릿 (Templates) API
// ============================================================
export async function getApprovalTemplates(params?: {
definition_id?: number;
is_active?: string;
}): Promise<ApiResponse<ApprovalLineTemplate[]>> {
try {
const qs = new URLSearchParams();
if (params?.definition_id) qs.append("definition_id", String(params.definition_id));
if (params?.is_active) qs.append("is_active", params.is_active);
const response = await fetch(
`${API_BASE}/approval/templates${qs.toString() ? `?${qs}` : ""}`,
{ headers: getAuthHeaders(), credentials: "include" }
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getApprovalTemplate(id: number): Promise<ApiResponse<ApprovalLineTemplate>> {
try {
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createApprovalTemplate(data: {
template_name: string;
description?: string;
definition_id?: number;
is_active?: string;
steps?: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[];
}): Promise<ApiResponse<ApprovalLineTemplate>> {
try {
const response = await fetch(`${API_BASE}/approval/templates`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateApprovalTemplate(
id: number,
data: {
template_name?: string;
description?: string;
definition_id?: number;
is_active?: string;
steps?: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[];
}
): Promise<ApiResponse<ApprovalLineTemplate>> {
try {
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteApprovalTemplate(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 결재 요청 (Requests) API
// ============================================================
export async function getApprovalRequests(params?: {
status?: string;
target_table?: string;
target_record_id?: string;
requester_id?: string;
my_approvals?: boolean;
page?: number;
limit?: number;
}): Promise<ApiResponse<ApprovalRequest[]>> {
try {
const qs = new URLSearchParams();
if (params?.status) qs.append("status", params.status);
if (params?.target_table) qs.append("target_table", params.target_table);
if (params?.target_record_id) qs.append("target_record_id", params.target_record_id);
if (params?.requester_id) qs.append("requester_id", params.requester_id);
if (params?.my_approvals !== undefined) qs.append("my_approvals", String(params.my_approvals));
if (params?.page) qs.append("page", String(params.page));
if (params?.limit) qs.append("limit", String(params.limit));
const response = await fetch(
`${API_BASE}/approval/requests${qs.toString() ? `?${qs}` : ""}`,
{ headers: getAuthHeaders(), credentials: "include" }
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getApprovalRequest(id: number): Promise<ApiResponse<ApprovalRequest>> {
try {
const response = await fetch(`${API_BASE}/approval/requests/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createApprovalRequest(
data: CreateApprovalRequestInput
): Promise<ApiResponse<ApprovalRequest>> {
try {
const response = await fetch(`${API_BASE}/approval/requests`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function cancelApprovalRequest(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/requests/${id}/cancel`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 결재 라인 처리 (Lines) API
// ============================================================
export async function getMyPendingApprovals(): Promise<ApiResponse<ApprovalLine[]>> {
try {
const response = await fetch(`${API_BASE}/approval/my-pending`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function processApprovalLine(
lineId: number,
data: { action: "approved" | "rejected"; comment?: string }
): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/lines/${lineId}/process`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}

View File

@ -329,6 +329,11 @@ apiClient.interceptors.request.use(
const newToken = await refreshToken(); const newToken = await refreshToken();
if (newToken) { if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`; config.headers.Authorization = `Bearer ${newToken}`;
} else {
// 갱신 실패 시 인증 없는 요청을 보내면 TOKEN_MISSING 401 → 즉시 redirectToLogin 연쇄 장애
// 요청 자체를 차단하여 호출부의 try/catch에서 처리하도록 함
authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 요청 차단 (${config.url})`);
return Promise.reject(new Error("TOKEN_REFRESH_FAILED"));
} }
} }
} }

View File

@ -0,0 +1,264 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Plus, X } from "lucide-react";
import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes";
interface ConfigFieldProps<T = any> {
field: ConfigFieldDefinition<T>;
value: any;
onChange: (key: string, value: any) => void;
tableColumns?: ConfigOption[];
}
export function ConfigField<T>({
field,
value,
onChange,
tableColumns,
}: ConfigFieldProps<T>) {
const handleChange = (newValue: any) => {
onChange(field.key, newValue);
};
const renderField = () => {
switch (field.type) {
case "text":
return (
<Input
value={value ?? ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={field.placeholder}
className="h-8 text-xs"
/>
);
case "number":
return (
<Input
type="number"
value={value ?? ""}
onChange={(e) =>
handleChange(
e.target.value === "" ? undefined : Number(e.target.value),
)
}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step}
className="h-8 text-xs"
/>
);
case "switch":
return (
<Switch
checked={!!value}
onCheckedChange={handleChange}
/>
);
case "select":
return (
<Select
value={value ?? ""}
onValueChange={handleChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={field.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "textarea":
return (
<Textarea
value={value ?? ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={field.placeholder}
className="text-xs"
rows={3}
/>
);
case "color":
return (
<div className="flex items-center gap-2">
<input
type="color"
value={value ?? "#000000"}
onChange={(e) => handleChange(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
value={value ?? ""}
onChange={(e) => handleChange(e.target.value)}
placeholder="#000000"
className="h-8 flex-1 text-xs"
/>
</div>
);
case "slider":
return (
<div className="flex items-center gap-2">
<Input
type="number"
value={value ?? field.min ?? 0}
onChange={(e) => handleChange(Number(e.target.value))}
min={field.min}
max={field.max}
step={field.step}
className="h-8 w-20 text-xs"
/>
<span className="text-muted-foreground text-[10px]">
{field.min ?? 0} ~ {field.max ?? 100}
</span>
</div>
);
case "multi-select":
return (
<div className="space-y-1">
{(field.options || []).map((opt) => {
const selected = Array.isArray(value) && value.includes(opt.value);
return (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
>
<input
type="checkbox"
checked={selected}
onChange={() => {
const current = Array.isArray(value) ? [...value] : [];
if (selected) {
handleChange(current.filter((v: string) => v !== opt.value));
} else {
handleChange([...current, opt.value]);
}
}}
className="rounded"
/>
<span className="text-xs">{opt.label}</span>
</label>
);
})}
</div>
);
case "key-value": {
const entries: Array<[string, string]> = Object.entries(
(value as Record<string, string>) || {},
);
return (
<div className="space-y-1">
{entries.map(([k, v], idx) => (
<div key={idx} className="flex items-center gap-1">
<Input
value={k}
onChange={(e) => {
const newObj = { ...(value || {}) };
delete newObj[k];
newObj[e.target.value] = v;
handleChange(newObj);
}}
placeholder="키"
className="h-7 flex-1 text-xs"
/>
<Input
value={v}
onChange={(e) => {
handleChange({ ...(value || {}), [k]: e.target.value });
}}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newObj = { ...(value || {}) };
delete newObj[k];
handleChange(newObj);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
handleChange({ ...(value || {}), "": "" });
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
case "column-picker": {
const options = tableColumns || field.options || [];
return (
<Select
value={value ?? ""}
onValueChange={handleChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
default:
return null;
}
};
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">{field.label}</Label>
{field.type === "switch" && renderField()}
</div>
{field.description && (
<p className="text-muted-foreground text-[10px]">{field.description}</p>
)}
{field.type !== "switch" && renderField()}
</div>
);
}

View File

@ -0,0 +1,76 @@
"use client";
import React from "react";
import { ConfigPanelBuilderProps } from "./ConfigPanelTypes";
import { ConfigSection } from "./ConfigSection";
import { ConfigField } from "./ConfigField";
export function ConfigPanelBuilder<T extends Record<string, any>>({
config,
onChange,
sections,
presets,
tableColumns,
children,
}: ConfigPanelBuilderProps<T>) {
return (
<div className="space-y-3">
{/* 프리셋 버튼 */}
{presets && presets.length > 0 && (
<div className="border-b pb-3">
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<div className="flex flex-wrap gap-1">
{presets.map((preset, idx) => (
<button
key={idx}
onClick={() => {
Object.entries(preset.values).forEach(([key, value]) => {
onChange(key, value);
});
}}
className="rounded-full bg-muted px-2.5 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
title={preset.description}
>
{preset.label}
</button>
))}
</div>
</div>
)}
{/* 섹션 렌더링 */}
{sections.map((section) => {
if (section.condition && !section.condition(config)) {
return null;
}
const visibleFields = section.fields.filter(
(field) => !field.condition || field.condition(config),
);
if (visibleFields.length === 0) {
return null;
}
return (
<ConfigSection key={section.id} section={section}>
{visibleFields.map((field) => (
<ConfigField
key={field.key}
field={field}
value={(config as any)[field.key]}
onChange={onChange}
tableColumns={tableColumns}
/>
))}
</ConfigSection>
);
})}
{/* 커스텀 children */}
{children}
</div>
);
}

View File

@ -0,0 +1,56 @@
import React from "react";
export type ConfigFieldType =
| "text"
| "number"
| "switch"
| "select"
| "textarea"
| "color"
| "slider"
| "multi-select"
| "key-value"
| "column-picker";
export interface ConfigOption {
label: string;
value: string;
description?: string;
}
export interface ConfigFieldDefinition<T = any> {
key: string;
label: string;
type: ConfigFieldType;
description?: string;
placeholder?: string;
options?: ConfigOption[];
min?: number;
max?: number;
step?: number;
condition?: (config: T) => boolean;
disabled?: (config: T) => boolean;
}
export interface ConfigSectionDefinition<T = any> {
id: string;
title: string;
description?: string;
collapsible?: boolean;
defaultOpen?: boolean;
fields: ConfigFieldDefinition<T>[];
condition?: (config: T) => boolean;
}
export interface ConfigPanelBuilderProps<T = any> {
config: T;
onChange: (key: string, value: any) => void;
sections: ConfigSectionDefinition<T>[];
presets?: Array<{
label: string;
description?: string;
values: Partial<T>;
}>;
tableColumns?: ConfigOption[];
children?: React.ReactNode;
}

View File

@ -0,0 +1,52 @@
"use client";
import React, { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { ConfigSectionDefinition } from "./ConfigPanelTypes";
interface ConfigSectionProps {
section: ConfigSectionDefinition;
children: React.ReactNode;
}
export function ConfigSection({ section, children }: ConfigSectionProps) {
const [isOpen, setIsOpen] = useState(section.defaultOpen ?? true);
if (section.collapsible) {
return (
<div className="border-b pb-3">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center gap-1.5 py-1 text-left"
>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
<span className="text-sm font-medium">{section.title}</span>
{section.description && (
<span className="text-muted-foreground ml-auto text-[10px]">
{section.description}
</span>
)}
</button>
{isOpen && <div className="mt-2 space-y-3">{children}</div>}
</div>
);
}
return (
<div className="border-b pb-3">
<div className="mb-2">
<h4 className="text-sm font-medium">{section.title}</h4>
{section.description && (
<p className="text-muted-foreground text-[10px]">
{section.description}
</p>
)}
</div>
<div className="space-y-3">{children}</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,530 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import { ComponentRendererProps } from "@/types/component";
import { ApprovalStepConfig } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import {
getApprovalRequests,
getApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
import {
Check,
X,
Clock,
SkipForward,
Loader2,
FileCheck,
ChevronDown,
ChevronUp,
ArrowRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface ApprovalStepComponentProps extends ComponentRendererProps {}
interface ApprovalStepData {
request: ApprovalRequest;
lines: ApprovalLine[];
approvalMode: "sequential" | "parallel";
}
const STATUS_CONFIG = {
approved: {
label: "승인",
icon: Check,
bgColor: "bg-emerald-100",
borderColor: "border-emerald-500",
textColor: "text-emerald-700",
iconColor: "text-emerald-600",
dotColor: "bg-emerald-500",
},
rejected: {
label: "반려",
icon: X,
bgColor: "bg-rose-100",
borderColor: "border-rose-500",
textColor: "text-rose-700",
iconColor: "text-rose-600",
dotColor: "bg-rose-500",
},
pending: {
label: "결재 대기",
icon: Clock,
bgColor: "bg-amber-50",
borderColor: "border-amber-400",
textColor: "text-amber-700",
iconColor: "text-amber-500",
dotColor: "bg-amber-400",
},
waiting: {
label: "대기",
icon: Clock,
bgColor: "bg-muted",
borderColor: "border-border",
textColor: "text-muted-foreground",
iconColor: "text-muted-foreground",
dotColor: "bg-muted-foreground/40",
},
skipped: {
label: "건너뜀",
icon: SkipForward,
bgColor: "bg-muted/50",
borderColor: "border-border/50",
textColor: "text-muted-foreground/70",
iconColor: "text-muted-foreground/50",
dotColor: "bg-muted-foreground/30",
},
} as const;
const REQUEST_STATUS_CONFIG = {
requested: { label: "요청됨", color: "text-blue-600", bg: "bg-blue-50" },
in_progress: { label: "진행 중", color: "text-amber-600", bg: "bg-amber-50" },
approved: { label: "승인 완료", color: "text-emerald-600", bg: "bg-emerald-50" },
rejected: { label: "반려", color: "text-rose-600", bg: "bg-rose-50" },
cancelled: { label: "취소", color: "text-muted-foreground", bg: "bg-muted" },
} as const;
/**
*
*
*/
export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
formData,
...props
}) => {
const componentConfig = (component.componentConfig || {}) as ApprovalStepConfig;
const {
targetTable,
targetRecordIdField,
displayMode = "horizontal",
showComment = true,
showTimestamp = true,
showDept = true,
compact = false,
} = componentConfig;
const [stepData, setStepData] = useState<ApprovalStepData | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const [error, setError] = useState<string | null>(null);
const targetRecordId = targetRecordIdField && formData
? String(formData[targetRecordIdField] || "")
: "";
const fetchApprovalData = useCallback(async () => {
if (isDesignMode || !targetTable || !targetRecordId) return;
setLoading(true);
setError(null);
try {
const res = await getApprovalRequests({
target_table: targetTable,
target_record_id: targetRecordId,
limit: 1,
});
if (res.success && res.data && res.data.length > 0) {
const latestRequest = res.data[0];
const detailRes = await getApprovalRequest(latestRequest.request_id);
if (detailRes.success && detailRes.data) {
const request = detailRes.data;
const lines = request.lines || [];
const approvalMode =
(request.target_record_data?.approval_mode as "sequential" | "parallel") || "sequential";
setStepData({ request, lines, approvalMode });
}
} else {
setStepData(null);
}
} catch (err) {
setError("결재 정보를 불러올 수 없습니다.");
} finally {
setLoading(false);
}
}, [isDesignMode, targetTable, targetRecordId]);
useEffect(() => {
fetchApprovalData();
}, [fetchApprovalData]);
// 디자인 모드용 샘플 데이터
useEffect(() => {
if (isDesignMode) {
setStepData({
request: {
request_id: 0,
title: "결재 요청 샘플",
target_table: "sample_table",
target_record_id: "1",
status: "in_progress",
current_step: 2,
total_steps: 3,
requester_id: "admin",
requester_name: "홍길동",
requester_dept: "개발팀",
company_code: "SAMPLE",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
lines: [
{
line_id: 1, request_id: 0, step_order: 1,
approver_id: "user1", approver_name: "김부장", approver_position: "부장", approver_dept: "경영지원팀",
status: "approved", comment: "확인했습니다.",
processed_at: new Date(Date.now() - 86400000).toISOString(),
company_code: "SAMPLE", created_at: new Date().toISOString(),
},
{
line_id: 2, request_id: 0, step_order: 2,
approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀",
status: "pending",
company_code: "SAMPLE", created_at: new Date().toISOString(),
},
{
line_id: 3, request_id: 0, step_order: 3,
approver_id: "user3", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀",
status: "waiting",
company_code: "SAMPLE", created_at: new Date().toISOString(),
},
],
approvalMode: "sequential",
});
}
}, [isDesignMode]);
const componentStyle: React.CSSProperties = {
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: `${component.style?.width || 500}px`,
height: "auto",
minHeight: `${component.style?.height || 80}px`,
zIndex: component.style?.positionZ || 1,
cursor: isDesignMode ? "pointer" : "default",
border: isSelected ? "2px solid #3b82f6" : "none",
};
const handleClick = (e: React.MouseEvent) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
};
const domProps = filterDOMProps(props);
const formatDate = (dateStr?: string | null) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
if (!isDesignMode && !targetTable) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center justify-center rounded-md border border-dashed border-border p-4 text-xs text-muted-foreground">
.
</div>
</div>
);
}
if (loading) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</div>
</div>
);
}
if (error) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error}
</div>
</div>
);
}
if (!stepData) {
if (!isDesignMode && !targetRecordId) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
<FileCheck className="h-3.5 w-3.5" />
.
</div>
</div>
);
}
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
<FileCheck className="h-3.5 w-3.5" />
.
</div>
</div>
);
}
const { request, lines, approvalMode } = stepData;
const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested;
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="rounded-md border border-border bg-card">
{/* 헤더 - 요약 */}
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
onClick={(e) => {
e.stopPropagation();
if (!isDesignMode) setExpanded((prev) => !prev);
}}
>
<div className="flex items-center gap-2">
<FileCheck className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{request.title}</span>
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
{reqStatus.label}
</span>
{approvalMode === "parallel" && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
</span>
)}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-[10px]">
{request.current_step}/{request.total_steps}
</span>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</div>
</button>
{/* 스테퍼 영역 */}
<div className={cn("border-t border-border", compact ? "px-2 py-1.5" : "px-3 py-2.5")}>
{displayMode === "horizontal" ? (
<HorizontalStepper
lines={lines}
approvalMode={approvalMode}
compact={compact}
showDept={showDept}
/>
) : (
<VerticalStepper
lines={lines}
approvalMode={approvalMode}
compact={compact}
showDept={showDept}
showComment={showComment}
showTimestamp={showTimestamp}
formatDate={formatDate}
/>
)}
</div>
{/* 확장 영역 - 상세 정보 */}
{expanded && (
<div className="border-t border-border px-3 py-2">
<div className="space-y-2">
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<span>: {request.requester_name || request.requester_id}</span>
{request.requester_dept && <span>: {request.requester_dept}</span>}
<span>: {formatDate(request.created_at)}</span>
</div>
{displayMode === "horizontal" && lines.length > 0 && (
<div className="mt-1.5 space-y-1">
{lines.map((line) => {
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
return (
<div key={line.line_id} className="flex items-start gap-2 text-[11px]">
<span className={cn("mt-0.5 inline-block h-2 w-2 shrink-0 rounded-full", sc.dotColor)} />
<span className="font-medium">{line.approver_name || line.approver_id}</span>
<span className={cn("font-medium", sc.textColor)}>{sc.label}</span>
{showTimestamp && line.processed_at && (
<span className="text-muted-foreground">{formatDate(line.processed_at)}</span>
)}
{showComment && line.comment && (
<span className="text-muted-foreground">- {line.comment}</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
};
/* ========== 가로형 스테퍼 ========== */
interface StepperProps {
lines: ApprovalLine[];
approvalMode: "sequential" | "parallel";
compact: boolean;
showDept: boolean;
showComment?: boolean;
showTimestamp?: boolean;
formatDate?: (d?: string | null) => string;
}
const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compact, showDept }) => {
if (lines.length === 0) {
return <div className="py-1 text-center text-[11px] text-muted-foreground"> </div>;
}
return (
<div className="flex items-center gap-0 overflow-x-auto">
{lines.map((line, idx) => {
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
const StatusIcon = sc.icon;
const isLast = idx === lines.length - 1;
return (
<React.Fragment key={line.line_id}>
<div className="flex shrink-0 flex-col items-center gap-0.5">
{/* 아이콘 원 */}
<div
className={cn(
"flex items-center justify-center rounded-full border-2 transition-all",
sc.bgColor,
sc.borderColor,
compact ? "h-6 w-6" : "h-8 w-8"
)}
>
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
</div>
{/* 결재자 이름 */}
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
{line.approver_name || line.approver_id}
</span>
{/* 직급/부서 */}
{showDept && !compact && (line.approver_position || line.approver_dept) && (
<span className="max-w-[70px] truncate text-center text-[9px] text-muted-foreground">
{line.approver_position || line.approver_dept}
</span>
)}
</div>
{/* 연결선 */}
{!isLast && (
<div className="mx-1 flex shrink-0 items-center">
{approvalMode === "parallel" ? (
<div className="flex h-[1px] w-4 items-center border-t border-dashed border-muted-foreground/40" />
) : (
<ArrowRight className="h-3 w-3 text-muted-foreground/40" />
)}
</div>
)}
</React.Fragment>
);
})}
</div>
);
};
/* ========== 세로형 스테퍼 ========== */
const VerticalStepper: React.FC<StepperProps> = ({
lines,
approvalMode,
compact,
showDept,
showComment,
showTimestamp,
formatDate,
}) => {
if (lines.length === 0) {
return <div className="py-1 text-center text-[11px] text-muted-foreground"> </div>;
}
return (
<div className="space-y-0">
{lines.map((line, idx) => {
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
const StatusIcon = sc.icon;
const isLast = idx === lines.length - 1;
return (
<div key={line.line_id} className="flex gap-3">
{/* 타임라인 바 */}
<div className="flex flex-col items-center">
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full border-2",
sc.bgColor,
sc.borderColor,
compact ? "h-5 w-5" : "h-7 w-7"
)}
>
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3.5 w-3.5")} />
</div>
{!isLast && (
<div
className={cn(
"w-[2px] flex-1",
approvalMode === "parallel"
? "border-l border-dashed border-muted-foreground/30"
: "bg-muted-foreground/20",
compact ? "min-h-[12px]" : "min-h-[20px]"
)}
/>
)}
</div>
{/* 결재자 정보 */}
<div className={cn("pb-2", compact ? "pb-1" : "pb-3")}>
<div className="flex items-center gap-2">
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
{line.approver_name || line.approver_id}
</span>
{showDept && (line.approver_position || line.approver_dept) && (
<span className="text-[10px] text-muted-foreground">
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
</span>
)}
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-medium", sc.bgColor, sc.textColor)}>
{sc.label}
</span>
</div>
{showTimestamp && line.processed_at && formatDate && (
<div className="mt-0.5 text-[10px] text-muted-foreground">
{formatDate(line.processed_at)}
</div>
)}
{showComment && line.comment && (
<div className="mt-0.5 text-[10px] text-muted-foreground">
&quot;{line.comment}&quot;
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export const ApprovalStepWrapper: React.FC<ApprovalStepComponentProps> = (props) => {
return <ApprovalStepComponent {...props} />;
};

View File

@ -0,0 +1,369 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Check, ChevronsUpDown, Table2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { ApprovalStepConfig } from "./types";
export interface ApprovalStepConfigPanelProps {
config: ApprovalStepConfig;
onChange: (config: Partial<ApprovalStepConfig>) => void;
tables?: any[];
allTables?: any[];
screenTableName?: string;
tableColumns?: any[];
}
export const ApprovalStepConfigPanel: React.FC<ApprovalStepConfigPanelProps> = ({
config,
onChange,
screenTableName,
}) => {
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [availableColumns, setAvailableColumns] = useState<Array<{ columnName: string; label: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
const handleChange = (key: keyof ApprovalStepConfig, value: any) => {
onChange({ [key]: value });
};
const targetTableName = config.targetTable || screenTableName;
// 테이블 목록 가져오기 - tableTypeApi 사용 (다른 ConfigPanel과 동일)
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
}))
);
} catch (error) {
console.error("테이블 목록 가져오기 실패:", error);
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
// 선택된 테이블의 컬럼 로드 - tableManagementApi 사용 (다른 ConfigPanel과 동일)
useEffect(() => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
const fetchColumns = async () => {
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data) {
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
if (columns && Array.isArray(columns)) {
setAvailableColumns(
columns.map((col: any) => ({
columnName: col.columnName || col.column_name || col.name,
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
}))
);
}
}
} catch (error) {
console.error("컬럼 목록 가져오기 실패:", error);
setAvailableColumns([]);
} finally {
setLoadingColumns(false);
}
};
fetchColumns();
}, [targetTableName]);
const handleTableChange = (newTableName: string) => {
if (newTableName === targetTableName) return;
handleChange("targetTable", newTableName);
handleChange("targetRecordIdField", "");
setTableComboboxOpen(false);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
<div className="space-y-6">
{/* 대상 테이블 선택 - TableListConfigPanel과 동일한 Combobox */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]">
.
</p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: targetTableName
? availableTables.find((t) => t.tableName === targetTableName)?.displayName ||
targetTableName
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
targetTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-gray-400">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{screenTableName && targetTableName !== screenTableName && (
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
<span className="text-[10px] text-amber-700">
({screenTableName})
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
onClick={() => handleTableChange(screenTableName)}
>
</Button>
</div>
)}
</div>
</div>
{/* 레코드 ID 필드 선택 - 동일한 Combobox 패턴 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]">
PK .
</p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<Label className="text-xs"> ID </Label>
{targetTableName ? (
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={columnComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingColumns}
>
<span className="truncate">
{loadingColumns
? "컬럼 로딩 중..."
: config.targetRecordIdField
? availableColumns.find((c) => c.columnName === config.targetRecordIdField)?.label ||
config.targetRecordIdField
: "컬럼 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableColumns.map((col) => (
<CommandItem
key={col.columnName}
value={`${col.columnName} ${col.label}`}
onSelect={() => {
handleChange("targetRecordIdField", col.columnName);
setColumnComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.targetRecordIdField === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{col.label}</span>
{col.label !== col.columnName && (
<span className="text-[10px] text-gray-400">{col.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<p className="text-muted-foreground text-[10px]">
.
</p>
)}
</div>
</div>
{/* 표시 모드 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]">
.
</p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.displayMode || "horizontal"}
onValueChange={(v) => handleChange("displayMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="표시 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="vertical"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 옵션 체크박스들 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center gap-2">
<Checkbox
id="showDept"
checked={config.showDept !== false}
onCheckedChange={(checked) => handleChange("showDept", !!checked)}
/>
<Label htmlFor="showDept" className="text-xs font-normal">
/
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="showComment"
checked={config.showComment !== false}
onCheckedChange={(checked) => handleChange("showComment", !!checked)}
/>
<Label htmlFor="showComment" className="text-xs font-normal">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="showTimestamp"
checked={config.showTimestamp !== false}
onCheckedChange={(checked) => handleChange("showTimestamp", !!checked)}
/>
<Label htmlFor="showTimestamp" className="text-xs font-normal">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="compact"
checked={config.compact || false}
onCheckedChange={(checked) => handleChange("compact", !!checked)}
/>
<Label htmlFor="compact" className="text-xs font-normal">
( )
</Label>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,20 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2ApprovalStepDefinition } from "./index";
import { ApprovalStepComponent } from "./ApprovalStepComponent";
/**
* ApprovalStep
*
*/
export class ApprovalStepRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ApprovalStepDefinition;
render(): React.ReactElement {
return <ApprovalStepComponent {...this.props} renderer={this} />;
}
}
ApprovalStepRenderer.registerSelf();

View File

@ -0,0 +1,42 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
import { ApprovalStepConfigPanel } from "./ApprovalStepConfigPanel";
import { ApprovalStepConfig } from "./types";
/**
* ApprovalStep
*
*/
export const V2ApprovalStepDefinition = createComponentDefinition({
id: "v2-approval-step",
name: "결재 단계",
nameEng: "ApprovalStep Component",
description: "결재 요청의 각 단계별 상태를 스테퍼 형태로 시각화합니다",
category: ComponentCategory.DISPLAY,
webType: "text",
component: ApprovalStepWrapper,
defaultConfig: {
targetTable: "",
targetRecordIdField: "",
displayMode: "horizontal",
showComment: true,
showTimestamp: true,
showDept: true,
compact: false,
},
defaultSize: { width: 500, height: 100 },
configPanel: ApprovalStepConfigPanel,
icon: "GitBranchPlus",
tags: ["결재", "승인", "단계", "스테퍼", "워크플로우"],
version: "1.0.0",
author: "개발팀",
});
export type { ApprovalStepConfig } from "./types";
export { ApprovalStepComponent } from "./ApprovalStepComponent";
export { ApprovalStepRenderer } from "./ApprovalStepRenderer";

View File

@ -0,0 +1,17 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* ApprovalStep
*
*/
export interface ApprovalStepConfig extends ComponentConfig {
targetTable?: string;
targetRecordIdField?: string;
displayMode?: "horizontal" | "vertical";
showComment?: boolean;
showTimestamp?: boolean;
showDept?: boolean;
compact?: boolean;
}

View File

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

View File

@ -0,0 +1,90 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { SplitPanelLayoutConfig } from "../types";
export interface CommonConfigTabProps {
config: SplitPanelLayoutConfig;
onChange?: (key: string, value: any) => void;
updateConfig: (updates: Partial<SplitPanelLayoutConfig>) => void;
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
}
export const CommonConfigTab: React.FC<CommonConfigTabProps> = ({
config,
updateConfig,
updateRightPanel,
}) => {
const relationshipType = config.rightPanel?.relation?.type || "detail";
return (
<div className="space-y-6">
{/* 관계 타입 선택 */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
<Select
value={relationshipType}
onValueChange={(value: "join" | "detail") => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
});
}}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 방식 선택">
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="detail">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> / </span>
</div>
</SelectItem>
<SelectItem value="join">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> / </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"></h3>
<div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label>
<Slider
value={[config.splitRatio || 30]}
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
min={20}
max={80}
step={5}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.resizable ?? true}
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.autoLoad ?? true}
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,606 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown, ChevronRight, Database, Link2, Move, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "../types";
import { PanelInlineComponent } from "../types";
import { ColumnInfo } from "@/types/screen";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { SortableColumnRow, ScreenSelector } from "./SharedComponents";
export interface LeftPanelConfigTabProps {
config: SplitPanelLayoutConfig;
onChange: (config: SplitPanelLayoutConfig) => void;
updateLeftPanel: (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => void;
screenTableName?: string;
allTables: any[];
leftTableOpen: boolean;
setLeftTableOpen: (open: boolean) => void;
localTitles: { left: string; right: string };
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
isUserEditing: boolean;
setIsUserEditing: (v: boolean) => void;
leftTableColumns: ColumnInfo[];
entityJoinColumns: Record<string, {
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
}>;
menuObjid?: number;
}
export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
config,
onChange,
updateLeftPanel,
screenTableName,
allTables,
leftTableOpen,
setLeftTableOpen,
localTitles,
setLocalTitles,
setIsUserEditing,
leftTableColumns,
entityJoinColumns,
menuObjid,
}) => {
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
return (
<div className="space-y-4">
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ()</h3>
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Popover open={leftTableOpen} onOpenChange={setLeftTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={leftTableOpen}
className="h-9 w-full justify-between text-xs"
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{config.leftPanel?.tableName
? allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.tableLabel ||
allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.displayName ||
config.leftPanel?.tableName
: "테이블을 선택하세요"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
.
</CommandEmpty>
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => {
updateLeftPanel({ tableName: screenTableName, columns: [] });
setLeftTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.leftPanel?.tableName === screenTableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
{allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.tableLabel ||
allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.displayName ||
screenTableName}
</CommandItem>
</CommandGroup>
)}
<CommandGroup heading="전체 테이블">
{allTables
.filter((t) => (t.tableName || t.table_name) !== screenTableName)
.map((table) => {
const tableName = table.tableName || table.table_name;
const displayName = table.tableLabel || table.displayName || tableName;
return (
<CommandItem
key={tableName}
value={tableName}
onSelect={() => {
updateLeftPanel({ tableName, columns: [] });
setLeftTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.leftPanel?.tableName === tableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{displayName}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.leftPanel?.tableName && config.leftPanel?.tableName !== screenTableName && (
<p className="text-[10px] text-muted-foreground">
.
</p>
)}
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={localTitles.left}
onChange={(e) => {
setIsUserEditing(true);
setLocalTitles((prev) => ({ ...prev, left: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateLeftPanel({ title: localTitles.left });
}}
placeholder="좌측 패널 제목"
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={config.leftPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 모드 선택">
{(config.leftPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.leftPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
{config.leftPanel?.displayMode === "custom" && (
<p className="text-xs text-amber-600">
.
</p>
)}
</div>
{config.leftPanel?.displayMode === "custom" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0}, {comp.position?.y || 0}) | : {comp.size?.width || 0}x{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() => {
const updatedComponents = (config.leftPanel?.components || []).filter(
(c: PanelInlineComponent) => c.id !== comp.id
);
onChange({
...config,
leftPanel: {
...config.leftPanel,
components: updatedComponents,
},
});
}}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{config.leftPanel?.displayMode !== "custom" && (() => {
const selectedColumns = config.leftPanel?.columns || [];
const filteredTableColumns = leftTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
const handleLeftDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
updateLeftPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
}
}
};
return (
<div className="space-y-2">
<Label className="text-xs font-medium"> ({selectedColumns.length} )</Label>
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
{leftTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
) : (
<>
{selectedColumns.length > 0 && (
<DndContext collisionDetection={closestCenter} onDragEnd={handleLeftDragEnd}>
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{selectedColumns.map((col, index) => {
const colInfo = leftTableColumns.find((c) => c.columnName === col.name);
const isNumeric = colInfo && (
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
);
return (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={index}
isNumeric={!!isNumeric}
isEntityJoin={!!(col as any).isEntityJoin}
onLabelChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], label: value };
updateLeftPanel({ columns: newColumns });
}}
onWidthChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], width: value };
updateLeftPanel({ columns: newColumns });
}}
onFormatChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateLeftPanel({ columns: newColumns });
}}
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
<span className="text-muted-foreground text-[10px]"> </span>
</div>
)}
<div className="space-y-0.5">
{unselectedColumns.map((column) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
onClick={() => {
updateLeftPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
</div>
))}
</div>
{(() => {
const leftTable = config.leftPanel?.tableName || screenTableName;
const joinData = leftTable ? entityJoinColumns[leftTable] : null;
if (!joinData || joinData.joinTables.length === 0) return null;
return joinData.joinTables.map((joinTable, tableIndex) => {
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return false;
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
});
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
return (
<details key={`join-${tableIndex}`} className="group">
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
{addedCount > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount} </span>
)}
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length} </span>
</summary>
<div className="space-y-0.5 pt-1">
{joinColumnsToShow.map((column, colIndex) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return null;
return (
<div
key={colIndex}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
onClick={() => {
updateLeftPanel({
columns: [...selectedColumns, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: leftTable!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
</div>
);
})}
{joinColumnsToShow.length === 0 && (
<p className="px-2 py-1 text-[10px] text-gray-400"> </p>
)}
</div>
</details>
);
});
})()}
</>
)}
</div>
</div>
);
})()}
</div>
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
<DataFilterConfigPanel
tableName={config.leftPanel?.tableName || screenTableName}
columns={leftTableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
}) as any,
)}
config={config.leftPanel?.dataFilter}
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
menuObjid={menuObjid}
/>
</div>
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<div className="grid grid-cols-4 gap-2">
<div className="flex items-center gap-1">
<Checkbox
id="left-showSearch"
checked={config.leftPanel?.showSearch ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showSearch: !!checked })}
/>
<label htmlFor="left-showSearch" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showAdd"
checked={config.leftPanel?.showAdd ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showAdd: !!checked })}
/>
<label htmlFor="left-showAdd" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showEdit"
checked={config.leftPanel?.showEdit ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showEdit: !!checked })}
/>
<label htmlFor="left-showEdit" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showDelete"
checked={config.leftPanel?.showDelete ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showDelete: !!checked })}
/>
<label htmlFor="left-showDelete" className="text-xs"></label>
</div>
</div>
{config.leftPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={config.leftPanel?.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateLeftPanel({
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: value },
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.leftPanel?.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={config.leftPanel?.addButton?.modalScreenId}
onChange={(screenId) =>
updateLeftPanel({
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.leftPanel?.addButton?.buttonLabel || ""}
onChange={(e) =>
updateLeftPanel({
addButton: {
...config.leftPanel?.addButton,
enabled: true,
mode: config.leftPanel?.addButton?.mode || "auto",
buttonLabel: e.target.value || undefined,
},
})
}
placeholder="추가"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{(config.leftPanel?.showEdit ?? true) && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={config.leftPanel?.editButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateLeftPanel({
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: value },
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.leftPanel?.editButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={config.leftPanel?.editButton?.modalScreenId}
onChange={(screenId) =>
updateLeftPanel({
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.leftPanel?.editButton?.buttonLabel || ""}
onChange={(e) =>
updateLeftPanel({
editButton: {
...config.leftPanel?.editButton,
enabled: true,
mode: config.leftPanel?.editButton?.mode || "auto",
buttonLabel: e.target.value || undefined,
},
})
}
placeholder="수정"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,801 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown, ChevronRight, Link2, Move, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "../types";
import { PanelInlineComponent } from "../types";
import { ColumnInfo, TableInfo } from "@/types/screen";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { SortableColumnRow, ScreenSelector, GroupByColumnsSelector } from "./SharedComponents";
export interface RightPanelConfigTabProps {
config: SplitPanelLayoutConfig;
onChange: (config: SplitPanelLayoutConfig) => void;
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
relationshipType: "join" | "detail";
localTitles: { left: string; right: string };
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
setIsUserEditing: (v: boolean) => void;
rightTableOpen: boolean;
setRightTableOpen: (open: boolean) => void;
availableRightTables: TableInfo[];
rightTableColumns: ColumnInfo[];
entityJoinColumns: Record<string, {
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
}>;
menuObjid?: number;
renderAdditionalTabs?: () => React.ReactNode;
}
export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
config,
onChange,
updateRightPanel,
relationshipType,
localTitles,
setLocalTitles,
setIsUserEditing,
rightTableOpen,
setRightTableOpen,
availableRightTables,
rightTableColumns,
entityJoinColumns,
menuObjid,
renderAdditionalTabs,
}) => {
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
return (
<div className="space-y-4">
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ({relationshipType === "detail" ? "선택 시 표시" : "연관 목록"})</h3>
<div className="space-y-2">
<Label> </Label>
<Input
value={localTitles.right}
onChange={(e) => {
setIsUserEditing(true);
setLocalTitles((prev) => ({ ...prev, right: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateRightPanel({ title: localTitles.right });
}}
placeholder="우측 패널 제목"
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightTableOpen}
className="w-full justify-between"
>
{config.rightPanel?.tableName || "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableRightTables.map((table) => {
const tableName = (table as any).tableName || (table as any).table_name;
return (
<CommandItem
key={tableName}
value={`${(table as any).displayName || ""} ${tableName}`}
onSelect={() => {
updateRightPanel({ tableName });
setRightTableOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
{(table as any).displayName || tableName}
{(table as any).displayName && <span className="ml-2 text-xs text-gray-500">({tableName})</span>}
</CommandItem>
);
})}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={config.rightPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 모드 선택">
{(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
{config.rightPanel?.displayMode === "custom" && (
<p className="text-xs text-amber-600">
.
</p>
)}
</div>
{config.rightPanel?.displayMode === "custom" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0}, {comp.position?.y || 0}) | : {comp.size?.width || 0}x{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() => {
const updatedComponents = (config.rightPanel?.components || []).filter(
(c: PanelInlineComponent) => c.id !== comp.id
);
onChange({
...config,
rightPanel: {
...config.rightPanel,
components: updatedComponents,
},
});
}}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{(config.rightPanel?.displayMode || "list") === "list" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="number"
min="1"
max="10"
value={config.rightPanel?.summaryColumnCount ?? 3}
onChange={(e) => {
const value = parseInt(e.target.value) || 3;
updateRightPanel({ summaryColumnCount: value });
}}
className="bg-white"
/>
<p className="text-xs text-gray-500"> (기본: 3개)</p>
</div>
<div className="flex items-center justify-between space-x-2">
<div className="flex-1">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
</div>
<Checkbox
checked={config.rightPanel?.summaryShowLabel ?? true}
onCheckedChange={(checked) => {
updateRightPanel({ summaryShowLabel: checked as boolean });
}}
/>
</div>
</div>
)}
{config.rightPanel?.displayMode !== "custom" && (() => {
const selectedColumns = config.rightPanel?.columns || [];
const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
const handleRightDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
}
}
};
return (
<div className="space-y-2">
<Label className="text-xs font-medium"> ({selectedColumns.length} )</Label>
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
{rightTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs"> </p>
) : (
<>
{selectedColumns.length > 0 && (
<DndContext collisionDetection={closestCenter} onDragEnd={handleRightDragEnd}>
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{selectedColumns.map((col, index) => {
const colInfo = rightTableColumns.find((c) => c.columnName === col.name);
const isNumeric = colInfo && (
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
);
return (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={index}
isNumeric={!!isNumeric}
isEntityJoin={!!(col as any).isEntityJoin}
onLabelChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], label: value };
updateRightPanel({ columns: newColumns });
}}
onWidthChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], width: value };
updateRightPanel({ columns: newColumns });
}}
onFormatChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateRightPanel({ columns: newColumns });
}}
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInSummary: checked };
updateRightPanel({ columns: newColumns });
}}
onShowInDetailChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateRightPanel({ columns: newColumns });
}}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
<span className="text-muted-foreground text-[10px]"> </span>
</div>
)}
<div className="space-y-0.5">
{unselectedColumns.map((column) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
onClick={() => {
updateRightPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
</div>
))}
</div>
{(() => {
const rightTable = config.rightPanel?.tableName;
const joinData = rightTable ? entityJoinColumns[rightTable] : null;
if (!joinData || joinData.joinTables.length === 0) return null;
return joinData.joinTables.map((joinTable, tableIndex) => {
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return false;
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
});
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
return (
<details key={`join-${tableIndex}`} className="group">
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
{addedCount > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount} </span>
)}
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length} </span>
</summary>
<div className="space-y-0.5 pt-1">
{joinColumnsToShow.map((column, colIndex) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return null;
return (
<div
key={colIndex}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
onClick={() => {
updateRightPanel({
columns: [...selectedColumns, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: rightTable!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
</div>
);
})}
{joinColumnsToShow.length === 0 && (
<p className="px-2 py-1 text-[10px] text-gray-400"> </p>
)}
</div>
</details>
);
});
})()}
</>
)}
</div>
</div>
);
})()}
</div>
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
<DataFilterConfigPanel
tableName={config.rightPanel?.tableName}
columns={rightTableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
}) as any,
)}
config={config.rightPanel?.dataFilter}
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
menuObjid={menuObjid}
/>
</div>
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
checked={config.rightPanel?.deduplication?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateRightPanel({
deduplication: {
enabled: true,
groupByColumn: "",
keepStrategy: "latest",
sortColumn: "start_date",
},
});
} else {
updateRightPanel({ deduplication: undefined });
}
}}
/>
</div>
{config.rightPanel?.deduplication?.enabled && (
<div className="space-y-3 border-l-2 pl-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deduplication?.groupByColumn || ""}
onValueChange={(value) =>
updateRightPanel({
deduplication: { ...config.rightPanel?.deduplication!, groupByColumn: value },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deduplication?.keepStrategy || "latest"}
onValueChange={(value: any) =>
updateRightPanel({
deduplication: { ...config.rightPanel?.deduplication!, keepStrategy: value },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest"> ( )</SelectItem>
<SelectItem value="earliest"> ( )</SelectItem>
<SelectItem value="current_date"> ( )</SelectItem>
<SelectItem value="base_price"> </SelectItem>
</SelectContent>
</Select>
</div>
{(config.rightPanel?.deduplication?.keepStrategy === "latest" ||
config.rightPanel?.deduplication?.keepStrategy === "earliest") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deduplication?.sortColumn || ""}
onValueChange={(value) =>
updateRightPanel({
deduplication: { ...config.rightPanel?.deduplication!, sortColumn: value },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
</div>
{/* 수정 버튼 설정 */}
{config.rightPanel?.showEdit && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.editButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton,
enabled: config.rightPanel?.editButton?.enabled ?? true,
mode: value,
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.rightPanel?.editButton?.mode === "modal" && (
<GroupByColumnsSelector
tableName={config.rightPanel?.tableName}
selectedColumns={config.rightPanel?.editButton?.groupByColumns || []}
onChange={(columns) => {
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
groupByColumns: columns,
enabled: config.rightPanel?.editButton?.enabled ?? true,
mode: config.rightPanel?.editButton?.mode || "auto",
},
});
}}
/>
)}
{config.rightPanel?.editButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.rightPanel?.editButton?.modalScreenId}
onChange={(screenId) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
modalScreenId: screenId,
},
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.editButton?.buttonLabel || ""}
onChange={(e) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
buttonLabel: e.target.value || undefined,
enabled: config.rightPanel?.editButton?.enabled ?? true,
mode: config.rightPanel?.editButton?.mode || "auto",
},
})
}
placeholder="수정"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 추가 버튼 설정 */}
{config.rightPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton,
mode: value,
enabled: true,
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.rightPanel?.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.rightPanel?.addButton?.modalScreenId}
onChange={(screenId) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
modalScreenId: screenId,
},
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.addButton?.buttonLabel || "추가"}
onChange={(e) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
buttonLabel: e.target.value,
enabled: true,
mode: config.rightPanel?.addButton?.mode || "auto",
},
})
}
placeholder="추가"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 삭제 버튼 설정 */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<Switch
checked={config.rightPanel?.deleteButton?.enabled ?? true}
onCheckedChange={(checked) => {
updateRightPanel({
deleteButton: {
enabled: checked,
buttonLabel: config.rightPanel?.deleteButton?.buttonLabel,
buttonVariant: config.rightPanel?.deleteButton?.buttonVariant,
},
});
}}
/>
</div>
{(config.rightPanel?.deleteButton?.enabled ?? true) && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.deleteButton?.buttonLabel || ""}
placeholder="삭제"
onChange={(e) => {
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
buttonLabel: e.target.value || undefined,
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
},
});
}}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deleteButton?.buttonVariant || "ghost"}
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
buttonVariant: value,
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ghost">Ghost ()</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="destructive">Destructive ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.deleteButton?.confirmMessage || ""}
placeholder="정말 삭제하시겠습니까?"
onChange={(e) => {
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
confirmMessage: e.target.value || undefined,
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
},
});
}}
className="h-8 text-xs"
/>
</div>
</div>
)}
</div>
{/* 추가 탭 */}
{renderAdditionalTabs && (
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs">
</p>
{renderAdditionalTabs()}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,256 @@
"use client";
import React, { useState, useEffect } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
}: {
id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
index: number;
isNumeric: boolean;
isEntityJoin?: boolean;
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void;
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
<Input
value={col.label}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="라벨"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
{isNumeric && (
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
/>
,
</label>
)}
{onShowInSummaryChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
<input
type="checkbox"
checked={col.showInSummary !== false}
onChange={(e) => onShowInSummaryChange(e.target.checked)}
className="h-3 w-3"
/>
</label>
)}
{onShowInDetailChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
<input
type="checkbox"
checked={col.showInDetail !== false}
onChange={(e) => onShowInDetailChange(e.target.checked)}
className="h-3 w-3"
/>
</label>
)}
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" />
</Button>
</div>
);
}
export const GroupByColumnsSelector: React.FC<{
tableName?: string;
selectedColumns: string[];
onChange: (columns: string[]) => void;
}> = ({ tableName, selectedColumns, onChange }) => {
const [columns, setColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!tableName) {
setColumns([]);
return;
}
const loadColumns = async () => {
setLoading(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data && response.data.columns) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 정보 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadColumns();
}, [tableName]);
const toggleColumn = (columnName: string) => {
const newSelection = selectedColumns.includes(columnName)
? selectedColumns.filter((c) => c !== columnName)
: [...selectedColumns, columnName];
onChange(newSelection);
};
if (!tableName) {
return (
<div className="rounded-md border border-dashed p-3">
<p className="text-muted-foreground text-center text-xs"> </p>
</div>
);
}
return (
<div>
<Label className="text-xs"> </Label>
{loading ? (
<div className="rounded-md border p-3">
<p className="text-muted-foreground text-center text-xs"> ...</p>
</div>
) : columns.length === 0 ? (
<div className="rounded-md border border-dashed p-3">
<p className="text-muted-foreground text-center text-xs"> </p>
</div>
) : (
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-3">
{columns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`groupby-${col.columnName}`}
checked={selectedColumns.includes(col.columnName)}
onCheckedChange={() => toggleColumn(col.columnName)}
/>
<label htmlFor={`groupby-${col.columnName}`} className="flex-1 cursor-pointer text-xs">
{col.columnLabel || col.columnName}
<span className="text-muted-foreground ml-1">({col.columnName})</span>
</label>
</div>
))}
</div>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"}
<br />
</p>
</div>
);
};
export const ScreenSelector: React.FC<{
value?: number;
onChange: (screenId?: number) => void;
}> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadScreens = async () => {
setLoading(true);
try {
const { screenApi } = await import("@/lib/api/screen");
const response = await screenApi.getScreens({ page: 1, size: 1000 });
setScreens(
response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
const selectedScreen = screens.find((s) => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-6 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-auto">
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
onSelect={() => {
onChange(screen.screenId === value ? undefined : screen.screenId);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,125 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Table2, Check, ChevronsUpDown } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
export interface BasicConfigPanelProps {
config: any;
onChange: (key: string, value: any) => void;
screenTableName?: string;
availableTables: Array<{ tableName: string; displayName: string }>;
loadingTables: boolean;
targetTableName: string | undefined;
tableComboboxOpen: boolean;
onTableComboboxOpenChange: (open: boolean) => void;
onTableChange: (newTableName: string) => void;
}
/**
* 패널: 테이블 ,
*/
export const BasicConfigPanel: React.FC<BasicConfigPanelProps> = ({
config,
screenTableName,
availableTables,
loadingTables,
targetTableName,
tableComboboxOpen,
onTableComboboxOpenChange,
onTableChange,
}) => {
return (
<div className="space-y-6">
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]">
. .
</p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover open={tableComboboxOpen} onOpenChange={onTableComboboxOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: targetTableName
? availableTables.find((t) => t.tableName === targetTableName)?.displayName || targetTableName
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => onTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
targetTableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-gray-400">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{screenTableName && targetTableName && targetTableName !== screenTableName && (
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
<span className="text-[10px] text-amber-700">
({screenTableName})
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
onClick={() => onTableChange(screenTableName)}
>
</Button>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,534 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ColumnConfig } from "../types";
import { Database, Link2, GripVertical, X, Check, ChevronsUpDown, Lock, Unlock } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function SortableColumnRow({
id,
col,
index,
isEntityJoin,
onLabelChange,
onWidthChange,
onRemove,
}: {
id: string;
col: ColumnConfig;
index: number;
isEntityJoin?: boolean;
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
<Input
value={col.displayName || col.columnName}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="표시명"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" />
</Button>
</div>
);
}
export interface ColumnsConfigPanelProps {
config: any;
onChange: (key: string, value: any) => void;
screenTableName?: string;
targetTableName: string | undefined;
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
tableColumns?: any[];
entityJoinColumns: {
availableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}>;
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
description?: string;
}>;
}>;
};
entityDisplayConfigs: Record<
string,
{
sourceColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
joinColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
selectedColumns: string[];
separator: string;
}
>;
onAddColumn: (columnName: string) => void;
onAddEntityColumn: (joinColumn: {
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}) => void;
onRemoveColumn: (columnName: string) => void;
onUpdateColumn: (columnName: string, updates: Partial<ColumnConfig>) => void;
onToggleEntityDisplayColumn: (columnName: string, selectedColumn: string) => void;
onUpdateEntityDisplaySeparator: (columnName: string, separator: string) => void;
}
/**
* 패널: 컬럼 , Entity , DnD
*/
export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
config,
onChange,
screenTableName,
targetTableName,
availableColumns,
tableColumns,
entityJoinColumns,
entityDisplayConfigs,
onAddColumn,
onAddEntityColumn,
onRemoveColumn,
onUpdateColumn,
onToggleEntityDisplayColumn,
onUpdateEntityDisplaySeparator,
}) => {
const handleChange = (key: string, value: any) => {
onChange(key, value);
};
return (
<div className="space-y-6">
{/* 엔티티 컬럼 표시 설정 섹션 */}
{config.columns?.some((col: ColumnConfig) => col.isEntityJoin) && (
<div className="space-y-3">
{config.columns
?.filter((col: ColumnConfig) => col.isEntityJoin && col.entityDisplayConfig)
.map((column: ColumnConfig) => (
<div key={column.columnName} className="space-y-2">
<div className="mb-2">
<span className="truncate text-xs font-medium" style={{ fontSize: "12px" }}>
{column.displayName || column.columnName}
</span>
</div>
{entityDisplayConfigs[column.columnName] ? (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={entityDisplayConfigs[column.columnName].separator}
onChange={(e) => onUpdateEntityDisplaySeparator(column.columnName, e.target.value)}
className="h-6 w-full text-xs"
style={{ fontSize: "12px" }}
placeholder=" - "
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
<div className="py-2 text-center text-xs text-gray-400">
.
{!column.entityDisplayConfig?.joinTable && (
<p className="mt-1 text-[10px]">
.
</p>
)}
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-6 w-full justify-between text-xs"
style={{ fontSize: "12px" }}
>
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<CommandGroup
heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}
>
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
<CommandItem
key={`source-${col.columnName}`}
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
? "opacity-100"
: "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
<CommandItem
key={`join-${col.columnName}`}
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
? "opacity-100"
: "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{!column.entityDisplayConfig?.joinTable &&
entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
.
.
</div>
)}
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<div className="flex flex-wrap gap-1 rounded bg-gray-50 p-2 text-xs">
{entityDisplayConfigs[column.columnName].selectedColumns.map((colName, idx) => (
<React.Fragment key={colName}>
<Badge variant="secondary" className="text-xs">
{colName}
</Badge>
{idx < entityDisplayConfigs[column.columnName].selectedColumns.length - 1 && (
<span className="text-gray-400">
{entityDisplayConfigs[column.columnName].separator}
</span>
)}
</React.Fragment>
))}
</div>
</div>
)}
</div>
) : (
<div className="py-4 text-center text-xs text-gray-400"> ...</div>
)}
</div>
))}
</div>
)}
{!targetTableName ? (
<div className="space-y-3">
<div className="text-center text-gray-500">
<p> .</p>
<p className="text-sm"> .</p>
</div>
</div>
) : availableColumns.length === 0 ? (
<div className="space-y-3">
<div className="text-center text-gray-500">
<p> </p>
<p className="text-sm"> .</p>
<p className="mt-2 text-xs text-blue-600"> : {screenTableName}</p>
</div>
</div>
) : (
<>
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
{availableColumns.map((column) => {
const isAdded = config.columns?.some((c: ColumnConfig) => c.columnName === column.columnName);
return (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isAdded && "bg-primary/10",
)}
onClick={() => {
if (isAdded) {
handleChange(
"columns",
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
);
} else {
onAddColumn(column.columnName);
}
}}
>
<Checkbox
checked={isAdded}
onCheckedChange={() => {
if (isAdded) {
handleChange(
"columns",
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
);
} else {
onAddColumn(column.columnName);
}
}}
className="pointer-events-none h-3.5 w-3.5"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.label || column.columnName}</span>
{isAdded && (
<button
type="button"
title={
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
? "편집 잠금 (클릭하여 해제)"
: "편집 가능 (클릭하여 잠금)"
}
className={cn(
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:bg-muted",
)}
onClick={(e) => {
e.stopPropagation();
const currentCol = config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName);
if (currentCol) {
onUpdateColumn(column.columnName, {
editable: currentCol.editable === false ? undefined : false,
});
}
}}
>
{config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false ? (
<Lock className="h-3 w-3" />
) : (
<Unlock className="h-3 w-3" />
)}
</button>
)}
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
{column.input_type || column.dataType}
</span>
</div>
);
})}
</div>
</div>
{entityJoinColumns.joinTables.length > 0 && (
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold">Entity </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="space-y-3">
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<Badge variant="outline" className="text-[10px]">
{joinTable.currentDisplayColumn}
</Badge>
</div>
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
const isAlreadyAdded = config.columns?.some(
(col: ColumnConfig) => col.columnName === matchingJoinColumn?.joinAlias,
);
if (!matchingJoinColumn) return null;
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
isAlreadyAdded && "bg-blue-100",
)}
onClick={() => {
if (isAlreadyAdded) {
handleChange(
"columns",
config.columns?.filter(
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
) || [],
);
} else {
onAddEntityColumn(matchingJoinColumn);
}
}}
>
<Checkbox
checked={isAlreadyAdded}
onCheckedChange={() => {
if (isAlreadyAdded) {
handleChange(
"columns",
config.columns?.filter(
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
) || [],
);
} else {
onAddEntityColumn(matchingJoinColumn);
}
}}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-blue-400">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> ({config.columns.length} )</h3>
<p className="text-muted-foreground text-[10px]">
/
</p>
</div>
<hr className="border-border" />
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const columns = [...(config.columns || [])];
const oldIndex = columns.findIndex((c: ColumnConfig) => c.columnName === active.id);
const newIndex = columns.findIndex((c: ColumnConfig) => c.columnName === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(columns, oldIndex, newIndex);
reordered.forEach((col: ColumnConfig, idx: number) => {
col.order = idx;
});
handleChange("columns", reordered);
}
}}
>
<SortableContext
items={(config.columns || []).map((c: ColumnConfig) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(config.columns || []).map((column: ColumnConfig, idx: number) => {
const resolvedLabel =
column.displayName && column.displayName !== column.columnName
? column.displayName
: availableColumns.find((c) => c.columnName === column.columnName)?.label ||
column.displayName ||
column.columnName;
const colWithLabel = { ...column, displayName: resolvedLabel };
return (
<SortableColumnRow
key={column.columnName}
id={column.columnName}
col={colWithLabel}
index={idx}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => onUpdateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => onUpdateColumn(column.columnName, { width: value })}
onRemove={() => onRemoveColumn(column.columnName)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,290 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
export interface OptionsConfigPanelProps {
config: any;
onChange: (key: string, value: any) => void;
onNestedChange: (parentKey: string, childKey: string, value: any) => void;
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
screenTableName?: string;
}
/**
* 패널: 툴바, , , ,
*/
export const OptionsConfigPanel: React.FC<OptionsConfigPanelProps> = ({
config,
onChange,
onNestedChange,
availableColumns,
screenTableName,
}) => {
return (
<div className="space-y-6">
{/* 툴바 버튼 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="showEditMode"
checked={config.toolbar?.showEditMode ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showEditMode", checked)}
/>
<Label htmlFor="showEditMode" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showExcel"
checked={config.toolbar?.showExcel ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showExcel", checked)}
/>
<Label htmlFor="showExcel" className="text-xs">
Excel
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPdf"
checked={config.toolbar?.showPdf ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showPdf", checked)}
/>
<Label htmlFor="showPdf" className="text-xs">
PDF
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showCopy"
checked={config.toolbar?.showCopy ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showCopy", checked)}
/>
<Label htmlFor="showCopy" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showSearch"
checked={config.toolbar?.showSearch ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showSearch", checked)}
/>
<Label htmlFor="showSearch" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFilter"
checked={config.toolbar?.showFilter ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showFilter", checked)}
/>
<Label htmlFor="showFilter" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showRefresh"
checked={config.toolbar?.showRefresh ?? false}
onCheckedChange={(checked) => onNestedChange("toolbar", "showRefresh", checked)}
/>
<Label htmlFor="showRefresh" className="text-xs">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPaginationRefresh"
checked={config.toolbar?.showPaginationRefresh ?? true}
onCheckedChange={(checked) => onNestedChange("toolbar", "showPaginationRefresh", checked)}
/>
<Label htmlFor="showPaginationRefresh" className="text-xs">
()
</Label>
</div>
</div>
</div>
{/* 체크박스 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
</div>
<hr className="border-border" />
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxEnabled"
checked={config.checkbox?.enabled ?? true}
onCheckedChange={(checked) => onNestedChange("checkbox", "enabled", checked)}
/>
<Label htmlFor="checkboxEnabled"> </Label>
</div>
{config.checkbox?.enabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxSelectAll"
checked={config.checkbox?.selectAll ?? true}
onCheckedChange={(checked) => onNestedChange("checkbox", "selectAll", checked)}
/>
<Label htmlFor="checkboxSelectAll"> </Label>
</div>
<div className="space-y-1">
<Label htmlFor="checkboxPosition" className="text-xs">
</Label>
<select
id="checkboxPosition"
value={config.checkbox?.position || "left"}
onChange={(e) => onNestedChange("checkbox", "position", e.target.value)}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="left"></option>
<option value="right"></option>
</select>
</div>
</>
)}
</div>
</div>
{/* 기본 정렬 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="defaultSortColumn" className="text-xs">
</Label>
<select
id="defaultSortColumn"
value={config.defaultSort?.columnName || ""}
onChange={(e) => {
if (e.target.value) {
onChange("defaultSort", {
columnName: e.target.value,
direction: config.defaultSort?.direction || "asc",
});
} else {
onChange("defaultSort", undefined);
}
}}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value=""> </option>
{availableColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</option>
))}
</select>
</div>
{config.defaultSort?.columnName && (
<div className="space-y-1">
<Label htmlFor="defaultSortDirection" className="text-xs">
</Label>
<select
id="defaultSortDirection"
value={config.defaultSort?.direction || "asc"}
onChange={(e) =>
onChange("defaultSort", {
...config.defaultSort,
columnName: config.defaultSort?.columnName || "",
direction: e.target.value as "asc" | "desc",
})
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="asc"> (AZ, 19)</option>
<option value="desc"> (ZA, 91)</option>
</select>
</div>
)}
</div>
</div>
{/* 가로 스크롤 및 컬럼 고정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
</div>
<hr className="border-border" />
<div className="flex items-center space-x-2">
<Checkbox
id="horizontalScrollEnabled"
checked={config.horizontalScroll?.enabled}
onCheckedChange={(checked) => onNestedChange("horizontalScroll", "enabled", checked)}
/>
<Label htmlFor="horizontalScrollEnabled"> </Label>
</div>
{config.horizontalScroll?.enabled && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="maxVisibleColumns" className="text-sm">
</Label>
<Input
id="maxVisibleColumns"
type="number"
value={config.horizontalScroll?.maxVisibleColumns || 8}
onChange={(e) =>
onNestedChange("horizontalScroll", "maxVisibleColumns", parseInt(e.target.value) || 8)
}
min={3}
max={20}
placeholder="8"
className="h-8"
/>
<div className="text-xs text-gray-500"> </div>
</div>
</div>
)}
</div>
{/* 데이터 필터링 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<hr className="border-border" />
<DataFilterConfigPanel
tableName={config.selectedTable || screenTableName}
columns={availableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.label || col.columnName,
dataType: col.dataType,
input_type: col.input_type,
}) as any,
)}
config={config.dataFilter}
onConfigChange={(dataFilter) => onChange("dataFilter", dataFilter)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,129 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
export interface StyleConfigPanelProps {
config: any;
onChange: (key: string, value: any) => void;
onNestedChange: (parentKey: string, childKey: string, value: any) => void;
}
/**
* 패널: 테이블 (theme, headerStyle, rowHeight )
*/
export const StyleConfigPanel: React.FC<StyleConfigPanelProps> = ({
config,
onNestedChange,
}) => {
const tableStyle = config.tableStyle || {};
return (
<div className="space-y-6">
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="tableTheme" className="text-xs">
</Label>
<select
id="tableTheme"
value={tableStyle.theme || "default"}
onChange={(e) =>
onNestedChange("tableStyle", "theme", e.target.value as "default" | "striped" | "bordered" | "minimal")
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="default"></option>
<option value="striped"></option>
<option value="bordered"></option>
<option value="minimal"></option>
</select>
</div>
<div className="space-y-1">
<Label htmlFor="headerStyle" className="text-xs">
</Label>
<select
id="headerStyle"
value={tableStyle.headerStyle || "default"}
onChange={(e) =>
onNestedChange("tableStyle", "headerStyle", e.target.value as "default" | "dark" | "light")
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="default"></option>
<option value="dark"></option>
<option value="light"></option>
</select>
</div>
<div className="space-y-1">
<Label htmlFor="rowHeight" className="text-xs">
</Label>
<select
id="rowHeight"
value={tableStyle.rowHeight || "normal"}
onChange={(e) =>
onNestedChange("tableStyle", "rowHeight", e.target.value as "compact" | "normal" | "comfortable")
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="compact"></option>
<option value="normal"></option>
<option value="comfortable"></option>
</select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="alternateRows"
checked={tableStyle.alternateRows ?? false}
onCheckedChange={(checked) => onNestedChange("tableStyle", "alternateRows", checked)}
/>
<Label htmlFor="alternateRows" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hoverEffect"
checked={tableStyle.hoverEffect ?? true}
onCheckedChange={(checked) => onNestedChange("tableStyle", "hoverEffect", checked)}
/>
<Label htmlFor="hoverEffect" className="text-xs">
</Label>
</div>
<div className="space-y-1">
<Label htmlFor="borderStyle" className="text-xs">
</Label>
<select
id="borderStyle"
value={tableStyle.borderStyle || "light"}
onChange={(e) =>
onNestedChange("tableStyle", "borderStyle", e.target.value as "none" | "light" | "heavy")
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="none"></option>
<option value="light"></option>
<option value="heavy"></option>
</select>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,367 +0,0 @@
"use client";
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
import { LayoutDefinition } from "@/types/layout";
import { LayoutRegistry } from "../LayoutRegistry";
import { ComponentData, LayoutComponent } from "@/types/screen";
import { LayoutZone } from "@/types/layout";
import React from "react";
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
/**
*
*
* :
* 1.
* 2. static layoutDefinition을
* 3. import하면
*/
export class AutoRegisteringLayoutRenderer {
protected props: LayoutRendererProps;
constructor(props: LayoutRendererProps) {
this.props = props;
}
/**
* -
*/
static readonly layoutDefinition: LayoutDefinition;
/**
* -
*/
render(): React.ReactElement {
throw new Error("render() method must be implemented by subclass");
}
/**
* .
*/
getLayoutContainerStyle(): React.CSSProperties {
const { layout, style: propStyle } = this.props;
const style: React.CSSProperties = {
width: layout.size.width,
height: layout.size.height,
position: "relative",
overflow: "hidden",
...propStyle,
};
// 레이아웃 커스텀 스타일 적용
if (layout.style) {
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
}
return style;
}
/**
* CSS .
*/
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
const cssStyle: React.CSSProperties = {};
if (componentStyle.backgroundColor) {
cssStyle.backgroundColor = componentStyle.backgroundColor;
}
if (componentStyle.borderColor) {
cssStyle.borderColor = componentStyle.borderColor;
}
if (componentStyle.borderWidth) {
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
}
if (componentStyle.borderStyle) {
cssStyle.borderStyle = componentStyle.borderStyle;
}
if (componentStyle.borderRadius) {
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
}
return cssStyle;
}
/**
* .
*/
getZoneChildren(zoneId: string): ComponentData[] {
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
}
/**
* .
*/
renderZone(
zone: LayoutZone,
zoneChildren: ComponentData[] = [],
additionalProps: Record<string, any> = {},
): React.ReactElement {
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
// 존 스타일 계산 - 항상 구역 경계 표시
const zoneStyle: React.CSSProperties = {
position: "relative",
// 구역 경계 시각화 - 항상 표시
border: "1px solid #e2e8f0",
borderRadius: "6px",
backgroundColor: "rgba(248, 250, 252, 0.5)",
transition: "all 0.2s ease",
...this.getZoneStyle(zone),
...additionalProps.style,
};
// 디자인 모드일 때 더 강조된 스타일
if (isDesignMode) {
zoneStyle.border = "2px dashed #cbd5e1";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
}
// 호버 효과를 위한 추가 스타일
const dropZoneStyle: React.CSSProperties = {
minHeight: isDesignMode ? "60px" : "40px",
borderRadius: "4px",
display: "flex",
flexDirection: "column",
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
color: "#64748b",
fontSize: "12px",
transition: "all 0.2s ease",
padding: "8px",
position: "relative",
};
return (
<div
key={zone.id}
className={`layout-zone ${additionalProps.className || ""}`}
style={zoneStyle}
onClick={(e) => {
e.stopPropagation();
onZoneClick?.(zone.id, e);
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
const componentData = e.dataTransfer.getData("application/json");
if (componentData) {
try {
const component = JSON.parse(componentData);
onComponentDrop?.(zone.id, component, e);
} catch (error) {
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
}
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(241, 245, 249, 0.8)"
: "rgba(248, 250, 252, 0.5)";
e.currentTarget.style.boxShadow = "none";
}}
{...additionalProps}
>
{/* 존 라벨 */}
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
opacity: isDesignMode ? 1 : 0.7,
}}
>
{zone.name || zone.id}
</div>
{/* 드롭존 */}
<div className="drop-zone" style={dropZoneStyle}>
{zoneChildren.length > 0 ? (
zoneChildren.map((child) => (
<DynamicComponentRenderer
key={child.id}
component={child}
allComponents={this.props.allComponents}
isDesignMode={isDesignMode}
/>
))
) : (
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
</div>
)}
</div>
</div>
);
}
/**
* .
*/
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
const style: React.CSSProperties = {};
if (zone.size) {
if (zone.size.width) {
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
}
if (zone.size.height) {
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
}
if (zone.size.minWidth) {
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
}
if (zone.size.minHeight) {
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
}
}
return style;
}
/**
*
*/
private static registeredLayouts = new Set<string>();
/**
*
*/
static registerSelf(): void {
const definition = this.layoutDefinition;
if (!definition) {
console.error(`${this.name}: layoutDefinition이 정의되지 않았습니다.`);
return;
}
if (this.registeredLayouts.has(definition.id)) {
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
return;
}
try {
// 레지스트리에 등록
LayoutRegistry.registerLayout(definition);
this.registeredLayouts.add(definition.id);
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
// 개발 모드에서 추가 정보 출력
if (process.env.NODE_ENV === "development") {
console.log(`📦 ${definition.id}:`, {
name: definition.name,
category: definition.category,
zones: definition.defaultZones?.length || 0,
tags: definition.tags?.join(", ") || "none",
});
}
} catch (error) {
console.error(`${definition.id} 레이아웃 등록 실패:`, error);
}
}
/**
* ( Hot Reload용)
*/
static unregisterSelf(): void {
const definition = this.layoutDefinition;
if (definition && this.registeredLayouts.has(definition.id)) {
LayoutRegistry.unregisterLayout(definition.id);
this.registeredLayouts.delete(definition.id);
console.log(`🗑️ 등록 해제: ${definition.id}`);
}
}
/**
* Hot Reload ( )
*/
static reloadSelf(): void {
if (process.env.NODE_ENV === "development") {
this.unregisterSelf();
this.registerSelf();
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
}
}
/**
*
*/
static getRegisteredLayouts(): string[] {
return Array.from(this.registeredLayouts);
}
/**
*
*/
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
const definition = this.layoutDefinition;
if (!definition) {
return {
isValid: false,
errors: ["layoutDefinition이 정의되지 않았습니다."],
warnings: [],
};
}
const errors: string[] = [];
const warnings: string[] = [];
// 필수 필드 검사
if (!definition.id) errors.push("ID가 필요합니다.");
if (!definition.name) errors.push("이름이 필요합니다.");
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
if (!definition.category) errors.push("카테고리가 필요합니다.");
// 권장사항 검사
if (!definition.description || definition.description.length < 10) {
warnings.push("설명은 10자 이상 권장됩니다.");
}
if (!definition.defaultZones || definition.defaultZones.length === 0) {
warnings.push("기본 존 정의가 권장됩니다.");
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
}
/**
* Hot Module Replacement
*/
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
// HMR API가 있는 경우 등록
if ((module as any).hot) {
(module as any).hot.accept();
// 글로벌 Hot Reload 함수 등록
(window as any).__reloadLayout__ = (layoutId: string) => {
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
console.log(`🔄 Available layouts for reload:`, layouts);
// TODO: 특정 레이아웃만 리로드하는 로직 구현
};
}
}

View File

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

View File

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

View File

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

View File

@ -56,7 +56,8 @@ export type ButtonActionType =
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간) | "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT) | "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등) | "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
| "approval"; // 결재 요청
/** /**
* *
@ -451,6 +452,9 @@ export class ButtonActionExecutor {
case "event": case "event":
return await this.handleEvent(config, context); return await this.handleEvent(config, context);
case "approval":
return this.handleApproval(config, context);
default: default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`); console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false; return false;
@ -7598,6 +7602,37 @@ export class ButtonActionExecutor {
return false; return false;
} }
} }
/**
*
*/
private static handleApproval(config: ButtonActionConfig, context: ButtonActionContext): boolean {
try {
const selectedRow = context.selectedRowsData?.[0] || context.formData || {};
const targetTable = (config as any).approvalTargetTable || context.tableName || "";
const recordIdField = (config as any).approvalRecordIdField || "id";
const targetRecordId = selectedRow?.[recordIdField] || "";
window.dispatchEvent(
new CustomEvent("open-approval-modal", {
detail: {
targetTable,
targetRecordId: targetRecordId ? String(targetRecordId) : "",
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
definitionId: (config as any).approvalDefinitionId || undefined,
screenId: context.screenId ? Number(context.screenId) : undefined,
buttonComponentId: context.formData?.buttonId,
},
}),
);
return true;
} catch (error) {
console.error("[handleApproval] 결재 요청 오류:", error);
toast.error("결재 요청 모달을 열 수 없습니다.");
return false;
}
}
} }
/** /**
@ -7722,4 +7757,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
event: { event: {
type: "event", type: "event",
}, },
approval: {
type: "approval",
},
}; };

View File

@ -976,6 +976,35 @@ export class ImprovedButtonActionExecutor {
return await this.executeTransferDataAction(buttonConfig, formData, context); return await this.executeTransferDataAction(buttonConfig, formData, context);
} }
// 결재 요청 모달 열기
if (buttonConfig.actionType === "approval") {
const actionConfig = (buttonConfig as any).componentConfig?.action || buttonConfig;
const selectedRow = context.selectedRows?.[0] || context.formData || formData || {};
const targetTable = actionConfig.approvalTargetTable || "";
const recordIdField = actionConfig.approvalRecordIdField || "id";
const targetRecordId = selectedRow?.[recordIdField] || "";
window.dispatchEvent(
new CustomEvent("open-approval-modal", {
detail: {
targetTable,
targetRecordId: targetRecordId ? String(targetRecordId) : "",
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
definitionId: actionConfig.approvalDefinitionId || undefined,
screenId: context.screenId ? Number(context.screenId) : undefined,
buttonComponentId: context.buttonId,
},
}),
);
return {
success: true,
message: "결재 요청 모달이 열렸습니다",
executionTime: performance.now() - startTime,
data: { actionType: "approval", targetTable, targetRecordId },
};
}
// 기존 액션들 (임시 구현) // 기존 액션들 (임시 구현)
const result = { const result = {
success: true, success: true,

View File

@ -0,0 +1,171 @@
/**
* E2E
* 실행: npx tsx scripts/approval-flow-test.ts
*/
import { chromium } from "playwright";
const BASE_URL = "http://localhost:9771";
const LOGIN_ID = "wace";
const LOGIN_PW = "1234";
const FALLBACK_PW = "qlalfqjsgh11"; // 마스터 패스워드 (1234 실패 시)
async function main() {
const results: string[] = [];
const consoleErrors: string[] = [];
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 800 }, // 데스크톱 뷰 (사이드바 표시)
});
const page = await context.newPage();
// 콘솔 에러 수집
page.on("console", (msg) => {
const type = msg.type();
if (type === "error") {
const text = msg.text();
consoleErrors.push(text);
}
});
try {
// 1. http://localhost:9771 이동
results.push("=== 1. http://localhost:9771 이동 ===");
await page.goto(BASE_URL, { waitUntil: "networkidle", timeout: 15000 });
results.push("OK: 페이지 로드 완료");
// 2. 로그인 여부 확인
results.push("\n=== 2. 로그인 상태 확인 ===");
const isLoginPage = await page.locator('#userId, input[name="userId"]').count() > 0;
if (isLoginPage) {
results.push("로그인 페이지 감지됨. 로그인 시도...");
await page.fill('#userId', LOGIN_ID);
await page.fill('#password', LOGIN_PW);
await page.click('button[type="submit"]');
await page.waitForTimeout(4000);
// 여전히 로그인 페이지면 마스터 패스워드로 재시도
const stillLoginPage = await page.locator('#userId').count() > 0;
if (stillLoginPage) {
results.push("1234 로그인 실패. 마스터 패스워드로 재시도...");
await page.fill('#userId', LOGIN_ID);
await page.fill('#password', FALLBACK_PW);
await page.click('button[type="submit"]');
await page.waitForTimeout(4000);
}
results.push("로그인 폼 제출 완료");
} else {
results.push("이미 로그인된 상태로 판단 (로그인 폼 없음)");
}
// 3. 사용자 프로필 아바타 클릭 (사이드바 하단)
results.push("\n=== 3. 사용자 프로필 아바타 클릭 ===");
await page.waitForTimeout(2000);
// 사이드바 하단 사용자 프로필 버튼 (border-t border-slate-200 내부의 button)
const sidebarAvatarBtn = page.locator('aside div.border-t.border-slate-200 button').first();
let avatarClicked = false;
if ((await sidebarAvatarBtn.count()) > 0) {
try {
// force: true - Next.js dev overlay가 클릭을 가로채는 경우 우회
await sidebarAvatarBtn.click({ timeout: 5000, force: true });
avatarClicked = true;
results.push("OK: 사이드바 하단 아바타 클릭 완료");
await page.waitForTimeout(500); // 드롭다운 열림 대기
} catch (e) {
results.push(`WARN: 사이드바 아바타 클릭 실패 - ${(e as Error).message}`);
}
}
if (!avatarClicked) {
// 모바일 헤더 아바타 또는 fallback
const headerAvatar = page.locator('header button:has(div.rounded-full)').first();
if ((await headerAvatar.count()) > 0) {
await headerAvatar.click({ force: true });
avatarClicked = true;
results.push("OK: 헤더 아바타 클릭 (모바일 뷰?)");
}
}
if (!avatarClicked) {
results.push("WARN: 아바타 클릭 실패. 직접 /admin/approvalBox로 이동하여 페이지 검증");
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
}
await page.waitForTimeout(1000);
// 4. "결재함" 메뉴 확인 (드롭다운이 열린 경우)
results.push("\n=== 4. 결재함 메뉴 확인 ===");
const approvalMenuItem = page.locator('[role="menuitem"]:has-text("결재함"), [data-radix-collection-item]:has-text("결재함")').first();
const hasApprovalMenu = (await approvalMenuItem.count()) > 0;
if (hasApprovalMenu) {
results.push("OK: 결재함 메뉴가 보입니다.");
} else {
results.push("FAIL: 결재함 메뉴를 찾을 수 없습니다.");
}
// 5. 결재함 메뉴 클릭
results.push("\n=== 5. 결재함 메뉴 클릭 ===");
if (hasApprovalMenu) {
await approvalMenuItem.click({ force: true });
await page.waitForTimeout(3000);
results.push("OK: 결재함 메뉴 클릭 완료");
} else if (!avatarClicked) {
results.push("(직접 이동으로 스킵 - 이미 approvalBox 페이지)");
} else {
results.push("WARN: 드롭다운에서 결재함 메뉴 미발견. 직접 이동...");
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
await page.waitForTimeout(2000);
}
// 6. /admin/approvalBox 페이지 렌더링 확인
results.push("\n=== 6. /admin/approvalBox 페이지 확인 ===");
const currentUrl = page.url();
const isApprovalBoxPage = currentUrl.includes("/admin/approvalBox");
results.push(`현재 URL: ${currentUrl}`);
results.push(isApprovalBoxPage ? "OK: approvalBox 페이지에 있습니다." : "FAIL: approvalBox 페이지가 아닙니다.");
// 제목 "결재함" 확인
const titleEl = page.locator('h1:has-text("결재함")');
const hasTitle = (await titleEl.count()) > 0;
results.push(hasTitle ? "OK: 제목 '결재함' 확인됨" : "FAIL: 제목 '결재함' 없음");
// 탭 확인: 수신함, 상신함
const receivedTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "수신함" });
const sentTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "상신함" });
const hasReceivedTab = (await receivedTab.count()) > 0;
const hasSentTab = (await sentTab.count()) > 0;
results.push(hasReceivedTab ? "OK: '수신함' 탭 확인됨" : "FAIL: '수신함' 탭 없음");
results.push(hasSentTab ? "OK: '상신함' 탭 확인됨" : "FAIL: '상신함' 탭 없음");
// 7. 콘솔 에러 확인
results.push("\n=== 7. 콘솔 에러 확인 ===");
if (consoleErrors.length === 0) {
results.push("OK: 콘솔 에러 없음");
} else {
results.push(`WARN: 콘솔 에러 ${consoleErrors.length}건 발견:`);
consoleErrors.slice(0, 10).forEach((err, i) => {
results.push(` [${i + 1}] ${err.substring(0, 200)}${err.length > 200 ? "..." : ""}`);
});
if (consoleErrors.length > 10) {
results.push(` ... 외 ${consoleErrors.length - 10}`);
}
}
// 스크린샷 저장 (프로젝트 내)
await page.screenshot({ path: "approval-box-result.png" }).catch(() => {});
} catch (err: any) {
results.push(`\nERROR: ${err.message}`);
} finally {
await browser.close();
}
// 결과 출력
console.log("\n" + "=".repeat(60));
console.log("결재함 플로우 테스트 결과");
console.log("=".repeat(60));
results.forEach((r) => console.log(r));
console.log("\n" + "=".repeat(60));
}
main();

View File

@ -24,5 +24,12 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next"] "exclude": [
"node_modules",
".next",
"components/screen/ScreenDesigner_old.tsx",
"components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx",
"components/flow/FlowDataListModal.tsx",
"test-scenarios"
]
} }

View File

@ -12,6 +12,7 @@ import {
TimestampFields, TimestampFields,
BaseApiResponse, BaseApiResponse,
} from "./v2-core"; } from "./v2-core";
import { FlowVisibilityConfig } from "./screen-management";
// ===== 버튼 제어 관련 ===== // ===== 버튼 제어 관련 =====
@ -58,23 +59,6 @@ export interface ExtendedButtonTypeConfig {
borderColor?: string; borderColor?: string;
} }
/**
*
*/
export interface FlowVisibilityConfig {
enabled: boolean;
targetFlowComponentId: string;
targetFlowId?: number;
targetFlowName?: string;
mode: "whitelist" | "blacklist" | "all";
visibleSteps?: number[];
hiddenSteps?: number[];
layoutBehavior: "preserve-position" | "auto-compact";
groupId?: string;
groupDirection?: "horizontal" | "vertical";
groupGap?: number;
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
}
/** /**
* 🔥 * 🔥

View File

@ -7,6 +7,8 @@
// ===== 핵심 공통 타입들 ===== // ===== 핵심 공통 타입들 =====
export * from "./v2-core"; export * from "./v2-core";
import type { WebType, ButtonActionType, CompanyCode } from "./v2-core";
import type { ComponentData } from "./screen-management";
// ===== 시스템별 전용 타입들 ===== // ===== 시스템별 전용 타입들 =====
export * from "./screen-management"; export * from "./screen-management";
@ -258,86 +260,3 @@ export type SelectedRowData = Record<string, unknown>;
*/ */
export type TableData = Record<string, unknown>[]; export type TableData = Record<string, unknown>[];
// ===== 마이그레이션 도우미 =====
/**
* screen.ts
*/
export namespace Migration {
/**
* screen.ts의 WebType을 WebType으로
*/
export const migrateWebType = (oldWebType: string): WebType => {
// 기존 타입이 새로운 WebType에 포함되어 있는지 확인
if (isWebType(oldWebType)) {
return oldWebType as WebType;
}
// 호환되지 않는 타입의 경우 기본값 반환
console.warn(`Unknown WebType: ${oldWebType}, defaulting to 'text'`);
return "text";
};
/**
* ButtonActionType을 ButtonActionType으로
*/
export const migrateButtonActionType = (oldActionType: string): ButtonActionType => {
if (isButtonActionType(oldActionType)) {
return oldActionType as ButtonActionType;
}
console.warn(`Unknown ButtonActionType: ${oldActionType}, defaulting to 'submit'`);
return "submit";
};
/**
* Y/N boolean으로 (DB )
*/
export const migrateYNToBoolean = (value: string | undefined): boolean => {
return value === "Y";
};
/**
* boolean을 Y/N (DB )
*/
export const migrateBooleanToYN = (value: boolean): string => {
return value ? "Y" : "N";
};
}
// ===== 타입 검증 도우미 =====
/**
*
*/
export namespace TypeValidation {
/**
* BaseComponent
*/
export const validateBaseComponent = (obj: unknown): obj is BaseComponent => {
if (typeof obj !== "object" || obj === null) return false;
const component = obj as Record<string, unknown>;
return (
typeof component.id === "string" &&
typeof component.type === "string" &&
isComponentType(component.type as string) &&
typeof component.position === "object" &&
typeof component.size === "object"
);
};
/**
* WebTypeConfig를
*/
export const validateWebTypeConfig = (obj: unknown): obj is WebTypeConfig => {
return typeof obj === "object" && obj !== null;
};
/**
* CompanyCode인지
*/
export const validateCompanyCode = (code: unknown): code is CompanyCode => {
return typeof code === "string" && code.length > 0;
};
}

View File

@ -88,12 +88,18 @@ export interface ExternalDBSourceNodeData {
// REST API 소스 노드 // REST API 소스 노드
export interface RestAPISourceNodeData { export interface RestAPISourceNodeData {
method: "GET" | "POST" | "PUT" | "DELETE"; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
url: string; url: string;
headers?: Record<string, string>; headers?: Record<string, string>;
body?: string; body?: any;
responseFields: FieldDefinition[]; responseFields: FieldDefinition[];
displayName?: string; displayName?: string;
authentication?: {
type: "none" | "bearer" | "basic" | "apikey";
token?: string;
};
timeout?: number;
responseMapping?: string;
} }
// 조건 연산자 타입 // 조건 연산자 타입
@ -510,21 +516,6 @@ export interface UpsertActionNodeData {
}; };
} }
// REST API 소스 노드
export interface RestAPISourceNodeData {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record<string, string>;
body?: any;
authentication?: {
type: "none" | "bearer" | "basic" | "apikey";
token?: string;
};
timeout?: number;
responseMapping?: string; // JSON 경로 (예: "data.items")
displayName?: string;
}
// 주석 노드 // 주석 노드
export interface CommentNodeData { export interface CommentNodeData {
content: string; content: string;
@ -788,7 +779,7 @@ export type EdgeType =
| "conditionalTrue" // 조건 TRUE | "conditionalTrue" // 조건 TRUE
| "conditionalFalse"; // 조건 FALSE | "conditionalFalse"; // 조건 FALSE
export interface FlowEdge extends ReactFlowEdge { export interface FlowEdge extends Omit<ReactFlowEdge, 'type' | 'data'> {
type?: EdgeType; type?: EdgeType;
data?: { data?: {
dataType?: string; dataType?: string;

View File

@ -81,6 +81,7 @@ export interface V2ColumnInfo {
* ( ColumnTypeInfo) * ( ColumnTypeInfo)
*/ */
export interface ColumnTypeInfo { export interface ColumnTypeInfo {
tableName?: string;
columnName: string; columnName: string;
displayName: string; displayName: string;
dataType: string; dataType: string;
@ -367,6 +368,9 @@ export const WEB_TYPE_OPTIONS = [
{ value: "email", label: "email", description: "이메일 입력" }, { value: "email", label: "email", description: "이메일 입력" },
{ value: "tel", label: "tel", description: "전화번호 입력" }, { value: "tel", label: "tel", description: "전화번호 입력" },
{ value: "url", label: "url", description: "URL 입력" }, { value: "url", label: "url", description: "URL 입력" },
{ value: "checkbox-group", label: "checkbox-group", description: "체크박스 그룹" },
{ value: "radio-horizontal", label: "radio-horizontal", description: "가로 라디오" },
{ value: "radio-vertical", label: "radio-vertical", description: "세로 라디오" },
] as const; ] as const;
/** /**

View File

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

View File

@ -0,0 +1,161 @@
/**
*
*
* 실행: npx ts-node scripts/menu-copy-automation.ts
* 또는: npx playwright test scripts/menu-copy-automation.ts (playwright test )
*
* 요구사항: playwright (npm install playwright)
*/
import { chromium, type Browser, type Page } from "playwright";
import * as fs from "fs";
import * as path from "path";
const BASE_URL = "http://localhost:9771";
const SCREENSHOT_DIR = path.join(__dirname, "../screenshots-menu-copy");
// 스크린샷 저장
async function takeScreenshot(page: Page, stepName: string): Promise<string> {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
const filename = `${Date.now()}_${stepName}.png`;
const filepath = path.join(SCREENSHOT_DIR, filename);
await page.screenshot({ path: filepath, fullPage: true });
console.log(`[스크린샷] ${stepName} -> ${filepath}`);
return filepath;
}
async function main() {
let browser: Browser | null = null;
const screenshots: { step: string; path: string }[] = [];
try {
console.log("=== 메뉴 복사 자동화 시작 ===\n");
browser = await chromium.launch({ headless: false });
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
// 1. 로그인
console.log("1. 로그인 페이지 이동...");
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" });
await takeScreenshot(page, "01_login_page").then((p) =>
screenshots.push({ step: "로그인 페이지", path: p })
);
await page.fill('#userId', "admin");
await page.fill('#password', "1234");
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
await takeScreenshot(page, "02_after_login").then((p) =>
screenshots.push({ step: "로그인 후", path: p })
);
// 로그인 실패 시 wace 계정 시도 (admin이 DB에 없을 수 있음)
const currentUrl = page.url();
if (currentUrl.includes("/login")) {
console.log("admin 로그인 실패, wace 계정으로 재시도...");
await page.fill('#userId', "wace");
await page.fill('#password', "1234");
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
}
// 2. 메뉴 관리 페이지로 이동
console.log("2. 메뉴 관리 페이지 이동...");
await page.goto(`${BASE_URL}/admin/menu`, { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
await takeScreenshot(page, "03_menu_page").then((p) =>
screenshots.push({ step: "메뉴 관리 페이지", path: p })
);
// 3. 회사 선택 - 탑씰 (COMPANY_7)
console.log("3. 회사 선택: 탑씰 (COMPANY_7)...");
const companyDropdown = page.locator('.company-dropdown button, button:has(svg)').first();
await companyDropdown.click();
await page.waitForTimeout(500);
const topsealOption = page.getByText("탑씰", { exact: false }).first();
await topsealOption.click();
await page.waitForTimeout(1500);
await takeScreenshot(page, "04_company_selected").then((p) =>
screenshots.push({ step: "탑씰 선택 후", path: p })
);
// 4. "사용자" 메뉴 찾기 및 복사 버튼 클릭
console.log("4. 사용자 메뉴 찾기 및 복사 버튼 클릭...");
const userMenuRow = page.locator('tr').filter({ hasText: "사용자" }).first();
await userMenuRow.waitFor({ timeout: 10000 });
const copyButton = userMenuRow.getByRole("button", { name: "복사" });
await copyButton.click();
await page.waitForTimeout(1500);
await takeScreenshot(page, "05_copy_dialog_open").then((p) =>
screenshots.push({ step: "복사 다이얼로그", path: p })
);
// 5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)
console.log("5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)...");
const targetCompanyTrigger = page.locator('[id="company"]').or(page.getByRole("combobox")).first();
await targetCompanyTrigger.click();
await page.waitForTimeout(500);
const dubaiOption = page.getByText("두바이 강정 단단", { exact: false }).first();
await dubaiOption.click();
await page.waitForTimeout(500);
await takeScreenshot(page, "06_target_company_selected").then((p) =>
screenshots.push({ step: "대상 회사 선택 후", path: p })
);
// 6. 복사 시작 버튼 클릭
console.log("6. 복사 시작...");
const copyStartButton = page.getByRole("button", { name: /복사 시작|확인/ }).first();
await copyStartButton.click();
// 7. 복사 완료 대기 (최대 5분)
console.log("7. 복사 완료 대기 (최대 5분)...");
try {
await page.waitForSelector('text=완료, text=성공, [role="status"]', { timeout: 300000 });
await page.waitForTimeout(3000);
} catch {
console.log("타임아웃 또는 완료 메시지 대기 중...");
}
await takeScreenshot(page, "07_copy_result").then((p) =>
screenshots.push({ step: "복사 결과", path: p })
);
// 결과 확인
const resultText = await page.locator("body").textContent();
if (resultText?.includes("완료") || resultText?.includes("성공")) {
console.log("\n=== 메뉴 복사 성공 ===");
} else if (resultText?.includes("오류") || resultText?.includes("실패") || resultText?.includes("error")) {
console.log("\n=== 에러 발생 가능 - 스크린샷 확인 필요 ===");
}
console.log("\n=== 스크린샷 목록 ===");
screenshots.forEach((s) => console.log(` - ${s.step}: ${s.path}`));
} catch (error) {
console.error("오류 발생:", error);
if (browser) {
const pages = (browser as any).contexts?.()?.[0]?.pages?.() || [];
for (const p of pages) {
try {
await takeScreenshot(p, "error_state").then((path) =>
screenshots.push({ step: "에러 상태", path })
);
} catch {}
}
}
throw error;
} finally {
if (browser) {
await browser.close();
}
}
}
main().catch(console.error);