Merge pull request '회사 보기 기능 구현' (#179) from feat/dashboard into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/179
This commit is contained in:
hyeonsu 2025-11-04 14:34:50 +09:00
commit 1e7be6c61c
28 changed files with 3208 additions and 104 deletions

View File

@ -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);

View File

@ -8,6 +8,7 @@ import config from "../config/environment";
import { AdminService } from "../services/adminService"; import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil"; import { EncryptUtil } from "../utils/encryptUtil";
import { FileSystemManager } from "../utils/fileSystemManager"; import { FileSystemManager } from "../utils/fileSystemManager";
import { validateBusinessNumber } from "../utils/businessNumberValidator";
/** /**
* *
@ -609,9 +610,15 @@ export const getCompanyList = async (
// Raw Query로 회사 목록 조회 // Raw Query로 회사 목록 조회
const companies = await query<any>( const companies = await query<any>(
`SELECT ` SELECT
company_code, company_code,
company_name, company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
status, status,
writer, writer,
regdate regdate
@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB(
// Raw Query로 회사 목록 조회 // Raw Query로 회사 목록 조회
const companies = await query<any>( const companies = await query<any>(
`SELECT ` SELECT
company_code, company_code,
company_name, company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
writer, writer,
regdate, regdate,
status status
@ -2440,6 +2453,25 @@ export const createCompany = async (
[company_name.trim()] [company_name.trim()]
); );
// 사업자등록번호 유효성 검증
const businessNumberValidation = validateBusinessNumber(
req.body.business_registration_number?.trim() || ""
);
if (!businessNumberValidation.isValid) {
res.status(400).json({
success: false,
message: businessNumberValidation.message,
errorCode: "INVALID_BUSINESS_NUMBER",
});
return;
}
// Raw Query로 사업자등록번호 중복 체크
const existingBusinessNumber = await queryOne<any>(
`SELECT company_code FROM company_mng WHERE business_registration_number = $1`,
[req.body.business_registration_number?.trim()]
);
if (existingCompany) { if (existingCompany) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@ -2449,6 +2481,15 @@ export const createCompany = async (
return; return;
} }
if (existingBusinessNumber) {
res.status(400).json({
success: false,
message: "이미 등록된 사업자등록번호입니다.",
errorCode: "DUPLICATE_BUSINESS_NUMBER",
});
return;
}
// PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용) // PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용)
const client = new Client({ const client = new Client({
connectionString: connectionString:
@ -2474,11 +2515,17 @@ export const createCompany = async (
const insertQuery = ` const insertQuery = `
INSERT INTO company_mng ( INSERT INTO company_mng (
company_code, company_code,
company_name, company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
writer, writer,
regdate, regdate,
status status
) VALUES ($1, $2, $3, $4, $5) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING * RETURNING *
`; `;
@ -2488,6 +2535,12 @@ export const createCompany = async (
const insertValues = [ const insertValues = [
companyCode, companyCode,
company_name.trim(), company_name.trim(),
req.body.business_registration_number?.trim() || null,
req.body.representative_name?.trim() || null,
req.body.representative_phone?.trim() || null,
req.body.email?.trim() || null,
req.body.website?.trim() || null,
req.body.address?.trim() || null,
writer, writer,
new Date(), new Date(),
"active", "active",
@ -2552,7 +2605,16 @@ export const updateCompany = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const { companyCode } = req.params; const { companyCode } = req.params;
const { company_name, status } = req.body; const {
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
status,
} = req.body;
logger.info("회사 정보 수정 요청", { logger.info("회사 정보 수정 요청", {
companyCode, companyCode,
@ -2586,13 +2648,61 @@ export const updateCompany = async (
return; return;
} }
// 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외)
if (business_registration_number && business_registration_number.trim()) {
// 유효성 검증
const businessNumberValidation = validateBusinessNumber(business_registration_number.trim());
if (!businessNumberValidation.isValid) {
res.status(400).json({
success: false,
message: businessNumberValidation.message,
errorCode: "INVALID_BUSINESS_NUMBER",
});
return;
}
// 중복 체크
const duplicateBusinessNumber = await queryOne<any>(
`SELECT company_code FROM company_mng
WHERE business_registration_number = $1 AND company_code != $2`,
[business_registration_number.trim(), companyCode]
);
if (duplicateBusinessNumber) {
res.status(400).json({
success: false,
message: "이미 등록된 사업자등록번호입니다.",
errorCode: "DUPLICATE_BUSINESS_NUMBER",
});
return;
}
}
// Raw Query로 회사 정보 수정 // Raw Query로 회사 정보 수정
const result = await query<any>( const result = await query<any>(
`UPDATE company_mng `UPDATE company_mng
SET company_name = $1, status = $2 SET
WHERE company_code = $3 company_name = $1,
business_registration_number = $2,
representative_name = $3,
representative_phone = $4,
email = $5,
website = $6,
address = $7,
status = $8
WHERE company_code = $9
RETURNING *`, RETURNING *`,
[company_name.trim(), status || "active", companyCode] [
company_name.trim(),
business_registration_number?.trim() || null,
representative_name?.trim() || null,
representative_phone?.trim() || null,
email?.trim() || null,
website?.trim() || null,
address?.trim() || null,
status || "active",
companyCode,
]
); );
if (result.length === 0) { if (result.length === 0) {

View File

@ -0,0 +1,534 @@
import { Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { query, queryOne } from "../database/db";
/**
* ()
*/
export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise<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;
}
// 같은 회사 내 중복 부서명 확인
const duplicate = await queryOne<any>(`
SELECT dept_code, dept_name
FROM dept_info
WHERE company_code = $1 AND dept_name = $2
`, [companyCode, dept_name.trim()]);
if (duplicate) {
res.status(409).json({
success: false,
message: `"${dept_name}" 부서가 이미 존재합니다.`,
isDuplicate: true,
});
return;
}
// 회사 이름 조회
const company = await queryOne<any>(`
SELECT company_name FROM company_mng WHERE company_code = $1
`, [companyCode]);
const companyName = company?.company_name || companyCode;
// 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...)
const codeResult = await queryOne<any>(`
SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number
FROM dept_info
WHERE dept_code ~ '^DEPT_[0-9]+$'
`);
const nextNumber = codeResult?.next_number || 1;
const deptCode = `DEPT_${nextNumber}`;
// 부서 생성
const result = await query<any>(`
INSERT INTO dept_info (
dept_code,
dept_name,
company_code,
company_name,
parent_dept_code,
status,
regdate
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING *
`, [
deptCode,
dept_name.trim(),
companyCode,
companyName,
parent_dept_code || null,
'active',
]);
logger.info("부서 생성 성공", { deptCode, dept_name });
res.status(201).json({
success: true,
message: "부서가 생성되었습니다.",
data: result[0],
});
} catch (error) {
logger.error("부서 생성 실패", error);
res.status(500).json({
success: false,
message: "부서 생성 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise<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 deletedMembers = await query<any>(`
DELETE FROM user_dept
WHERE dept_code = $1
RETURNING user_id
`, [deptCode]);
const memberCount = deletedMembers.length;
// 부서 삭제
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,
deptName: result[0].dept_name,
deletedMemberCount: memberCount
});
res.status(200).json({
success: true,
message: memberCount > 0
? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)`
: "부서가 삭제되었습니다.",
});
} catch (error) {
logger.error("부서 삭제 실패", error);
res.status(500).json({
success: false,
message: "부서 삭제 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise<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 searchUsers(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { companyCode } = req.params;
const { search } = req.query;
if (!search || typeof search !== 'string') {
res.status(400).json({
success: false,
message: "검색어를 입력해주세요.",
});
return;
}
// 사용자 검색 (ID 또는 이름)
const users = await query<any>(`
SELECT
user_id,
user_name,
email,
position_name,
company_code
FROM user_info
WHERE company_code = $1
AND (
user_id ILIKE $2 OR
user_name ILIKE $2
)
ORDER BY user_name
LIMIT 20
`, [companyCode, `%${search}%`]);
res.status(200).json({
success: true,
data: users,
});
} catch (error) {
logger.error("사용자 검색 실패", error);
res.status(500).json({
success: false,
message: "사용자 검색 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<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(409).json({
success: false,
message: "이미 해당 부서의 부서원입니다.",
isDuplicate: true,
});
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: "주 부서 설정 중 오류가 발생했습니다.",
});
}
}

