From 257912ea928c4a7e32199a54bd7cb2dee90ccdc1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 16:31:03 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=80=EC=84=9C=20read=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/departmentController.ts | 458 ++++++++++++++++++ backend-node/src/routes/departmentRoutes.ts | 43 ++ .../[companyCode]/departments/page.tsx | 12 + frontend/components/admin/CompanyTable.tsx | 96 ++-- .../admin/department/DepartmentManagement.tsx | 72 +++ .../admin/department/DepartmentMembers.tsx | 247 ++++++++++ .../admin/department/DepartmentStructure.tsx | 275 +++++++++++ frontend/lib/api/department.ts | 131 +++++ frontend/types/department.ts | 69 +++ 10 files changed, 1369 insertions(+), 36 deletions(-) create mode 100644 backend-node/src/controllers/departmentController.ts create mode 100644 backend-node/src/routes/departmentRoutes.ts create mode 100644 frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx create mode 100644 frontend/components/admin/department/DepartmentManagement.tsx create mode 100644 frontend/components/admin/department/DepartmentMembers.tsx create mode 100644 frontend/components/admin/department/DepartmentStructure.tsx create mode 100644 frontend/lib/api/department.ts create mode 100644 frontend/types/department.ts 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/departmentController.ts b/backend-node/src/controllers/departmentController.ts new file mode 100644 index 00000000..34de0800 --- /dev/null +++ b/backend-node/src/controllers/departmentController.ts @@ -0,0 +1,458 @@ +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; + } + + // 부서 코드 생성 (DEPT_숫자) + const codeResult = await queryOne(` + SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number + FROM dept_info + WHERE company_code = $1 AND dept_code LIKE 'DEPT_%' + `, [companyCode]); + + const nextNumber = codeResult?.next_number || 1; + const deptCode = `DEPT_${nextNumber}`; + + // 부서 생성 + const result = await query(` + INSERT INTO dept_info ( + dept_code, + dept_name, + company_code, + parent_dept_code + ) VALUES ($1, $2, $3, $4) + RETURNING * + `, [ + deptCode, + dept_name.trim(), + companyCode, + parent_dept_code || null, + ]); + + 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 hasMembers = await queryOne(` + SELECT COUNT(*) as count + FROM user_dept + WHERE dept_code = $1 + `, [deptCode]); + + if (parseInt(hasMembers?.count || "0") > 0) { + res.status(400).json({ + success: false, + message: "부서원이 있는 부서는 삭제할 수 없습니다.", + }); + return; + } + + // 부서 삭제 + 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 }); + + res.status(200).json({ + success: true, + message: "부서가 삭제되었습니다.", + }); + } 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 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(400).json({ + success: false, + message: "이미 해당 부서의 부서원입니다.", + }); + 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/routes/departmentRoutes.ts b/backend-node/src/routes/departmentRoutes.ts new file mode 100644 index 00000000..2f06dd3c --- /dev/null +++ b/backend-node/src/routes/departmentRoutes.ts @@ -0,0 +1,43 @@ +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.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/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/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index 86ccdee3..78b9ca3e 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -1,8 +1,9 @@ -import { Edit, Trash2, HardDrive, FileText } from "lucide-react"; +import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react"; import { Company } from "@/types/company"; import { COMPANY_TABLE_COLUMNS } from "@/constants/company"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useRouter } from "next/navigation"; interface CompanyTableProps { companies: Company[]; @@ -17,11 +18,18 @@ interface CompanyTableProps { * 모바일/태블릿: 카드 뷰 */ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) { + const router = useRouter(); + + // 부서 관리 페이지로 이동 + const handleManageDepartments = (company: Company) => { + router.push(`/admin/company/${company.company_code}/departments`); + }; + // 디스크 사용량 포맷팅 함수 const formatDiskUsage = (company: Company) => { if (!company.diskUsage) { return ( -
+
정보 없음
@@ -33,11 +41,11 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company return (
- + {fileCount}개 파일
- + {totalSizeMB.toFixed(1)} MB
@@ -49,7 +57,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company return ( <> {/* 데스크톱 테이블 스켈레톤 */} -
+
@@ -66,21 +74,21 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {Array.from({ length: 10 }).map((_, index) => ( -
+
-
+
-
+
-
+
-
-
+
+
@@ -92,18 +100,18 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {/* 모바일/태블릿 카드 스켈레톤 */}
{Array.from({ length: 6 }).map((_, index) => ( -
+
-
-
+
+
{Array.from({ length: 3 }).map((_, i) => (
-
-
+
+
))}
@@ -117,9 +125,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company // 데이터가 없을 때 if (companies.length === 0) { return ( -
+
-

등록된 회사가 없습니다.

+

등록된 회사가 없습니다.

); @@ -129,28 +137,40 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company return ( <> {/* 데스크톱 테이블 뷰 (lg 이상) */} -
+
- - - {COMPANY_TABLE_COLUMNS.map((column) => ( - - {column.label} - - ))} - 디스크 사용량 - 작업 - - + + + {COMPANY_TABLE_COLUMNS.map((column) => ( + + {column.label} + + ))} + 디스크 사용량 + 작업 + + {companies.map((company) => ( - + {company.company_code} {company.company_name} {company.writer} {formatDiskUsage(company)}
+ + @@ -219,7 +243,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company variant="outline" size="sm" onClick={() => onDelete(company)} - className="h-9 flex-1 gap-2 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive" + className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm" > 삭제 diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx new file mode 100644 index 00000000..989b6084 --- /dev/null +++ b/frontend/components/admin/department/DepartmentManagement.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { DepartmentStructure } from "./DepartmentStructure"; +import { DepartmentMembers } from "./DepartmentMembers"; +import type { Department } from "@/types/department"; + +interface DepartmentManagementProps { + companyCode: string; +} + +/** + * 부서 관리 메인 컴포넌트 + * 좌측: 부서 구조, 우측: 부서 인원 + */ +export function DepartmentManagement({ companyCode }: DepartmentManagementProps) { + const [selectedDepartment, setSelectedDepartment] = useState(null); + const [activeTab, setActiveTab] = useState("structure"); + + return ( +
+ {/* 탭 네비게이션 (모바일용) */} +
+ + + 부서 구조 + 부서 인원 + + + + + + + + + + +
+ + {/* 좌우 레이아웃 (데스크톱) */} +
+ {/* 좌측: 부서 구조 (30%) */} +
+ +
+ + {/* 우측: 부서 인원 (70%) */} +
+ +
+
+
+ ); +} + + diff --git a/frontend/components/admin/department/DepartmentMembers.tsx b/frontend/components/admin/department/DepartmentMembers.tsx new file mode 100644 index 00000000..a1a79f92 --- /dev/null +++ b/frontend/components/admin/department/DepartmentMembers.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Plus, X, Star } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import type { Department, DepartmentMember } from "@/types/department"; +import * as departmentAPI from "@/lib/api/department"; + +interface DepartmentMembersProps { + companyCode: string; + selectedDepartment: Department | null; +} + +/** + * 부서 인원 관리 컴포넌트 + */ +export function DepartmentMembers({ companyCode, selectedDepartment }: DepartmentMembersProps) { + const [members, setMembers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [searchUserId, setSearchUserId] = useState(""); + + // 부서원 목록 로드 + useEffect(() => { + if (selectedDepartment) { + loadMembers(); + } + }, [selectedDepartment]); + + const loadMembers = async () => { + if (!selectedDepartment) return; + + setIsLoading(true); + try { + const response = await departmentAPI.getDepartmentMembers(selectedDepartment.dept_code); + if (response.success && response.data) { + setMembers(response.data); + } else { + console.error("부서원 목록 로드 실패:", response.error); + setMembers([]); + } + } catch (error) { + console.error("부서원 목록 로드 실패:", error); + setMembers([]); + } finally { + setIsLoading(false); + } + }; + + // 부서원 추가 + const handleAddMember = async () => { + if (!searchUserId.trim() || !selectedDepartment) return; + + try { + const response = await departmentAPI.addDepartmentMember( + selectedDepartment.dept_code, + searchUserId + ); + + if (response.success) { + setIsAddModalOpen(false); + setSearchUserId(""); + loadMembers(); + } else { + alert(response.error || "부서원 추가에 실패했습니다."); + } + } catch (error) { + console.error("부서원 추가 실패:", error); + alert("부서원 추가 중 오류가 발생했습니다."); + } + }; + + // 부서원 제거 + const handleRemoveMember = async (userId: string) => { + if (!selectedDepartment) return; + if (!confirm("이 부서에서 사용자를 제외하시겠습니까?")) return; + + try { + const response = await departmentAPI.removeDepartmentMember( + selectedDepartment.dept_code, + userId + ); + + if (response.success) { + loadMembers(); + } else { + alert(response.error || "부서원 제거에 실패했습니다."); + } + } catch (error) { + console.error("부서원 제거 실패:", error); + alert("부서원 제거 중 오류가 발생했습니다."); + } + }; + + // 주 부서 설정 + const handleSetPrimaryDepartment = async (userId: string) => { + if (!selectedDepartment) return; + + try { + const response = await departmentAPI.setPrimaryDepartment( + selectedDepartment.dept_code, + userId + ); + + if (response.success) { + loadMembers(); + } else { + alert(response.error || "주 부서 설정에 실패했습니다."); + } + } catch (error) { + console.error("주 부서 설정 실패:", error); + alert("주 부서 설정 중 오류가 발생했습니다."); + } + }; + + if (!selectedDepartment) { + return ( +
+

좌측에서 부서를 선택하세요

+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

{selectedDepartment.dept_name}

+

부서원 {members.length}명

+
+ +
+ + {/* 부서원 목록 */} +
+ {isLoading ? ( +
로딩 중...
+ ) : members.length === 0 ? ( +
+ 부서원이 없습니다. 부서원을 추가해주세요. +
+ ) : ( +
+ {members.map((member) => ( +
+
+
+ {member.user_name} + ({member.user_id}) + {member.is_primary && ( + + + 주 부서 + + )} +
+
+ {member.position_name && 직책: {member.position_name}} + {member.email && 이메일: {member.email}} + {member.phone && 전화: {member.phone}} + {member.cell_phone && 휴대폰: {member.cell_phone}} +
+
+ +
+ {!member.is_primary && ( + + )} + +
+
+ ))} +
+ )} +
+ + {/* 부서원 추가 모달 */} + + + + 부서원 추가 + + +
+
+ + setSearchUserId(e.target.value)} + placeholder="사용자 ID를 입력하세요" + autoFocus + /> +

+ 겸직이 가능합니다. 한 사용자를 여러 부서에 추가할 수 있습니다. +

+
+
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx new file mode 100644 index 00000000..1e8b83a9 --- /dev/null +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { Department, DepartmentFormData } from "@/types/department"; +import * as departmentAPI from "@/lib/api/department"; + +interface DepartmentStructureProps { + companyCode: string; + selectedDepartment: Department | null; + onSelectDepartment: (department: Department | null) => void; +} + +/** + * 부서 구조 컴포넌트 (트리 형태) + */ +export function DepartmentStructure({ + companyCode, + selectedDepartment, + onSelectDepartment, +}: DepartmentStructureProps) { + const [departments, setDepartments] = useState([]); + const [expandedDepts, setExpandedDepts] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(false); + + // 부서 추가 모달 + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [parentDeptForAdd, setParentDeptForAdd] = useState(null); + const [newDeptName, setNewDeptName] = useState(""); + + // 부서 목록 로드 + useEffect(() => { + loadDepartments(); + }, [companyCode]); + + const loadDepartments = async () => { + setIsLoading(true); + try { + const response = await departmentAPI.getDepartments(companyCode); + if (response.success && response.data) { + setDepartments(response.data); + } else { + console.error("부서 목록 로드 실패:", response.error); + setDepartments([]); + } + } catch (error) { + console.error("부서 목록 로드 실패:", error); + setDepartments([]); + } finally { + setIsLoading(false); + } + }; + + // 부서 트리 구조 생성 + const buildTree = (parentCode: string | null): Department[] => { + return departments + .filter((dept) => dept.parent_dept_code === parentCode) + .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); + }; + + // 부서 추가 핸들러 + const handleAddDepartment = (parentDeptCode: string | null = null) => { + setParentDeptForAdd(parentDeptCode); + setNewDeptName(""); + setIsAddModalOpen(true); + }; + + // 부서 저장 + const handleSaveDepartment = async () => { + if (!newDeptName.trim()) return; + + try { + const response = await departmentAPI.createDepartment(companyCode, { + dept_name: newDeptName, + parent_dept_code: parentDeptForAdd, + }); + + if (response.success) { + setIsAddModalOpen(false); + loadDepartments(); + } else { + alert(response.error || "부서 추가에 실패했습니다."); + } + } catch (error) { + console.error("부서 추가 실패:", error); + alert("부서 추가 중 오류가 발생했습니다."); + } + }; + + // 부서 삭제 + const handleDeleteDepartment = async (deptCode: string) => { + if (!confirm("이 부서를 삭제하시겠습니까?")) return; + + try { + const response = await departmentAPI.deleteDepartment(deptCode); + + if (response.success) { + loadDepartments(); + } else { + alert(response.error || "부서 삭제에 실패했습니다."); + } + } catch (error) { + console.error("부서 삭제 실패:", error); + alert("부서 삭제 중 오류가 발생했습니다."); + } + }; + + // 확장/축소 토글 + const toggleExpand = (deptCode: string) => { + const newExpanded = new Set(expandedDepts); + if (newExpanded.has(deptCode)) { + newExpanded.delete(deptCode); + } else { + newExpanded.add(deptCode); + } + setExpandedDepts(newExpanded); + }; + + // 부서 트리 렌더링 (재귀) + const renderDepartmentTree = (parentCode: string | null, level: number = 0) => { + const children = buildTree(parentCode); + + return children.map((dept) => { + const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code); + const isExpanded = expandedDepts.has(dept.dept_code); + const isSelected = selectedDepartment?.dept_code === dept.dept_code; + + return ( +
+ {/* 부서 항목 */} +
+
onSelectDepartment(dept)} + > + {/* 확장/축소 아이콘 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 부서명 */} + {dept.dept_name} + + {/* 인원수 */} +
+ + {dept.memberCount || 0} +
+
+ + {/* 액션 버튼 */} +
+ + +
+
+ + {/* 하위 부서 (재귀) */} + {hasChildren && isExpanded && renderDepartmentTree(dept.dept_code, level + 1)} +
+ ); + }); + }; + + return ( +
+ {/* 헤더 */} +
+

부서 구조

+ +
+ + {/* 부서 트리 */} +
+ {isLoading ? ( +
로딩 중...
+ ) : departments.length === 0 ? ( +
+ 부서가 없습니다. 최상위 부서를 추가해주세요. +
+ ) : ( + renderDepartmentTree(null) + )} +
+ + {/* 부서 추가 모달 */} + + + + + {parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"} + + + +
+
+ + setNewDeptName(e.target.value)} + placeholder="부서명을 입력하세요" + autoFocus + /> +
+
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts new file mode 100644 index 00000000..a3f13962 --- /dev/null +++ b/frontend/lib/api/department.ts @@ -0,0 +1,131 @@ +/** + * 부서 관리 API 클라이언트 + */ + +import { apiClient } from "./client"; +import { Department, DepartmentMember, DepartmentFormData } from "@/types/department"; + +/** + * 부서 목록 조회 (회사별) + */ +export async function getDepartments(companyCode: string) { + try { + const url = `/departments/companies/${companyCode}/departments`; + const response = await apiClient.get<{ success: boolean; data: Department[] }>(url); + return response.data; + } catch (error: any) { + console.error("부서 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 상세 조회 + */ +export async function getDepartment(deptCode: string) { + try { + const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`); + return response.data; + } catch (error: any) { + console.error("부서 상세 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 생성 + */ +export async function createDepartment(companyCode: string, data: DepartmentFormData) { + try { + const response = await apiClient.post<{ success: boolean; data: Department }>( + `/departments/companies/${companyCode}/departments`, + data, + ); + return response.data; + } catch (error: any) { + console.error("부서 생성 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 수정 + */ +export async function updateDepartment(deptCode: string, data: DepartmentFormData) { + try { + const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data); + return response.data; + } catch (error: any) { + console.error("부서 수정 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 삭제 + */ +export async function deleteDepartment(deptCode: string) { + try { + const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`); + return response.data; + } catch (error: any) { + console.error("부서 삭제 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 목록 조회 + */ +export async function getDepartmentMembers(deptCode: string) { + try { + const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>( + `/departments/${deptCode}/members`, + ); + return response.data; + } catch (error: any) { + console.error("부서원 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 추가 + */ +export async function addDepartmentMember(deptCode: string, userId: string) { + try { + const response = await apiClient.post<{ success: boolean }>(`/departments/${deptCode}/members`, { + user_id: userId, + }); + return response.data; + } catch (error: any) { + console.error("부서원 추가 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 제거 + */ +export async function removeDepartmentMember(deptCode: string, userId: string) { + try { + const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`); + return response.data; + } catch (error: any) { + console.error("부서원 제거 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 주 부서 설정 + */ +export async function setPrimaryDepartment(deptCode: string, userId: string) { + try { + const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`); + return response.data; + } catch (error: any) { + console.error("주 부서 설정 실패:", error); + return { success: false, error: error.message }; + } +} diff --git a/frontend/types/department.ts b/frontend/types/department.ts new file mode 100644 index 00000000..0abc25d0 --- /dev/null +++ b/frontend/types/department.ts @@ -0,0 +1,69 @@ +/** + * 부서 관리 관련 타입 정의 + */ + +// 부서 정보 (dept_info 테이블 기반) +export interface Department { + dept_code: string; // 부서 코드 + dept_name: string; // 부서명 + company_code: string; // 회사 코드 + parent_dept_code?: string | null; // 상위 부서 코드 + sort_order?: number; // 정렬 순서 + created_at?: string; + updated_at?: string; + // UI용 추가 필드 + children?: Department[]; // 하위 부서 목록 + memberCount?: number; // 부서원 수 +} + +// 부서원 정보 +export interface DepartmentMember { + user_id: string; // 사용자 ID + user_name: string; // 사용자명 + dept_code: string; // 부서 코드 + dept_name: string; // 부서명 + is_primary: boolean; // 주 부서 여부 + position_name?: string; // 직책명 + email?: string; // 이메일 + phone?: string; // 전화번호 + cell_phone?: string; // 휴대폰 +} + +// 사용자-부서 매핑 (겸직 지원) +export interface UserDepartmentMapping { + user_id: string; + dept_code: string; + is_primary: boolean; // 주 부서 여부 + created_at?: string; +} + +// 부서 등록/수정 폼 데이터 +export interface DepartmentFormData { + dept_name: string; // 부서명 (필수) + parent_dept_code?: string | null; // 상위 부서 코드 +} + +// 부서 트리 노드 (UI용) +export interface DepartmentTreeNode { + dept_code: string; + dept_name: string; + parent_dept_code?: string | null; + children: DepartmentTreeNode[]; + memberCount: number; + isExpanded: boolean; +} + +// 부서 API 응답 +export interface DepartmentApiResponse { + success: boolean; + message: string; + data?: Department | Department[]; +} + +// 부서원 API 응답 +export interface DepartmentMemberApiResponse { + success: boolean; + message: string; + data?: DepartmentMember | DepartmentMember[]; +} +