diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b75e6685..9e8c9da5 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -64,6 +64,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 +import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -222,6 +223,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 +app.use("/api/departments", departmentRoutes); // 부서 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b1638403..f79aec69 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -8,6 +8,7 @@ import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; +import { validateBusinessNumber } from "../utils/businessNumberValidator"; /** * 관리자 메뉴 목록 조회 @@ -609,9 +610,15 @@ export const getCompanyList = async ( // Raw Query로 회사 목록 조회 const companies = await query( - `SELECT - company_code, + ` SELECT + company_code, company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, status, writer, regdate @@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB( // Raw Query로 회사 목록 조회 const companies = await query( - `SELECT - company_code, + ` SELECT + company_code, company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, writer, regdate, status @@ -2440,6 +2453,25 @@ export const createCompany = async ( [company_name.trim()] ); + // 사업자등록번호 유효성 검증 + const businessNumberValidation = validateBusinessNumber( + req.body.business_registration_number?.trim() || "" + ); + if (!businessNumberValidation.isValid) { + res.status(400).json({ + success: false, + message: businessNumberValidation.message, + errorCode: "INVALID_BUSINESS_NUMBER", + }); + return; + } + + // Raw Query로 사업자등록번호 중복 체크 + const existingBusinessNumber = await queryOne( + `SELECT company_code FROM company_mng WHERE business_registration_number = $1`, + [req.body.business_registration_number?.trim()] + ); + if (existingCompany) { res.status(400).json({ success: false, @@ -2449,6 +2481,15 @@ export const createCompany = async ( return; } + if (existingBusinessNumber) { + res.status(400).json({ + success: false, + message: "이미 등록된 사업자등록번호입니다.", + errorCode: "DUPLICATE_BUSINESS_NUMBER", + }); + return; + } + // PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용) const client = new Client({ connectionString: @@ -2474,11 +2515,17 @@ export const createCompany = async ( const insertQuery = ` INSERT INTO company_mng ( company_code, - company_name, + company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, writer, regdate, status - ) VALUES ($1, $2, $3, $4, $5) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -2488,6 +2535,12 @@ export const createCompany = async ( const insertValues = [ companyCode, company_name.trim(), + req.body.business_registration_number?.trim() || null, + req.body.representative_name?.trim() || null, + req.body.representative_phone?.trim() || null, + req.body.email?.trim() || null, + req.body.website?.trim() || null, + req.body.address?.trim() || null, writer, new Date(), "active", @@ -2552,7 +2605,16 @@ export const updateCompany = async ( ): Promise => { try { const { companyCode } = req.params; - const { company_name, status } = req.body; + const { + company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, + status, + } = req.body; logger.info("회사 정보 수정 요청", { companyCode, @@ -2586,13 +2648,61 @@ export const updateCompany = async ( return; } + // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) + if (business_registration_number && business_registration_number.trim()) { + // 유효성 검증 + const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + if (!businessNumberValidation.isValid) { + res.status(400).json({ + success: false, + message: businessNumberValidation.message, + errorCode: "INVALID_BUSINESS_NUMBER", + }); + return; + } + + // 중복 체크 + const duplicateBusinessNumber = await queryOne( + `SELECT company_code FROM company_mng + WHERE business_registration_number = $1 AND company_code != $2`, + [business_registration_number.trim(), companyCode] + ); + + if (duplicateBusinessNumber) { + res.status(400).json({ + success: false, + message: "이미 등록된 사업자등록번호입니다.", + errorCode: "DUPLICATE_BUSINESS_NUMBER", + }); + return; + } + } + // Raw Query로 회사 정보 수정 const result = await query( `UPDATE company_mng - SET company_name = $1, status = $2 - WHERE company_code = $3 + SET + company_name = $1, + business_registration_number = $2, + representative_name = $3, + representative_phone = $4, + email = $5, + website = $6, + address = $7, + status = $8 + WHERE company_code = $9 RETURNING *`, - [company_name.trim(), status || "active", companyCode] + [ + company_name.trim(), + business_registration_number?.trim() || null, + representative_name?.trim() || null, + representative_phone?.trim() || null, + email?.trim() || null, + website?.trim() || null, + address?.trim() || null, + status || "active", + companyCode, + ] ); if (result.length === 0) { diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts new file mode 100644 index 00000000..9e3f0b6a --- /dev/null +++ b/backend-node/src/controllers/departmentController.ts @@ -0,0 +1,534 @@ +import { Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { query, queryOne } from "../database/db"; + +/** + * 부서 목록 조회 (회사별) + */ +export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const userCompanyCode = req.user?.companyCode; + + logger.info("부서 목록 조회", { companyCode, userCompanyCode }); + + // 최고 관리자가 아니면 자신의 회사만 조회 가능 + if (userCompanyCode !== "*" && userCompanyCode !== companyCode) { + res.status(403).json({ + success: false, + message: "해당 회사의 부서를 조회할 권한이 없습니다.", + }); + return; + } + + // 부서 목록 조회 (부서원 수 포함) + const departments = await query(` + SELECT + d.dept_code, + d.dept_name, + d.company_code, + d.parent_dept_code, + COUNT(DISTINCT ud.user_id) as member_count + FROM dept_info d + LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code + WHERE d.company_code = $1 + GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code + ORDER BY d.dept_name + `, [companyCode]); + + // 응답 형식 변환 + const formattedDepartments = departments.map((dept) => ({ + dept_code: dept.dept_code, + dept_name: dept.dept_name, + company_code: dept.company_code, + parent_dept_code: dept.parent_dept_code, + memberCount: parseInt(dept.member_count || "0"), + })); + + res.status(200).json({ + success: true, + data: formattedDepartments, + }); + } catch (error) { + logger.error("부서 목록 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 상세 조회 + */ +export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + const department = await queryOne(` + SELECT + dept_code, + dept_name, + company_code, + parent_dept_code + FROM dept_info + WHERE dept_code = $1 + `, [deptCode]); + + if (!department) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + res.status(200).json({ + success: true, + data: department, + }); + } catch (error) { + logger.error("부서 상세 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 생성 + */ +export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { dept_name, parent_dept_code } = req.body; + + if (!dept_name || !dept_name.trim()) { + res.status(400).json({ + success: false, + message: "부서명을 입력해주세요.", + }); + return; + } + + // 같은 회사 내 중복 부서명 확인 + const duplicate = await queryOne(` + SELECT dept_code, dept_name + FROM dept_info + WHERE company_code = $1 AND dept_name = $2 + `, [companyCode, dept_name.trim()]); + + if (duplicate) { + res.status(409).json({ + success: false, + message: `"${dept_name}" 부서가 이미 존재합니다.`, + isDuplicate: true, + }); + return; + } + + // 회사 이름 조회 + const company = await queryOne(` + SELECT company_name FROM company_mng WHERE company_code = $1 + `, [companyCode]); + + const companyName = company?.company_name || companyCode; + + // 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...) + const codeResult = await queryOne(` + SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number + FROM dept_info + WHERE dept_code ~ '^DEPT_[0-9]+$' + `); + + const nextNumber = codeResult?.next_number || 1; + const deptCode = `DEPT_${nextNumber}`; + + // 부서 생성 + const result = await query(` + INSERT INTO dept_info ( + dept_code, + dept_name, + company_code, + company_name, + parent_dept_code, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING * + `, [ + deptCode, + dept_name.trim(), + companyCode, + companyName, + parent_dept_code || null, + 'active', + ]); + + logger.info("부서 생성 성공", { deptCode, dept_name }); + + res.status(201).json({ + success: true, + message: "부서가 생성되었습니다.", + data: result[0], + }); + } catch (error) { + logger.error("부서 생성 실패", error); + res.status(500).json({ + success: false, + message: "부서 생성 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 수정 + */ +export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + const { dept_name, parent_dept_code } = req.body; + + if (!dept_name || !dept_name.trim()) { + res.status(400).json({ + success: false, + message: "부서명을 입력해주세요.", + }); + return; + } + + const result = await query(` + UPDATE dept_info + SET + dept_name = $1, + parent_dept_code = $2 + WHERE dept_code = $3 + RETURNING * + `, [dept_name.trim(), parent_dept_code || null, deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서 수정 성공", { deptCode }); + + res.status(200).json({ + success: true, + message: "부서가 수정되었습니다.", + data: result[0], + }); + } catch (error) { + logger.error("부서 수정 실패", error); + res.status(500).json({ + success: false, + message: "부서 수정 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 삭제 + */ +export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + // 하위 부서 확인 + const hasChildren = await queryOne(` + SELECT COUNT(*) as count + FROM dept_info + WHERE parent_dept_code = $1 + `, [deptCode]); + + if (parseInt(hasChildren?.count || "0") > 0) { + res.status(400).json({ + success: false, + message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.", + }); + return; + } + + // 부서원 삭제 (부서 삭제 전에 먼저 삭제) + const deletedMembers = await query(` + DELETE FROM user_dept + WHERE dept_code = $1 + RETURNING user_id + `, [deptCode]); + + const memberCount = deletedMembers.length; + + // 부서 삭제 + const result = await query(` + DELETE FROM dept_info + WHERE dept_code = $1 + RETURNING dept_code, dept_name + `, [deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서 삭제 성공", { + deptCode, + deptName: result[0].dept_name, + deletedMemberCount: memberCount + }); + + res.status(200).json({ + success: true, + message: memberCount > 0 + ? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)` + : "부서가 삭제되었습니다.", + }); + } catch (error) { + logger.error("부서 삭제 실패", error); + res.status(500).json({ + success: false, + message: "부서 삭제 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 목록 조회 + */ +export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + const members = await query(` + SELECT + u.user_id, + u.user_name, + u.email, + u.tel as phone, + u.cell_phone, + u.position_name, + ud.dept_code, + d.dept_name, + ud.is_primary + FROM user_dept ud + JOIN user_info u ON ud.user_id = u.user_id + JOIN dept_info d ON ud.dept_code = d.dept_code + WHERE ud.dept_code = $1 + ORDER BY ud.is_primary DESC, u.user_name + `, [deptCode]); + + res.status(200).json({ + success: true, + data: members, + }); + } catch (error) { + logger.error("부서원 목록 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서원 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { search } = req.query; + + if (!search || typeof search !== 'string') { + res.status(400).json({ + success: false, + message: "검색어를 입력해주세요.", + }); + return; + } + + // 사용자 검색 (ID 또는 이름) + const users = await query(` + SELECT + user_id, + user_name, + email, + position_name, + company_code + FROM user_info + WHERE company_code = $1 + AND ( + user_id ILIKE $2 OR + user_name ILIKE $2 + ) + ORDER BY user_name + LIMIT 20 + `, [companyCode, `%${search}%`]); + + res.status(200).json({ + success: true, + data: users, + }); + } catch (error) { + logger.error("사용자 검색 실패", error); + res.status(500).json({ + success: false, + message: "사용자 검색 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 추가 + */ +export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + const { user_id } = req.body; + + if (!user_id) { + res.status(400).json({ + success: false, + message: "사용자 ID를 입력해주세요.", + }); + return; + } + + // 사용자 존재 확인 + const user = await queryOne(` + SELECT user_id, user_name + FROM user_info + WHERE user_id = $1 + `, [user_id]); + + if (!user) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + // 이미 부서원인지 확인 + const existing = await queryOne(` + SELECT * + FROM user_dept + WHERE user_id = $1 AND dept_code = $2 + `, [user_id, deptCode]); + + if (existing) { + res.status(409).json({ + success: false, + message: "이미 해당 부서의 부서원입니다.", + isDuplicate: true, + }); + return; + } + + // 주 부서가 있는지 확인 + const hasPrimary = await queryOne(` + SELECT * + FROM user_dept + WHERE user_id = $1 AND is_primary = true + `, [user_id]); + + // 부서원 추가 + await query(` + INSERT INTO user_dept (user_id, dept_code, is_primary, created_at) + VALUES ($1, $2, $3, NOW()) + `, [user_id, deptCode, !hasPrimary]); + + logger.info("부서원 추가 성공", { user_id, deptCode }); + + res.status(201).json({ + success: true, + message: "부서원이 추가되었습니다.", + }); + } catch (error) { + logger.error("부서원 추가 실패", error); + res.status(500).json({ + success: false, + message: "부서원 추가 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 제거 + */ +export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode, userId } = req.params; + + const result = await query(` + DELETE FROM user_dept + WHERE user_id = $1 AND dept_code = $2 + RETURNING * + `, [userId, deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "해당 부서원을 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서원 제거 성공", { userId, deptCode }); + + res.status(200).json({ + success: true, + message: "부서원이 제거되었습니다.", + }); + } catch (error) { + logger.error("부서원 제거 실패", error); + res.status(500).json({ + success: false, + message: "부서원 제거 중 오류가 발생했습니다.", + }); + } +} + +/** + * 주 부서 설정 + */ +export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode, userId } = req.params; + + // 다른 부서의 주 부서 해제 + await query(` + UPDATE user_dept + SET is_primary = false + WHERE user_id = $1 + `, [userId]); + + // 해당 부서를 주 부서로 설정 + await query(` + UPDATE user_dept + SET is_primary = true + WHERE user_id = $1 AND dept_code = $2 + `, [userId, deptCode]); + + logger.info("주 부서 설정 성공", { userId, deptCode }); + + res.status(200).json({ + success: true, + message: "주 부서가 설정되었습니다.", + }); + } catch (error) { + logger.error("주 부서 설정 실패", error); + res.status(500).json({ + success: false, + message: "주 부서 설정 중 오류가 발생했습니다.", + }); + } +} + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index d7b2bd74..9661ab0a 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -12,6 +12,7 @@ import { ColumnListResponse, ColumnSettingsResponse, } from "../types/tableManagement"; +import { query } from "../database/db"; // 🆕 query 함수 import /** * 테이블 목록 조회 @@ -506,7 +507,91 @@ export async function updateColumnInputType( } /** - * 테이블 데이터 조회 (페이징 + 검색) + * 단일 레코드 조회 (자동 입력용) + */ +export async function getTableRecord( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { filterColumn, filterValue, displayColumn } = req.body; + + logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`); + logger.info(`필터: ${filterColumn} = ${filterValue}`); + logger.info(`표시 컬럼: ${displayColumn}`); + + if (!tableName || !filterColumn || !filterValue || !displayColumn) { + const response: ApiResponse = { + success: false, + message: "필수 파라미터가 누락되었습니다.", + error: { + code: "MISSING_PARAMETERS", + details: + "tableName, filterColumn, filterValue, displayColumn이 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 단일 레코드 조회 (WHERE filterColumn = filterValue) + const result = await tableManagementService.getTableData(tableName, { + page: 1, + size: 1, + search: { + [filterColumn]: filterValue, + }, + }); + + if (!result.data || result.data.length === 0) { + const response: ApiResponse = { + success: false, + message: "데이터를 찾을 수 없습니다.", + error: { + code: "NOT_FOUND", + details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`, + }, + }; + res.status(404).json(response); + return; + } + + const record = result.data[0]; + const displayValue = record[displayColumn]; + + logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`); + + const response: ApiResponse<{ value: any; record: any }> = { + success: true, + message: "레코드를 성공적으로 조회했습니다.", + data: { + value: displayValue, + record: record, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("레코드 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: { + code: "RECORD_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 데이터 조회 (페이징 + 검색 + 필터링) */ export async function getTableData( req: AuthenticatedRequest, @@ -520,12 +605,14 @@ export async function getTableData( search = {}, sortBy, sortOrder = "asc", + autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) } = req.body; logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`페이징: page=${page}, size=${size}`); logger.info(`검색 조건:`, search); logger.info(`정렬: ${sortBy} ${sortOrder}`); + logger.info(`자동 필터:`, autoFilter); // 🆕 if (!tableName) { const response: ApiResponse = { @@ -542,11 +629,35 @@ export async function getTableData( const tableManagementService = new TableManagementService(); + // 🆕 현재 사용자 필터 적용 + let enhancedSearch = { ...search }; + if (autoFilter?.enabled && req.user) { + const filterColumn = autoFilter.filterColumn || "company_code"; + const userField = autoFilter.userField || "companyCode"; + const userValue = (req.user as any)[userField]; + + if (userValue) { + enhancedSearch[filterColumn] = userValue; + + logger.info("🔍 현재 사용자 필터 적용:", { + filterColumn, + userField, + userValue, + tableName, + }); + } else { + logger.warn("⚠️ 사용자 정보 필드 값 없음:", { + userField, + user: req.user, + }); + } + } + // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), size: parseInt(size), - search, + search: enhancedSearch, // 🆕 필터가 적용된 search 사용 sortBy, sortOrder, }); @@ -1216,9 +1327,7 @@ export async function getLogData( originalId: originalId as string, }); - logger.info( - `로그 데이터 조회 완료: ${tableName}_log, ${result.total}건` - ); + logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`); const response: ApiResponse = { success: true, @@ -1254,7 +1363,9 @@ export async function toggleLogTable( const { tableName } = req.params; const { isActive } = req.body; - logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); + logger.info( + `=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===` + ); if (!tableName) { const response: ApiResponse = { @@ -1288,9 +1399,7 @@ export async function toggleLogTable( isActive === "Y" || isActive === true ); - logger.info( - `로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}` - ); + logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`); const response: ApiResponse = { success: true, diff --git a/backend-node/src/routes/departmentRoutes.ts b/backend-node/src/routes/departmentRoutes.ts new file mode 100644 index 00000000..52cc309e --- /dev/null +++ b/backend-node/src/routes/departmentRoutes.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as departmentController from "../controllers/departmentController"; + +const router = Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 부서 관리 API 라우트 + * 기본 경로: /api/departments + */ + +// 부서 목록 조회 (회사별) +router.get("/companies/:companyCode/departments", departmentController.getDepartments); + +// 부서 상세 조회 +router.get("/:deptCode", departmentController.getDepartment); + +// 부서 생성 +router.post("/companies/:companyCode/departments", departmentController.createDepartment); + +// 부서 수정 +router.put("/:deptCode", departmentController.updateDepartment); + +// 부서 삭제 +router.delete("/:deptCode", departmentController.deleteDepartment); + +// 부서원 목록 조회 +router.get("/:deptCode/members", departmentController.getDepartmentMembers); + +// 사용자 검색 (부서원 추가용) +router.get("/companies/:companyCode/users/search", departmentController.searchUsers); + +// 부서원 추가 +router.post("/:deptCode/members", departmentController.addDepartmentMember); + +// 부서원 제거 +router.delete("/:deptCode/members/:userId", departmentController.removeDepartmentMember); + +// 주 부서 설정 +router.put("/:deptCode/members/:userId/primary", departmentController.setPrimaryDepartment); + +export default router; + diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5e5ddf38..9840c9c4 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -11,6 +11,7 @@ import { updateColumnInputType, updateTableLabel, getTableData, + getTableRecord, // 🆕 단일 레코드 조회 addTableData, editTableData, deleteTableData, @@ -134,6 +135,12 @@ router.get("/health", checkDatabaseConnection); */ router.post("/tables/:tableName/data", getTableData); +/** + * 단일 레코드 조회 (자동 입력용) + * POST /api/table-management/tables/:tableName/record + */ +router.post("/tables/:tableName/record", getTableRecord); + /** * 테이블 데이터 추가 * POST /api/table-management/tables/:tableName/add diff --git a/backend-node/src/utils/businessNumberValidator.ts b/backend-node/src/utils/businessNumberValidator.ts new file mode 100644 index 00000000..92385f28 --- /dev/null +++ b/backend-node/src/utils/businessNumberValidator.ts @@ -0,0 +1,52 @@ +/** + * 사업자등록번호 유효성 검사 유틸리티 (백엔드) + */ + +/** + * 사업자등록번호 포맷 검증 + */ +export function validateBusinessNumberFormat(value: string): boolean { + if (!value || value.trim() === "") { + return false; + } + + // 하이픈 제거 + const cleaned = value.replace(/-/g, ""); + + // 숫자 10자리인지 확인 + if (!/^\d{10}$/.test(cleaned)) { + return false; + } + + return true; +} + +/** + * 사업자등록번호 종합 검증 (포맷만 검사) + * 실제 국세청 검증은 API 호출로 처리하는 것을 권장 + */ +export function validateBusinessNumber(value: string): { + isValid: boolean; + message: string; +} { + if (!value || value.trim() === "") { + return { + isValid: false, + message: "사업자등록번호를 입력해주세요.", + }; + } + + if (!validateBusinessNumberFormat(value)) { + return { + isValid: false, + message: "사업자등록번호는 10자리 숫자여야 합니다.", + }; + } + + // 포맷만 검증하고 통과 + return { + isValid: true, + message: "", + }; +} + diff --git a/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx new file mode 100644 index 00000000..7854e6ee --- /dev/null +++ b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement"; + +export default function DepartmentManagementPage() { + const params = useParams(); + const companyCode = params.companyCode as string; + + return ; +} + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index dac590d6..c5f35351 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -147,6 +147,57 @@ export default function ScreenViewPage() { } }, [screenId]); + // 🆕 autoFill 자동 입력 초기화 + useEffect(() => { + const initAutoFill = async () => { + if (!layout || !layout.components || !user) { + return; + } + + for (const comp of layout.components) { + // type: "component" 또는 type: "widget" 모두 처리 + if (comp.type === 'widget' || comp.type === 'component') { + const widget = comp as any; + const fieldName = widget.columnName || widget.id; + + // autoFill 처리 + if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { + const autoFillConfig = widget.autoFill || (comp as any).autoFill; + const currentValue = formData[fieldName]; + + if (currentValue === undefined || currentValue === '') { + const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; + + // 사용자 정보에서 필터 값 가져오기 + const userValue = user?.[userField as keyof typeof user]; + + if (userValue && sourceTable && filterColumn && displayColumn) { + try { + const { tableTypeApi } = await import("@/lib/api/screen"); + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + + setFormData((prev) => ({ + ...prev, + [fieldName]: result.value, + })); + } catch (error) { + console.error(`autoFill 조회 실패: ${fieldName}`, error); + } + } + } + } + } + } + }; + + initAutoFill(); + }, [layout, user]); + // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화 useEffect(() => { // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) diff --git a/frontend/components/admin/CompanyFormModal.tsx b/frontend/components/admin/CompanyFormModal.tsx index 7b51ac6a..dd87140e 100644 --- a/frontend/components/admin/CompanyFormModal.tsx +++ b/frontend/components/admin/CompanyFormModal.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber"; interface CompanyFormModalProps { modalState: CompanyModalState; @@ -29,6 +30,7 @@ export function CompanyFormModal({ onClearError, }: CompanyFormModalProps) { const [isSaving, setIsSaving] = useState(false); + const [businessNumberError, setBusinessNumberError] = useState(""); // 모달이 열려있지 않으면 렌더링하지 않음 if (!modalState.isOpen) return null; @@ -36,15 +38,43 @@ export function CompanyFormModal({ const { mode, formData, selectedCompany } = modalState; const isEditMode = mode === "edit"; + // 사업자등록번호 변경 처리 + const handleBusinessNumberChange = (value: string) => { + // 자동 포맷팅 + const formatted = formatBusinessNumber(value); + onFormChange("business_registration_number", formatted); + + // 유효성 검사 (10자리가 다 입력되었을 때만) + const cleaned = formatted.replace(/-/g, ""); + if (cleaned.length === 10) { + const validation = validateBusinessNumber(formatted); + setBusinessNumberError(validation.isValid ? "" : validation.message); + } else if (cleaned.length < 10 && businessNumberError) { + // 10자리 미만이면 에러 초기화 + setBusinessNumberError(""); + } + }; + // 저장 처리 const handleSave = async () => { - // 입력값 검증 + // 입력값 검증 (필수 필드) if (!formData.company_name.trim()) { return; } + if (!formData.business_registration_number.trim()) { + return; + } + + // 사업자등록번호 최종 검증 + const validation = validateBusinessNumber(formData.business_registration_number); + if (!validation.isValid) { + setBusinessNumberError(validation.message); + return; + } setIsSaving(true); onClearError(); + setBusinessNumberError(""); try { const success = await onSave(); @@ -81,7 +111,7 @@ export function CompanyFormModal({
- {/* 회사명 입력 */} + {/* 회사명 입력 (필수) */}
+ {/* 사업자등록번호 입력 (필수) */} +
+ + handleBusinessNumberChange(e.target.value)} + placeholder="000-00-00000" + disabled={isLoading || isSaving} + maxLength={12} + className={businessNumberError ? "border-destructive" : ""} + /> + {businessNumberError ? ( +

{businessNumberError}

+ ) : ( +

10자리 숫자 (자동 하이픈 추가)

+ )} +
+ + {/* 대표자명 입력 */} +
+ + onFormChange("representative_name", e.target.value)} + placeholder="대표자명을 입력하세요" + disabled={isLoading || isSaving} + /> +
+ + {/* 대표 연락처 입력 */} +
+ + onFormChange("representative_phone", e.target.value)} + placeholder="010-0000-0000" + disabled={isLoading || isSaving} + type="tel" + /> +
+ + {/* 이메일 입력 */} +
+ + onFormChange("email", e.target.value)} + placeholder="company@example.com" + disabled={isLoading || isSaving} + type="email" + /> +
+ + {/* 웹사이트 입력 */} +
+ + onFormChange("website", e.target.value)} + placeholder="https://example.com" + disabled={isLoading || isSaving} + type="url" + /> +
+ + {/* 회사 주소 입력 */} +
+ + onFormChange("address", e.target.value)} + placeholder="서울특별시 강남구..." + disabled={isLoading || isSaving} + /> +
+ {/* 에러 메시지 */} {error && ( -
-

{error}

+
+

{error}

)} @@ -129,7 +243,13 @@ export function CompanyFormModal({