View File

@ -12,6 +12,7 @@ import {
ColumnListResponse, ColumnListResponse,
ColumnSettingsResponse, ColumnSettingsResponse,
} from "../types/tableManagement"; } from "../types/tableManagement";
import { query } from "../database/db"; // 🆕 query 함수 import
/** /**
* *
@ -506,7 +507,91 @@ export async function updateColumnInputType(
} }
/** /**
* ( + ) * ( )
*/
export async function getTableRecord(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { filterColumn, filterValue, displayColumn } = req.body;
logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`);
logger.info(`필터: ${filterColumn} = ${filterValue}`);
logger.info(`표시 컬럼: ${displayColumn}`);
if (!tableName || !filterColumn || !filterValue || !displayColumn) {
const response: ApiResponse<null> = {
success: false,
message: "필수 파라미터가 누락되었습니다.",
error: {
code: "MISSING_PARAMETERS",
details:
"tableName, filterColumn, filterValue, displayColumn이 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 단일 레코드 조회 (WHERE filterColumn = filterValue)
const result = await tableManagementService.getTableData(tableName, {
page: 1,
size: 1,
search: {
[filterColumn]: filterValue,
},
});
if (!result.data || result.data.length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "데이터를 찾을 수 없습니다.",
error: {
code: "NOT_FOUND",
details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`,
},
};
res.status(404).json(response);
return;
}
const record = result.data[0];
const displayValue = record[displayColumn];
logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`);
const response: ApiResponse<{ value: any; record: any }> = {
success: true,
message: "레코드를 성공적으로 조회했습니다.",
data: {
value: displayValue,
record: record,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("레코드 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "레코드 조회 중 오류가 발생했습니다.",
error: {
code: "RECORD_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* ( + + )
*/ */
export async function getTableData( export async function getTableData(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -520,12 +605,14 @@ export async function getTableData(
search = {}, search = {},
sortBy, sortBy,
sortOrder = "asc", sortOrder = "asc",
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
} = req.body; } = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
logger.info(`페이징: page=${page}, size=${size}`); logger.info(`페이징: page=${page}, size=${size}`);
logger.info(`검색 조건:`, search); logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`); logger.info(`정렬: ${sortBy} ${sortOrder}`);
logger.info(`자동 필터:`, autoFilter); // 🆕
if (!tableName) { if (!tableName) {
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
@ -542,11 +629,35 @@ export async function getTableData(
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
// 🆕 현재 사용자 필터 적용
let enhancedSearch = { ...search };
if (autoFilter?.enabled && req.user) {
const filterColumn = autoFilter.filterColumn || "company_code";
const userField = autoFilter.userField || "companyCode";
const userValue = (req.user as any)[userField];
if (userValue) {
enhancedSearch[filterColumn] = userValue;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue,
tableName,
});
} else {
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
userField,
user: req.user,
});
}
}
// 데이터 조회 // 데이터 조회
const result = await tableManagementService.getTableData(tableName, { const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page), page: parseInt(page),
size: parseInt(size), size: parseInt(size),
search, search: enhancedSearch, // 🆕 필터가 적용된 search 사용
sortBy, sortBy,
sortOrder, sortOrder,
}); });
@ -1216,9 +1327,7 @@ export async function getLogData(
originalId: originalId as string, originalId: originalId as string,
}); });
logger.info( logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}`);
`로그 데이터 조회 완료: ${tableName}_log, ${result.total}`
);
const response: ApiResponse<typeof result> = { const response: ApiResponse<typeof result> = {
success: true, success: true,
@ -1254,7 +1363,9 @@ export async function toggleLogTable(
const { tableName } = req.params; const { tableName } = req.params;
const { isActive } = req.body; const { isActive } = req.body;
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); logger.info(
`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`
);
if (!tableName) { if (!tableName) {
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
@ -1288,9 +1399,7 @@ export async function toggleLogTable(
isActive === "Y" || isActive === true isActive === "Y" || isActive === true
); );
logger.info( logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`);
`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`
);
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: true, success: true,

View File

@ -0,0 +1,46 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as departmentController from "../controllers/departmentController";
const router = Router();
// 인증 미들웨어 적용
router.use(authenticateToken);
/**
* API
* : /api/departments
*/
// 부서 목록 조회 (회사별)
router.get("/companies/:companyCode/departments", departmentController.getDepartments);
// 부서 상세 조회
router.get("/:deptCode", departmentController.getDepartment);
// 부서 생성
router.post("/companies/:companyCode/departments", departmentController.createDepartment);
// 부서 수정
router.put("/:deptCode", departmentController.updateDepartment);
// 부서 삭제
router.delete("/:deptCode", departmentController.deleteDepartment);
// 부서원 목록 조회
router.get("/:deptCode/members", departmentController.getDepartmentMembers);
// 사용자 검색 (부서원 추가용)
router.get("/companies/:companyCode/users/search", departmentController.searchUsers);
// 부서원 추가
router.post("/:deptCode/members", departmentController.addDepartmentMember);
// 부서원 제거
router.delete("/:deptCode/members/:userId", departmentController.removeDepartmentMember);
// 주 부서 설정
router.put("/:deptCode/members/:userId/primary", departmentController.setPrimaryDepartment);
export default router;

View File

@ -11,6 +11,7 @@ import {
updateColumnInputType, updateColumnInputType,
updateTableLabel, updateTableLabel,
getTableData, getTableData,
getTableRecord, // 🆕 단일 레코드 조회
addTableData, addTableData,
editTableData, editTableData,
deleteTableData, deleteTableData,
@ -134,6 +135,12 @@ router.get("/health", checkDatabaseConnection);
*/ */
router.post("/tables/:tableName/data", getTableData); router.post("/tables/:tableName/data", getTableData);
/**
* ( )
* POST /api/table-management/tables/:tableName/record
*/
router.post("/tables/:tableName/record", getTableRecord);
/** /**
* *
* POST /api/table-management/tables/:tableName/add * POST /api/table-management/tables/:tableName/add

View File

@ -0,0 +1,52 @@
/**
* ()
*/
/**
*
*/
export function validateBusinessNumberFormat(value: string): boolean {
if (!value || value.trim() === "") {
return false;
}
// 하이픈 제거
const cleaned = value.replace(/-/g, "");
// 숫자 10자리인지 확인
if (!/^\d{10}$/.test(cleaned)) {
return false;
}
return true;
}
/**
* ( )
* API
*/
export function validateBusinessNumber(value: string): {
isValid: boolean;
message: string;
} {
if (!value || value.trim() === "") {
return {
isValid: false,
message: "사업자등록번호를 입력해주세요.",
};
}
if (!validateBusinessNumberFormat(value)) {
return {
isValid: false,
message: "사업자등록번호는 10자리 숫자여야 합니다.",
};
}
// 포맷만 검증하고 통과
return {
isValid: true,
message: "",
};
}

View File

@ -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} />;
}

