부서 read 기능 구현
This commit is contained in:
@@ -64,6 +64,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
|||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
@@ -222,6 +223,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
|||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
|
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||||
// 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);
|
||||||
|
|||||||
458
backend-node/src/controllers/departmentController.ts
Normal file
458
backend-node/src/controllers/departmentController.ts
Normal file
@@ -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<void> {
|
||||||
|
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<any>(`
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
|
||||||
|
const department = await queryOne<any>(`
|
||||||
|
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<void> {
|
||||||
|
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<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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<void> {
|
||||||
|
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<any>(`
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
|
||||||
|
// 하위 부서 확인
|
||||||
|
const hasChildren = await queryOne<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
|
||||||
|
const members = await query<any>(`
|
||||||
|
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<void> {
|
||||||
|
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<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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<any>(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_dept
|
||||||
|
WHERE user_id = $1 AND is_primary = true
|
||||||
|
`, [user_id]);
|
||||||
|
|
||||||
|
// 부서원 추가
|
||||||
|
await query<any>(`
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode, userId } = req.params;
|
||||||
|
|
||||||
|
const result = await query<any>(`
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode, userId } = req.params;
|
||||||
|
|
||||||
|
// 다른 부서의 주 부서 해제
|
||||||
|
await query<any>(`
|
||||||
|
UPDATE user_dept
|
||||||
|
SET is_primary = false
|
||||||
|
WHERE user_id = $1
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
// 해당 부서를 주 부서로 설정
|
||||||
|
await query<any>(`
|
||||||
|
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: "주 부서 설정 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
43
backend-node/src/routes/departmentRoutes.ts
Normal file
43
backend-node/src/routes/departmentRoutes.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
@@ -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 <DepartmentManagement companyCode={companyCode} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 } from "@/types/company";
|
||||||
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface CompanyTableProps {
|
interface CompanyTableProps {
|
||||||
companies: Company[];
|
companies: Company[];
|
||||||
@@ -17,11 +18,18 @@ interface CompanyTableProps {
|
|||||||
* 모바일/태블릿: 카드 뷰
|
* 모바일/태블릿: 카드 뷰
|
||||||
*/
|
*/
|
||||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: 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) => {
|
const formatDiskUsage = (company: Company) => {
|
||||||
if (!company.diskUsage) {
|
if (!company.diskUsage) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
<HardDrive className="h-3 w-3" />
|
<HardDrive className="h-3 w-3" />
|
||||||
<span className="text-xs">정보 없음</span>
|
<span className="text-xs">정보 없음</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,11 +41,11 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FileText className="h-3 w-3 text-primary" />
|
<FileText className="text-primary h-3 w-3" />
|
||||||
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<HardDrive className="h-3 w-3 text-primary" />
|
<HardDrive className="text-primary h-3 w-3" />
|
||||||
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
|
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +57,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
<div className="hidden bg-card shadow-sm lg:block">
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -66,21 +74,21 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
{Array.from({ length: 10 }).map((_, index) => (
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||||
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -92,18 +100,18 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
|
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||||
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="flex justify-between">
|
<div key={i} className="flex justify-between">
|
||||||
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||||
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,9 +125,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
// 데이터가 없을 때
|
// 데이터가 없을 때
|
||||||
if (companies.length === 0) {
|
if (companies.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 flex-col items-center justify-center bg-card shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">등록된 회사가 없습니다.</p>
|
<p className="text-muted-foreground text-sm">등록된 회사가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -129,28 +137,40 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
<div className="hidden bg-card lg:block">
|
<div className="bg-card hidden lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{COMPANY_TABLE_COLUMNS.map((column) => (
|
{COMPANY_TABLE_COLUMNS.map((column) => (
|
||||||
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
||||||
{column.label}
|
{column.label}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">디스크 사용량</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">디스크 사용량</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<TableRow key={company.regdate + company.company_code} className="bg-background transition-colors hover:bg-muted/50">
|
<TableRow
|
||||||
|
key={company.regdate + company.company_code}
|
||||||
|
className="bg-background hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{company.company_code}</TableCell>
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{company.company_code}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm font-medium">{company.company_name}</TableCell>
|
<TableCell className="h-16 px-6 py-3 text-sm font-medium">{company.company_name}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">{company.writer}</TableCell>
|
<TableCell className="h-16 px-6 py-3 text-sm">{company.writer}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3">
|
<TableCell className="h-16 px-6 py-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleManageDepartments(company)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="부서관리"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -164,7 +184,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onDelete(company)}
|
onClick={() => onDelete(company)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
aria-label="삭제"
|
aria-label="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -182,13 +202,13 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<div
|
<div
|
||||||
key={company.regdate + company.company_code}
|
key={company.regdate + company.company_code}
|
||||||
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
|
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-base font-semibold">{company.company_name}</h3>
|
<h3 className="text-base font-semibold">{company.company_name}</h3>
|
||||||
<p className="mt-1 font-mono text-sm text-muted-foreground">{company.company_code}</p>
|
<p className="text-muted-foreground mt-1 font-mono text-sm">{company.company_code}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,9 +229,13 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onEdit(company)}
|
onClick={() => handleManageDepartments(company)}
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
>
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
부서
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onEdit(company)} className="h-9 flex-1 gap-2 text-sm">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
수정
|
수정
|
||||||
</Button>
|
</Button>
|
||||||
@@ -219,7 +243,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onDelete(company)}
|
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"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
|
|||||||
@@ -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<Department | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("structure");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 탭 네비게이션 (모바일용) */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="structure">부서 구조</TabsTrigger>
|
||||||
|
<TabsTrigger value="members">부서 인원</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="structure" className="mt-4">
|
||||||
|
<DepartmentStructure
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onSelectDepartment={setSelectedDepartment}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="members" className="mt-4">
|
||||||
|
<DepartmentMembers
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 좌우 레이아웃 (데스크톱) */}
|
||||||
|
<div className="hidden h-full gap-6 lg:flex">
|
||||||
|
{/* 좌측: 부서 구조 (30%) */}
|
||||||
|
<div className="w-[30%] border-r pr-6">
|
||||||
|
<DepartmentStructure
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onSelectDepartment={setSelectedDepartment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 부서 인원 (70%) */}
|
||||||
|
<div className="w-[70%] pl-0">
|
||||||
|
<DepartmentMembers
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
247
frontend/components/admin/department/DepartmentMembers.tsx
Normal file
247
frontend/components/admin/department/DepartmentMembers.tsx
Normal file
@@ -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<DepartmentMember[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card p-8 shadow-sm">
|
||||||
|
<p className="text-sm text-muted-foreground">좌측에서 부서를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{selectedDepartment.dept_name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">부서원 {members.length}명</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => setIsAddModalOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
부서원 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서원 목록 */}
|
||||||
|
<div className="space-y-2 rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">로딩 중...</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
부서원이 없습니다. 부서원을 추가해주세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.user_id}
|
||||||
|
className="flex items-center justify-between rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{member.user_name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">({member.user_id})</span>
|
||||||
|
{member.is_primary && (
|
||||||
|
<Badge variant="default" className="h-5 gap-1 text-xs">
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
주 부서
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-4 text-xs text-muted-foreground">
|
||||||
|
{member.position_name && <span>직책: {member.position_name}</span>}
|
||||||
|
{member.email && <span>이메일: {member.email}</span>}
|
||||||
|
{member.phone && <span>전화: {member.phone}</span>}
|
||||||
|
{member.cell_phone && <span>휴대폰: {member.cell_phone}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!member.is_primary && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-xs"
|
||||||
|
onClick={() => handleSetPrimaryDepartment(member.user_id)}
|
||||||
|
>
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
주 부서 설정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => handleRemoveMember(member.user_id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서원 추가 모달 */}
|
||||||
|
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>부서원 추가</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user_id">
|
||||||
|
사용자 ID <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="user_id"
|
||||||
|
value={searchUserId}
|
||||||
|
onChange={(e) => setSearchUserId(e.target.value)}
|
||||||
|
placeholder="사용자 ID를 입력하세요"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
겸직이 가능합니다. 한 사용자를 여러 부서에 추가할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddMember} disabled={!searchUserId.trim()}>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
275
frontend/components/admin/department/DepartmentStructure.tsx
Normal file
275
frontend/components/admin/department/DepartmentStructure.tsx
Normal file
@@ -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<Department[]>([]);
|
||||||
|
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 부서 추가 모달
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(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 (
|
||||||
|
<div key={dept.dept_code}>
|
||||||
|
{/* 부서 항목 */}
|
||||||
|
<div
|
||||||
|
className={`flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors hover:bg-muted ${
|
||||||
|
isSelected ? "bg-primary/10 text-primary" : ""
|
||||||
|
}`}
|
||||||
|
style={{ marginLeft: `${level * 16}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
onClick={() => onSelectDepartment(dept)}
|
||||||
|
>
|
||||||
|
{/* 확장/축소 아이콘 */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(dept.dept_code);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 부서명 */}
|
||||||
|
<span className="font-medium">{dept.dept_name}</span>
|
||||||
|
|
||||||
|
{/* 인원수 */}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
<span>{dept.memberCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAddDepartment(dept.dept_code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteDepartment(dept.dept_code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하위 부서 (재귀) */}
|
||||||
|
{hasChildren && isExpanded && renderDepartmentTree(dept.dept_code, level + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">부서 구조</h3>
|
||||||
|
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
최상위 부서 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 트리 */}
|
||||||
|
<div className="space-y-1 rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">로딩 중...</div>
|
||||||
|
) : departments.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
부서가 없습니다. 최상위 부서를 추가해주세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderDepartmentTree(null)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 추가 모달 */}
|
||||||
|
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dept_name">
|
||||||
|
부서명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dept_name"
|
||||||
|
value={newDeptName}
|
||||||
|
onChange={(e) => setNewDeptName(e.target.value)}
|
||||||
|
placeholder="부서명을 입력하세요"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveDepartment} disabled={!newDeptName.trim()}>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
131
frontend/lib/api/department.ts
Normal file
131
frontend/lib/api/department.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/types/department.ts
Normal file
69
frontend/types/department.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user