View File

@ -147,6 +147,57 @@ export default function ScreenViewPage() {
} }
}, [screenId]); }, [screenId]);
// 🆕 autoFill 자동 입력 초기화
useEffect(() => {
const initAutoFill = async () => {
if (!layout || !layout.components || !user) {
return;
}
for (const comp of layout.components) {
// type: "component" 또는 type: "widget" 모두 처리
if (comp.type === 'widget' || comp.type === 'component') {
const widget = comp as any;
const fieldName = widget.columnName || widget.id;
// autoFill 처리
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
const currentValue = formData[fieldName];
if (currentValue === undefined || currentValue === '') {
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
// 사용자 정보에서 필터 값 가져오기
const userValue = user?.[userField as keyof typeof user];
if (userValue && sourceTable && filterColumn && displayColumn) {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const result = await tableTypeApi.getTableRecord(
sourceTable,
filterColumn,
userValue,
displayColumn
);
setFormData((prev) => ({
...prev,
[fieldName]: result.value,
}));
} catch (error) {
console.error(`autoFill 조회 실패: ${fieldName}`, error);
}
}
}
}
}
}
};
initAutoFill();
}, [layout, user]);
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화 // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
useEffect(() => { useEffect(() => {
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)

View File

@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
interface CompanyFormModalProps { interface CompanyFormModalProps {
modalState: CompanyModalState; modalState: CompanyModalState;
@ -29,6 +30,7 @@ export function CompanyFormModal({
onClearError, onClearError,
}: CompanyFormModalProps) { }: CompanyFormModalProps) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [businessNumberError, setBusinessNumberError] = useState<string>("");
// 모달이 열려있지 않으면 렌더링하지 않음 // 모달이 열려있지 않으면 렌더링하지 않음
if (!modalState.isOpen) return null; if (!modalState.isOpen) return null;
@ -36,15 +38,43 @@ export function CompanyFormModal({
const { mode, formData, selectedCompany } = modalState; const { mode, formData, selectedCompany } = modalState;
const isEditMode = mode === "edit"; const isEditMode = mode === "edit";
// 사업자등록번호 변경 처리
const handleBusinessNumberChange = (value: string) => {
// 자동 포맷팅
const formatted = formatBusinessNumber(value);
onFormChange("business_registration_number", formatted);
// 유효성 검사 (10자리가 다 입력되었을 때만)
const cleaned = formatted.replace(/-/g, "");
if (cleaned.length === 10) {
const validation = validateBusinessNumber(formatted);
setBusinessNumberError(validation.isValid ? "" : validation.message);
} else if (cleaned.length < 10 && businessNumberError) {
// 10자리 미만이면 에러 초기화
setBusinessNumberError("");
}
};
// 저장 처리 // 저장 처리
const handleSave = async () => { const handleSave = async () => {
// 입력값 검증 // 입력값 검증 (필수 필드)
if (!formData.company_name.trim()) { if (!formData.company_name.trim()) {
return; return;
} }
if (!formData.business_registration_number.trim()) {
return;
}
// 사업자등록번호 최종 검증
const validation = validateBusinessNumber(formData.business_registration_number);
if (!validation.isValid) {
setBusinessNumberError(validation.message);
return;
}
setIsSaving(true); setIsSaving(true);
onClearError(); onClearError();
setBusinessNumberError("");
try { try {
const success = await onSave(); const success = await onSave();
@ -81,7 +111,7 @@ export function CompanyFormModal({
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
{/* 회사명 입력 */} {/* 회사명 입력 (필수) */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="company_name"> <Label htmlFor="company_name">
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
@ -97,10 +127,94 @@ export function CompanyFormModal({
/> />
</div> </div>
{/* 사업자등록번호 입력 (필수) */}
<div className="space-y-2">
<Label htmlFor="business_registration_number">
<span className="text-destructive">*</span>
</Label>
<Input
id="business_registration_number"
value={formData.business_registration_number || ""}
onChange={(e) => handleBusinessNumberChange(e.target.value)}
placeholder="000-00-00000"
disabled={isLoading || isSaving}
maxLength={12}
className={businessNumberError ? "border-destructive" : ""}
/>
{businessNumberError ? (
<p className="text-xs text-destructive">{businessNumberError}</p>
) : (
<p className="text-xs text-muted-foreground">10 ( )</p>
)}
</div>
{/* 대표자명 입력 */}
<div className="space-y-2">
<Label htmlFor="representative_name"></Label>
<Input
id="representative_name"
value={formData.representative_name || ""}
onChange={(e) => onFormChange("representative_name", e.target.value)}
placeholder="대표자명을 입력하세요"
disabled={isLoading || isSaving}
/>
</div>
{/* 대표 연락처 입력 */}
<div className="space-y-2">
<Label htmlFor="representative_phone"> </Label>
<Input
id="representative_phone"
value={formData.representative_phone || ""}
onChange={(e) => onFormChange("representative_phone", e.target.value)}
placeholder="010-0000-0000"
disabled={isLoading || isSaving}
type="tel"
/>
</div>
{/* 이메일 입력 */}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
value={formData.email || ""}
onChange={(e) => onFormChange("email", e.target.value)}
placeholder="company@example.com"
disabled={isLoading || isSaving}
type="email"
/>
</div>
{/* 웹사이트 입력 */}
<div className="space-y-2">
<Label htmlFor="website"></Label>
<Input
id="website"
value={formData.website || ""}
onChange={(e) => onFormChange("website", e.target.value)}
placeholder="https://example.com"
disabled={isLoading || isSaving}
type="url"
/>
</div>
{/* 회사 주소 입력 */}
<div className="space-y-2">
<Label htmlFor="address"> </Label>
<Input
id="address"
value={formData.address || ""}
onChange={(e) => onFormChange("address", e.target.value)}
placeholder="서울특별시 강남구..."
disabled={isLoading || isSaving}
/>
</div>
{/* 에러 메시지 */} {/* 에러 메시지 */}
{error && ( {error && (
<div className="bg-destructive/10 rounded-md p-3"> <div className="rounded-md bg-destructive/10 p-3">
<p className="text-destructive text-sm">{error}</p> <p className="text-sm text-destructive">{error}</p>
</div> </div>
)} )}
@ -129,7 +243,13 @@ export function CompanyFormModal({
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isLoading || isSaving || !formData.company_name.trim()} disabled={
isLoading ||
isSaving ||
!formData.company_name.trim() ||
!formData.business_registration_number.trim() ||
!!businessNumberError
}
className="min-w-[80px]" className="min-w-[80px]"
> >
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />} {(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}

View File

@ -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" />

View File

@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "./DepartmentStructure";
import { DepartmentMembers } from "./DepartmentMembers";
import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company";
interface DepartmentManagementProps {
companyCode: string;
}
/**
*
* 좌측: 부서 , 우측: 부서
*/
export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
const router = useRouter();
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드
useEffect(() => {
const loadCompanyInfo = async () => {
const response = await getCompanyList();
if (response.success && response.data) {
const company = response.data.find((c) => c.company_code === companyCode);
if (company) {
setCompanyName(company.company_name);
}
}
};
loadCompanyInfo();
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/company");
};
return (
<div className="space-y-4">
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="bg-border h-6 w-px" />
<div>
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
{/* 탭 네비게이션 (모바일용) */}
<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}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
<TabsContent value="members" className="mt-4">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent>
</Tabs>
</div>
{/* 좌우 레이아웃 (데스크톱) */}
<div className="hidden h-full gap-6 lg:flex">
{/* 좌측: 부서 구조 (20%) */}
<div className="w-[20%] border-r pr-6">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</div>
{/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,452 @@
"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 { useToast } from "@/hooks/use-toast";
import type { Department, DepartmentMember } from "@/types/department";
import * as departmentAPI from "@/lib/api/department";
interface DepartmentMembersProps {
companyCode: string;
selectedDepartment: Department | null;
onMemberChange?: () => void;
}
/**
*
*/
export function DepartmentMembers({
companyCode,
selectedDepartment,
onMemberChange,
}: DepartmentMembersProps) {
const { toast } = useToast();
const [members, setMembers] = useState<DepartmentMember[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false); // 검색 버튼을 눌렀는지 여부
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
// 부서원 삭제 확인 모달
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; name: string } | null>(null);
// 부서원 목록 로드
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 handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
setHasSearched(false);
return;
}
setIsSearching(true);
setHasSearched(true); // 검색 버튼을 눌렀음을 표시
try {
const response = await departmentAPI.searchUsers(companyCode, searchQuery);
if (response.success && response.data) {
setSearchResults(response.data);
} else {
setSearchResults([]);
}
} catch (error) {
console.error("사용자 검색 실패:", error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
// 부서원 추가
const handleAddMember = async (userId: string) => {
if (!selectedDepartment) return;
try {
const response = await departmentAPI.addDepartmentMember(
selectedDepartment.dept_code,
userId
);
if (response.success) {
setIsAddModalOpen(false);
setSearchQuery("");
setSearchResults([]);
setHasSearched(false); // 검색 상태 초기화
loadMembers();
onMemberChange?.(); // 부서 구조 새로고침
// 성공 Toast 표시
toast({
title: "부서원 추가 완료",
description: "부서원이 추가되었습니다.",
variant: "default",
});
} else {
if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다.");
} else {
toast({
title: "부서원 추가 실패",
description: response.error || "부서원 추가에 실패했습니다.",
variant: "destructive",
});
}
}
} catch (error) {
console.error("부서원 추가 실패:", error);
toast({
title: "부서원 추가 실패",
description: "부서원 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서원 제거 확인 요청
const handleRemoveMemberRequest = (userId: string, userName: string) => {
setMemberToRemove({ userId, name: userName });
setRemoveConfirmOpen(true);
};
// 부서원 제거 실행
const handleRemoveMemberConfirm = async () => {
if (!selectedDepartment || !memberToRemove) return;
try {
const response = await departmentAPI.removeDepartmentMember(
selectedDepartment.dept_code,
memberToRemove.userId
);
if (response.success) {
setRemoveConfirmOpen(false);
setMemberToRemove(null);
loadMembers();
onMemberChange?.(); // 부서 구조 새로고침
// 성공 Toast 표시
toast({
title: "부서원 제거 완료",
description: `${memberToRemove.name} 님이 부서에서 제외되었습니다.`,
variant: "default",
});
} else {
toast({
title: "부서원 제거 실패",
description: response.error || "부서원 제거에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서원 제거 실패:", error);
toast({
title: "부서원 제거 실패",
description: "부서원 제거 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 주 부서 설정
const handleSetPrimaryDepartment = async (userId: string) => {
if (!selectedDepartment) return;
try {
const response = await departmentAPI.setPrimaryDepartment(
selectedDepartment.dept_code,
userId
);
if (response.success) {
loadMembers();
// 성공 Toast 표시
toast({
title: "주 부서 설정 완료",
description: "주 부서가 변경되었습니다.",
variant: "default",
});
} else {
toast({
title: "주 부서 설정 실패",
description: response.error || "주 부서 설정에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("주 부서 설정 실패:", error);
toast({
title: "주 부서 설정 실패",
description: "주 부서 설정 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
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={() => handleRemoveMemberRequest(member.user_id, member.user_name)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 부서원 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 검색 입력 */}
<div className="space-y-2">
<Label htmlFor="search" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<Input
id="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
placeholder="이름 또는 ID로 검색"
className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus
/>
<Button
onClick={handleSearch}
disabled={!searchQuery.trim() || isSearching}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</div>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
{/* 검색 결과 */}
{isSearching ? (
<div className="py-8 text-center text-sm text-muted-foreground"> ...</div>
) : hasSearched && searchResults.length > 0 ? (
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border p-2">
{searchResults.map((user) => (
<div
key={user.user_id}
className="flex cursor-pointer items-center justify-between rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
onClick={() => handleAddMember(user.user_id)}
>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{user.user_name}</span>
<span className="text-sm text-muted-foreground">({user.user_id})</span>
</div>
<div className="mt-1 flex gap-3 text-xs text-muted-foreground">
{user.position_name && <span>{user.position_name}</span>}
{user.email && <span>{user.email}</span>}
</div>
</div>
<Plus className="h-4 w-4 text-muted-foreground" />
</div>
))}
</div>
) : hasSearched && searchResults.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
.
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsAddModalOpen(false);
setSearchQuery("");
setSearchResults([]);
setHasSearched(false); // 검색 상태 초기화
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 중복 알림 모달 */}
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{duplicateMessage}</p>
</div>
<DialogFooter>
<Button
onClick={() => setDuplicateMessage(null)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서원 제거 확인 모달 */}
<Dialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">
<span className="font-semibold">{memberToRemove?.name}</span> ?
</p>
<p className="mt-2 text-xs text-muted-foreground">
.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setRemoveConfirmOpen(false);
setMemberToRemove(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleRemoveMemberConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,383 @@
"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 { useToast } from "@/hooks/use-toast";
import type { Department } from "@/types/department";
import * as departmentAPI from "@/lib/api/department";
interface DepartmentStructureProps {
companyCode: string;
selectedDepartment: Department | null;
onSelectDepartment: (department: Department | null) => void;
refreshTrigger?: number;
}
/**
* ( )
*/
export function DepartmentStructure({
companyCode,
selectedDepartment,
onSelectDepartment,
refreshTrigger,
}: DepartmentStructureProps) {
const { toast } = useToast();
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("");
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
// 부서 삭제 확인 모달
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드
useEffect(() => {
loadDepartments();
}, [companyCode, refreshTrigger]);
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);
setNewDeptName("");
setParentDeptForAdd(null);
loadDepartments();
// 성공 Toast 표시
toast({
title: "부서 생성 완료",
description: `"${newDeptName}" 부서가 생성되었습니다.`,
variant: "default",
});
} else {
if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 존재하는 부서명입니다.");
} else {
toast({
title: "부서 생성 실패",
description: response.error || "부서 추가에 실패했습니다.",
variant: "destructive",
});
}
}
} catch (error) {
console.error("부서 추가 실패:", error);
toast({
title: "부서 생성 실패",
description: "부서 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 삭제 확인 요청
const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => {
setDeptToDelete({ code: deptCode, name: deptName });
setDeleteConfirmOpen(true);
};
// 부서 삭제 실행
const handleDeleteDepartmentConfirm = async () => {
if (!deptToDelete) return;
try {
const response = await departmentAPI.deleteDepartment(deptToDelete.code);
if (response.success) {
// 삭제된 부서가 선택되어 있었다면 선택 해제
if (selectedDepartment?.dept_code === deptToDelete.code) {
onSelectDepartment(null);
}
setDeleteConfirmOpen(false);
setDeptToDelete(null);
loadDepartments();
// 성공 메시지 Toast로 표시 (부서원 수 포함)
toast({
title: "부서 삭제 완료",
description: response.message || "부서가 삭제되었습니다.",
variant: "default",
});
} else {
// 삭제 확인 모달을 닫고 에러 모달을 표시
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage(response.error || "부서 삭제에 실패했습니다.");
}
} catch (error) {
console.error("부서 삭제 실패:", error);
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다.");
}
};
// 확장/축소 토글
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={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
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="text-muted-foreground flex items-center gap-1 text-xs">
<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="text-destructive h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<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="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : departments.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
. .
</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>
{/* 중복 알림 모달 */}
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{duplicateMessage}</p>
</div>
<DialogFooter>
<Button onClick={() => setDuplicateMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서 삭제 확인 모달 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false);
setDeptToDelete(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteDepartmentConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서 삭제 에러 모달 */}
<Dialog open={!!deleteErrorMessage} onOpenChange={() => setDeleteErrorMessage(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{deleteErrorMessage}</p>
</div>
<DialogFooter>
<Button onClick={() => setDeleteErrorMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -485,6 +485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
page, page,
size: pageSize, size: pageSize,
search: searchParams, search: searchParams,
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
}); });
setData(result.data); setData(result.data);
@ -576,7 +577,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false); setLoading(false);
} }
}, },
[component.tableName, pageSize], [component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가
); );
// 현재 사용자 정보 로드 // 현재 사용자 정보 로드

View File

@ -36,7 +36,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
import { FileUpload } from "./widgets/FileUpload"; import { FileUpload } from "./widgets/FileUpload";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen"; import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer"; import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
import { enhancedFormService } from "@/lib/services/enhancedFormService"; import { enhancedFormService } from "@/lib/services/enhancedFormService";
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator"; import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
@ -237,14 +237,46 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 자동입력 필드들의 값을 formData에 초기 설정 // 자동입력 필드들의 값을 formData에 초기 설정
React.useEffect(() => { React.useEffect(() => {
// console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length); // console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
const initAutoInputFields = () => { const initAutoInputFields = async () => {
// console.log("🔧 initAutoInputFields 실행 시작"); // console.log("🔧 initAutoInputFields 실행 시작");
allComponents.forEach(comp => { for (const comp of allComponents) {
if (comp.type === 'widget') { // 🆕 type: "component" 또는 type: "widget" 모두 처리
if (comp.type === 'widget' || comp.type === 'component') {
const widget = comp as WidgetComponent; const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id; const fieldName = widget.columnName || widget.id;
// 텍스트 타입 위젯의 자동입력 처리 // 🆕 autoFill 처리 (테이블 조회 기반 자동 입력)
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
const currentValue = formData[fieldName];
if (currentValue === undefined || currentValue === '') {
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
// 사용자 정보에서 필터 값 가져오기
const userValue = user?.[userField];
if (userValue && sourceTable && filterColumn && displayColumn) {
try {
const result = await tableTypeApi.getTableRecord(
sourceTable,
filterColumn,
userValue,
displayColumn
);
updateFormData(fieldName, result.value);
} catch (error) {
console.error(`autoFill 조회 실패: ${fieldName}`, error);
}
}
}
continue; // autoFill이 활성화되면 일반 자동입력은 건너뜀
}
// 기존 widget 타입 전용 로직은 widget인 경우만
if (comp.type !== 'widget') continue;
// 텍스트 타입 위젯의 자동입력 처리 (기존 로직)
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
widget.webTypeConfig) { widget.webTypeConfig) {
const config = widget.webTypeConfig as TextTypeConfig; const config = widget.webTypeConfig as TextTypeConfig;
@ -278,12 +310,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
} }
} }
}); }
}; };
// 초기 로드 시 자동입력 필드들 설정 // 초기 로드 시 자동입력 필드들 설정
initAutoInputFields(); initAutoInputFields();
}, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지) }, [allComponents, generateAutoValue, user]); // formData는 의존성에서 제외 (무한 루프 방지)
// 날짜 값 업데이트 // 날짜 값 업데이트
const updateDateValue = (fieldName: string, date: Date | undefined) => { const updateDateValue = (fieldName: string, date: Date | undefined) => {

View File

@ -81,6 +81,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용) // formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
const formData = externalFormData || localFormData; const formData = externalFormData || localFormData;
// formData 업데이트 함수
const updateFormData = useCallback(
(fieldName: string, value: any) => {
if (onFormDataChange) {
onFormDataChange(fieldName, value);
} else {
setLocalFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
},
[onFormDataChange],
);
// 자동값 생성 함수 // 자동값 생성 함수
const generateAutoValue = useCallback( const generateAutoValue = useCallback(
(autoValueType: string): string => { (autoValueType: string): string => {
@ -105,6 +120,50 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
[userName], [userName],
); );
// 🆕 autoFill 자동 입력 초기화
React.useEffect(() => {
const initAutoInputFields = async () => {
for (const comp of allComponents) {
// type: "component" 또는 type: "widget" 모두 처리
if (comp.type === 'widget' || comp.type === 'component') {
const widget = comp as any;
const fieldName = widget.columnName || widget.id;
// autoFill 처리 (테이블 조회 기반 자동 입력)
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
const currentValue = formData[fieldName];
if (currentValue === undefined || currentValue === '') {
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
// 사용자 정보에서 필터 값 가져오기
const userValue = user?.[userField];
if (userValue && sourceTable && filterColumn && displayColumn) {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const result = await tableTypeApi.getTableRecord(
sourceTable,
filterColumn,
userValue,
displayColumn
);
updateFormData(fieldName, result.value);
} catch (error) {
console.error(`autoFill 조회 실패: ${fieldName}`, error);
}
}
}
}
}
}
};
initAutoInputFields();
}, [allComponents, user]);
// 팝업 화면 레이아웃 로드 // 팝업 화면 레이아웃 로드
React.useEffect(() => { React.useEffect(() => {
if (popupScreen?.screenId) { if (popupScreen?.screenId) {

View File

@ -2198,6 +2198,90 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* 🆕 자동 필터 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium flex items-center gap-2">
<Filter className="h-4 w-4" />
</h4>
<div className="space-y-3 rounded-md border border-gray-200 p-3">
<div className="flex items-center space-x-2">
<Checkbox
id="auto-filter-enabled"
checked={component.autoFilter?.enabled || false}
onCheckedChange={(checked) => {
onUpdateComponent({
autoFilter: {
enabled: checked as boolean,
filterColumn: component.autoFilter?.filterColumn || 'company_code',
userField: component.autoFilter?.userField || 'companyCode',
},
});
}}
/>
<Label htmlFor="auto-filter-enabled" className="font-normal">
</Label>
</div>
{component.autoFilter?.enabled && (
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="filter-column" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="filter-column"
value={component.autoFilter?.filterColumn || ''}
onChange={(e) => {
onUpdateComponent({
autoFilter: {
...component.autoFilter!,
filterColumn: e.target.value,
},
});
}}
placeholder="company_code"
className="text-xs"
/>
<p className="text-[10px] text-muted-foreground">
: company_code, dept_code, user_id
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-field" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={component.autoFilter?.userField || 'companyCode'}
onValueChange={(value: any) => {
onUpdateComponent({
autoFilter: {
...component.autoFilter!,
userField: value,
},
});
}}
>
<SelectTrigger id="user-field" className="text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="companyCode"> </SelectItem>
<SelectItem value="userId"> ID</SelectItem>
<SelectItem value="deptCode"> </SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
)}
</div>
</div>
{/* 페이지네이션 설정 */} {/* 페이지네이션 설정 */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium"> </h4>

View File

@ -1,8 +1,12 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Settings } from "lucide-react"; import { Settings, Database } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent"; import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
import { import {
@ -1125,6 +1129,136 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}); });
}} }}
/> />
{/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */}
<div className="space-y-4 rounded-md border border-gray-200 p-4">
<h4 className="flex items-center gap-2 text-sm font-medium">
<Database className="h-4 w-4" />
</h4>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="auto-fill-enabled-component"
checked={selectedComponent.autoFill?.enabled || false}
onCheckedChange={(checked) => {
onUpdateProperty(selectedComponent.id, "autoFill", {
enabled: checked as boolean,
sourceTable: selectedComponent.autoFill?.sourceTable || "",
filterColumn: selectedComponent.autoFill?.filterColumn || "company_code",
userField: selectedComponent.autoFill?.userField || "companyCode",
displayColumn: selectedComponent.autoFill?.displayColumn || "",
});
}}
/>
<Label htmlFor="auto-fill-enabled-component" className="text-xs font-normal">
</Label>
</div>
{selectedComponent.autoFill?.enabled && (
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="source-table-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedComponent.autoFill?.sourceTable || ""}
onValueChange={(value) => {
onUpdateProperty(selectedComponent.id, "autoFill", {
...selectedComponent.autoFill!,
sourceTable: value,
});
}}
>
<SelectTrigger id="source-table-component" className="text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-2">
<Label htmlFor="filter-column-autofill-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="filter-column-autofill-component"
value={selectedComponent.autoFill?.filterColumn || ""}
onChange={(e) => {
onUpdateProperty(selectedComponent.id, "autoFill", {
...selectedComponent.autoFill!,
filterColumn: e.target.value,
});
}}
placeholder="company_code"
className="text-xs"
/>
<p className="text-[10px] text-muted-foreground">: company_code, dept_code, user_id</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-field-autofill-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedComponent.autoFill?.userField || "companyCode"}
onValueChange={(value: any) => {
onUpdateProperty(selectedComponent.id, "autoFill", {
...selectedComponent.autoFill!,
userField: value,
});
}}
>
<SelectTrigger id="user-field-autofill-component" className="text-xs">
<SelectValue placeholder="사용자 정보 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="companyCode" className="text-xs">
</SelectItem>
<SelectItem value="userId" className="text-xs">
ID
</SelectItem>
<SelectItem value="deptCode" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-2">
<Label htmlFor="display-column-autofill-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="display-column-autofill-component"
value={selectedComponent.autoFill?.displayColumn || ""}
onChange={(e) => {
onUpdateProperty(selectedComponent.id, "autoFill", {
...selectedComponent.autoFill!,
displayColumn: e.target.value,
});
}}
placeholder="company_name"
className="text-xs"
/>
<p className="text-[10px] text-muted-foreground">
(: company_name)
</p>
</div>
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -1202,7 +1336,144 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div> </div>
{/* 상세 설정 영역 */} {/* 상세 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">{renderWebTypeConfig(widget)}</div> <div className="flex-1 overflow-y-auto p-4">
<div className="space-y-6">
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
{/* 🆕 자동 입력 섹션 */}
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
<h4 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4" />
🔥 ()
</h4>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="auto-fill-enabled"
checked={widget.autoFill?.enabled || false}
onCheckedChange={(checked) => {
onUpdateProperty(widget.id, "autoFill", {
enabled: checked as boolean,
sourceTable: widget.autoFill?.sourceTable || '',
filterColumn: widget.autoFill?.filterColumn || 'company_code',
userField: widget.autoFill?.userField || 'companyCode',
displayColumn: widget.autoFill?.displayColumn || '',
});
}}
/>
<Label htmlFor="auto-fill-enabled" className="font-normal text-xs">
</Label>
</div>
{widget.autoFill?.enabled && (
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="source-table" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={widget.autoFill?.sourceTable || ''}
onValueChange={(value) => {
onUpdateProperty(widget.id, "autoFill", {
...widget.autoFill!,
sourceTable: value,
});
}}
>
<SelectTrigger id="source-table" className="text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="filter-column-autofill" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="filter-column-autofill"
value={widget.autoFill?.filterColumn || ''}
onChange={(e) => {
onUpdateProperty(widget.id, "autoFill", {
...widget.autoFill!,
filterColumn: e.target.value,
});
}}
placeholder="company_code"
className="text-xs"
/>
<p className="text-[10px] text-muted-foreground">
: company_code, dept_code, user_id
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-field-autofill" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={widget.autoFill?.userField || 'companyCode'}
onValueChange={(value: any) => {
onUpdateProperty(widget.id, "autoFill", {
...widget.autoFill!,
userField: value,
});
}}
>
<SelectTrigger id="user-field-autofill" className="text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="companyCode"> </SelectItem>
<SelectItem value="userId"> ID</SelectItem>
<SelectItem value="deptCode"> </SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="display-column" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="display-column"
value={widget.autoFill?.displayColumn || ''}
onChange={(e) => {
onUpdateProperty(widget.id, "autoFill", {
...widget.autoFill!,
displayColumn: e.target.value,
});
}}
placeholder="company_name"
className="text-xs"
/>
<p className="text-[10px] text-muted-foreground">
Input에 (: company_name, dept_name)
</p>
</div>
</div>
)}
</div>
</div>
{/* 웹타입 설정 */}
<Separator />
{renderWebTypeConfig(widget)}
</div>
</div>
</div> </div>
); );
}; };

View File

@ -118,9 +118,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{/* 안내 메시지 */} {/* 안내 메시지 */}
<Separator className="my-4" /> <Separator className="my-4" />
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<Settings className="mb-2 h-8 w-8 text-muted-foreground/30" /> <Settings className="text-muted-foreground/30 mb-2 h-8 w-8" />
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-muted-foreground text-[10px]"> </p>
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-muted-foreground text-[10px]"> </p>
</div> </div>
</div> </div>
</div> </div>
@ -412,8 +412,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합) // 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
const renderDetailTab = () => { const renderDetailTab = () => {
console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type);
// 1. DataTable 컴포넌트 // 1. DataTable 컴포넌트
if (selectedComponent.type === "datatable") { if (selectedComponent.type === "datatable") {
console.log("✅ [renderDetailTab] DataTable 컴포넌트");
return ( return (
<DataTableConfigPanel <DataTableConfigPanel
component={selectedComponent as DataTableComponent} component={selectedComponent as DataTableComponent}
@ -470,6 +473,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 5. 새로운 컴포넌트 시스템 (type: "component") // 5. 새로운 컴포넌트 시스템 (type: "component")
if (selectedComponent.type === "component") { if (selectedComponent.type === "component") {
console.log("✅ [renderDetailTab] Component 타입");
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type; const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
const webType = selectedComponent.componentConfig?.webType; const webType = selectedComponent.componentConfig?.webType;
@ -479,7 +483,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
if (!componentId) { if (!componentId) {
return ( return (
<div className="flex h-full items-center justify-center p-8 text-center"> <div className="flex h-full items-center justify-center p-8 text-center">
<p className="text-sm text-muted-foreground"> ID가 </p> <p className="text-muted-foreground text-sm"> ID가 </p>
</div> </div>
); );
} }
@ -511,7 +515,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
<div> <div>
<div className="font-medium">{option.label}</div> <div className="font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">{option.description}</div> <div className="text-muted-foreground text-xs">{option.description}</div>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@ -535,45 +539,154 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}); });
}} }}
/> />
{/* 🆕 테이블 데이터 자동 입력 (component 타입용) */}
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-2">
<Database className="text-primary h-4 w-4" />
<h4 className="text-xs font-semibold"> </h4>
</div>
{/* 활성화 체크박스 */}
<div className="flex items-center space-x-2">
<Checkbox
id="autoFill-enabled-component"
checked={selectedComponent.autoFill?.enabled || false}
onCheckedChange={(checked) => {
handleUpdate("autoFill", {
...selectedComponent.autoFill,
enabled: Boolean(checked),
});
}}
/>
<Label htmlFor="autoFill-enabled-component" className="cursor-pointer text-xs">
</Label>
</div>
{selectedComponent.autoFill?.enabled && (
<>
{/* 조회할 테이블 */}
<div className="space-y-1">
<Label htmlFor="autoFill-sourceTable-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedComponent.autoFill?.sourceTable || ""}
onValueChange={(value) => {
handleUpdate("autoFill", {
...selectedComponent.autoFill,
enabled: selectedComponent.autoFill?.enabled || false,
sourceTable: value,
});
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName} className="text-xs">
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 필터링할 컬럼 */}
<div className="space-y-1">
<Label htmlFor="autoFill-filterColumn-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="autoFill-filterColumn-component"
value={selectedComponent.autoFill?.filterColumn || ""}
onChange={(e) => {
handleUpdate("autoFill", {
...selectedComponent.autoFill,
enabled: selectedComponent.autoFill?.enabled || false,
filterColumn: e.target.value,
});
}}
placeholder="예: company_code"
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
{/* 사용자 정보 필드 */}
<div className="space-y-1">
<Label htmlFor="autoFill-userField-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedComponent.autoFill?.userField || ""}
onValueChange={(value: "companyCode" | "userId" | "deptCode") => {
handleUpdate("autoFill", {
...selectedComponent.autoFill,
enabled: selectedComponent.autoFill?.enabled || false,
userField: value,
});
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue placeholder="사용자 정보 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="companyCode" className="text-xs">
</SelectItem>
<SelectItem value="userId" className="text-xs">
ID
</SelectItem>
<SelectItem value="deptCode" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시할 컬럼 */}
<div className="space-y-1">
<Label htmlFor="autoFill-displayColumn-component" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="autoFill-displayColumn-component"
value={selectedComponent.autoFill?.displayColumn || ""}
onChange={(e) => {
handleUpdate("autoFill", {
...selectedComponent.autoFill,
enabled: selectedComponent.autoFill?.enabled || false,
displayColumn: e.target.value,
});
}}
placeholder="예: company_name"
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</>
)}
</div>
</div> </div>
); );
} }
// 6. Widget 컴포넌트 // 6. Widget 컴포넌트
if (selectedComponent.type === "widget") { if (selectedComponent.type === "widget") {
console.log("✅ [renderDetailTab] Widget 타입");
const widget = selectedComponent as WidgetComponent; const widget = selectedComponent as WidgetComponent;
console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType);
// Widget에 webType이 있는 경우 // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크
if (widget.webType) {
return (
<div className="space-y-4">
{/* WebType 선택 */}
<div>
<Label> </Label>
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypes.map((wt) => (
<SelectItem key={wt.web_type} value={wt.web_type}>
{wt.web_type_name_kor || wt.web_type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등)
if ( if (
widget.widgetType && widget.widgetType &&
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes( ["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
widget.widgetType, widget.widgetType,
) )
) { ) {
console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)");
return ( return (
<DynamicComponentConfigPanel <DynamicComponentConfigPanel
componentId={widget.widgetType} componentId={widget.widgetType}
@ -589,12 +702,168 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
/> />
); );
} }
// 일반 위젯 (webType 기반)
console.log("✅ [renderDetailTab] 일반 위젯 렌더링 시작");
return (
<div className="space-y-4">
{console.log("🔍 [UnifiedPropertiesPanel] widget.webType:", widget.webType, "widget:", widget)}
{/* WebType 선택 (있는 경우만) */}
{widget.webType && (
<div>
<Label> </Label>
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypes.map((wt) => (
<SelectItem key={wt.web_type} value={wt.web_type}>
{wt.web_type_name_kor || wt.web_type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}
<Separator />
<div className="space-y-3 border-4 border-red-500 bg-yellow-100 p-4">
<div className="flex items-center gap-2">
<Database className="text-primary h-4 w-4" />
<h4 className="text-xs font-semibold"> </h4>
</div>
{/* 활성화 체크박스 */}
<div className="flex items-center space-x-2">
<Checkbox
id="autoFill-enabled"
checked={widget.autoFill?.enabled || false}
onCheckedChange={(checked) => {
handleUpdate("autoFill", {
...widget.autoFill,
enabled: Boolean(checked),
});
}}
/>
<Label htmlFor="autoFill-enabled" className="cursor-pointer text-xs">
</Label>
</div>
{widget.autoFill?.enabled && (
<>
{/* 조회할 테이블 */}
<div className="space-y-1">
<Label htmlFor="autoFill-sourceTable" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={widget.autoFill?.sourceTable || ""}
onValueChange={(value) => {
handleUpdate("autoFill", {
...widget.autoFill,
enabled: widget.autoFill?.enabled || false,
sourceTable: value,
});
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName} className="text-xs">
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 필터링할 컬럼 */}
<div className="space-y-1">
<Label htmlFor="autoFill-filterColumn" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="autoFill-filterColumn"
value={widget.autoFill?.filterColumn || ""}
onChange={(e) => {
handleUpdate("autoFill", {
...widget.autoFill,
enabled: widget.autoFill?.enabled || false,
filterColumn: e.target.value,
});
}}
placeholder="예: company_code"
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
{/* 사용자 정보 필드 */}
<div className="space-y-1">
<Label htmlFor="autoFill-userField" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={widget.autoFill?.userField || ""}
onValueChange={(value: "companyCode" | "userId" | "deptCode") => {
handleUpdate("autoFill", {
...widget.autoFill,
enabled: widget.autoFill?.enabled || false,
userField: value,
});
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue placeholder="사용자 정보 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="companyCode" className="text-xs">
</SelectItem>
<SelectItem value="userId" className="text-xs">
ID
</SelectItem>
<SelectItem value="deptCode" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시할 컬럼 */}
<div className="space-y-1">
<Label htmlFor="autoFill-displayColumn" className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
id="autoFill-displayColumn"
value={widget.autoFill?.displayColumn || ""}
onChange={(e) => {
handleUpdate("autoFill", {
...widget.autoFill,
enabled: widget.autoFill?.enabled || false,
displayColumn: e.target.value,
});
}}
placeholder="예: company_name"
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</>
)}
</div>
</div>
);
} }
// 기본 메시지 // 기본 메시지
return ( return (
<div className="flex h-full items-center justify-center p-8 text-center"> <div className="flex h-full items-center justify-center p-8 text-center">
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
); );
}; };
@ -602,9 +871,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
return ( return (
<div className="flex h-full flex-col bg-white"> <div className="flex h-full flex-col bg-white">
{/* 헤더 - 간소화 */} {/* 헤더 - 간소화 */}
<div className="border-b border-border px-3 py-2"> <div className="border-border border-b px-3 py-2">
{selectedComponent.type === "widget" && ( {selectedComponent.type === "widget" && (
<div className="truncate text-[10px] text-muted-foreground"> <div className="text-muted-foreground truncate text-[10px]">
{(selectedComponent as WidgetComponent).label || selectedComponent.id} {(selectedComponent as WidgetComponent).label || selectedComponent.id}
</div> </div>
)} )}

View File

@ -74,6 +74,12 @@ export const MOCK_COMPANIES: Company[] = [
// 새 회사 등록 시 기본값 // 새 회사 등록 시 기본값
export const DEFAULT_COMPANY_FORM_DATA = { export const DEFAULT_COMPANY_FORM_DATA = {
company_name: "", company_name: "",
business_registration_number: "",
representative_name: "",
representative_phone: "",
email: "",
website: "",
address: "",
}; };
// 페이징 관련 상수 // 페이징 관련 상수

View File

@ -144,6 +144,12 @@ export const useCompanyManagement = () => {
selectedCompany: company, selectedCompany: company,
formData: { formData: {
company_name: company.company_name, company_name: company.company_name,
business_registration_number: company.business_registration_number || "",
representative_name: company.representative_name || "",
representative_phone: company.representative_phone || "",
email: company.email || "",
website: company.website || "",
address: company.address || "",
}, },
}); });
}, []); }, []);
@ -175,6 +181,10 @@ export const useCompanyManagement = () => {
setError("회사명을 입력해주세요."); setError("회사명을 입력해주세요.");
return false; return false;
} }
if (!modalState.formData.business_registration_number.trim()) {
setError("사업자등록번호를 입력해주세요.");
return false;
}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@ -199,6 +209,10 @@ export const useCompanyManagement = () => {
setError("올바른 데이터를 입력해주세요."); setError("올바른 데이터를 입력해주세요.");
return false; return false;
} }
if (!modalState.formData.business_registration_number.trim()) {
setError("사업자등록번호를 입력해주세요.");
return false;
}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@ -206,6 +220,12 @@ export const useCompanyManagement = () => {
try { try {
await companyAPI.update(modalState.selectedCompany.company_code, { await companyAPI.update(modalState.selectedCompany.company_code, {
company_name: modalState.formData.company_name, company_name: modalState.formData.company_name,
business_registration_number: modalState.formData.business_registration_number,
representative_name: modalState.formData.representative_name,
representative_phone: modalState.formData.representative_phone,
email: modalState.formData.email,
website: modalState.formData.website,
address: modalState.formData.address,
status: modalState.selectedCompany.status, status: modalState.selectedCompany.status,
}); });
closeModal(); closeModal();

View File

@ -0,0 +1,157 @@
/**
* 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);
const isDuplicate = error.response?.status === 409;
return {
success: false,
error: error.response?.data?.message || error.message,
isDuplicate,
};
}
}
/**
*
*/
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 searchUsers(companyCode: string, search: string) {
try {
const response = await apiClient.get<{ success: boolean; data: any[] }>(
`/departments/companies/${companyCode}/users/search`,
{ params: { search } },
);
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; message?: string }>(`/departments/${deptCode}/members`, {
user_id: userId,
});
return response.data;
} catch (error: any) {
console.error("부서원 추가 실패:", error);
const isDuplicate = error.response?.status === 409;
return {
success: false,
error: error.response?.data?.message || error.message,
isDuplicate,
};
}
}
/**
*
*/
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 };
}
}

View File

@ -313,6 +313,21 @@ export const tableTypeApi = {
deleteTableData: async (tableName: string, data: Record<string, any>[] | { ids: string[] }): Promise<void> => { deleteTableData: async (tableName: string, data: Record<string, any>[] | { ids: string[] }): Promise<void> => {
await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data }); await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data });
}, },
// 🆕 단일 레코드 조회 (자동 입력용)
getTableRecord: async (
tableName: string,
filterColumn: string,
filterValue: any,
displayColumn: string,
): Promise<{ value: any; record: Record<string, any> }> => {
const response = await apiClient.post(`/table-management/tables/${tableName}/record`, {
filterColumn,
filterValue,
displayColumn,
});
return response.data.data;
},
}; };
// 메뉴-화면 할당 관련 API // 메뉴-화면 할당 관련 API

View File

@ -0,0 +1,74 @@
/**
*
*/
/**
* (000-00-00000 )
*/
export function validateBusinessNumberFormat(value: string): boolean {
if (!value || value.trim() === "") {
return false;
}
// 하이픈 제거
const cleaned = value.replace(/-/g, "");
// 숫자 10자리인지 확인
if (!/^\d{10}$/.test(cleaned)) {
return false;
}
return true;
}
/**
* ( )
* API
*/
export function validateBusinessNumber(value: string): {
isValid: boolean;
message: string;
} {
if (!value || value.trim() === "") {
return {
isValid: false,
message: "사업자등록번호를 입력해주세요.",
};
}
if (!validateBusinessNumberFormat(value)) {
return {
isValid: false,
message: "사업자등록번호는 10자리 숫자여야 합니다.",
};
}
// 포맷만 검증하고 통과
return {
isValid: true,
message: "",
};
}
/**
* ( )
*/
export function formatBusinessNumber(value: string): string {
if (!value) return "";
// 숫자만 추출
const cleaned = value.replace(/\D/g, "");
// 최대 10자리까지만
const limited = cleaned.slice(0, 10);
// 하이픈 추가 (000-00-00000)
if (limited.length <= 3) {
return limited;
} else if (limited.length <= 5) {
return `${limited.slice(0, 3)}-${limited.slice(3)}`;
} else {
return `${limited.slice(0, 3)}-${limited.slice(3, 5)}-${limited.slice(5)}`;
}
}

View File

@ -6,6 +6,12 @@
export interface Company { export interface Company {
company_code: string; // 회사 코드 (varchar 32) - PK company_code: string; // 회사 코드 (varchar 32) - PK
company_name: string; // 회사명 (varchar 64) company_name: string; // 회사명 (varchar 64)
business_registration_number?: string; // 사업자등록번호 (varchar 20)
representative_name?: string; // 대표자명 (varchar 100)
representative_phone?: string; // 대표 연락처 (varchar 20)
email?: string; // 이메일 (varchar 255)
website?: string; // 웹사이트 (varchar 500)
address?: string; // 회사 주소 (text)
writer: string; // 등록자 (varchar 32) writer: string; // 등록자 (varchar 32)
regdate: string; // 등록일시 (timestamp -> ISO string) regdate: string; // 등록일시 (timestamp -> ISO string)
status: string; // 상태 (varchar 32) status: string; // 상태 (varchar 32)
@ -20,7 +26,13 @@ export interface Company {
// 회사 등록/수정 폼 데이터 // 회사 등록/수정 폼 데이터
export interface CompanyFormData { export interface CompanyFormData {
company_name: string; // 등록 시에는 회사명만 필요 company_name: string; // 회사명 (필수)
business_registration_number: string; // 사업자등록번호 (필수)
representative_name?: string; // 대표자명
representative_phone?: string; // 대표 연락처
email?: string; // 이메일
website?: string; // 웹사이트
address?: string; // 회사 주소
} }
// 회사 검색 필터 // 회사 검색 필터

View 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[];
}

View File

@ -84,6 +84,15 @@ export interface WidgetComponent extends BaseComponent {
entityConfig?: EntityTypeConfig; entityConfig?: EntityTypeConfig;
buttonConfig?: ButtonTypeConfig; buttonConfig?: ButtonTypeConfig;
arrayConfig?: ArrayTypeConfig; arrayConfig?: ArrayTypeConfig;
// 🆕 자동 입력 설정 (테이블 조회 기반)
autoFill?: {
enabled: boolean; // 자동 입력 활성화
sourceTable: string; // 조회할 테이블 (예: company_mng)
filterColumn: string; // 필터링할 컬럼 (예: company_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드
displayColumn: string; // 표시할 컬럼 (예: company_name)
};
} }
/** /**
@ -121,6 +130,13 @@ export interface DataTableComponent extends BaseComponent {
searchable?: boolean; searchable?: boolean;
sortable?: boolean; sortable?: boolean;
filters?: DataTableFilter[]; filters?: DataTableFilter[];
// 🆕 현재 사용자 정보로 자동 필터링
autoFilter?: {
enabled: boolean; // 자동 필터 활성화 여부
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
};
} }
/** /**