Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
9f131a80ab
|
|
@ -65,6 +65,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관
|
|||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -224,6 +225,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
|||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import config from "../config/environment";
|
|||
import { AdminService } from "../services/adminService";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
|
|
@ -609,9 +610,15 @@ export const getCompanyList = async (
|
|||
|
||||
// Raw Query로 회사 목록 조회
|
||||
const companies = await query<any>(
|
||||
`SELECT
|
||||
company_code,
|
||||
` SELECT
|
||||
company_code,
|
||||
company_name,
|
||||
business_registration_number,
|
||||
representative_name,
|
||||
representative_phone,
|
||||
email,
|
||||
website,
|
||||
address,
|
||||
status,
|
||||
writer,
|
||||
regdate
|
||||
|
|
@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB(
|
|||
|
||||
// Raw Query로 회사 목록 조회
|
||||
const companies = await query<any>(
|
||||
`SELECT
|
||||
company_code,
|
||||
` SELECT
|
||||
company_code,
|
||||
company_name,
|
||||
business_registration_number,
|
||||
representative_name,
|
||||
representative_phone,
|
||||
email,
|
||||
website,
|
||||
address,
|
||||
writer,
|
||||
regdate,
|
||||
status
|
||||
|
|
@ -2440,6 +2453,25 @@ export const createCompany = async (
|
|||
[company_name.trim()]
|
||||
);
|
||||
|
||||
// 사업자등록번호 유효성 검증
|
||||
const businessNumberValidation = validateBusinessNumber(
|
||||
req.body.business_registration_number?.trim() || ""
|
||||
);
|
||||
if (!businessNumberValidation.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: businessNumberValidation.message,
|
||||
errorCode: "INVALID_BUSINESS_NUMBER",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Raw Query로 사업자등록번호 중복 체크
|
||||
const existingBusinessNumber = await queryOne<any>(
|
||||
`SELECT company_code FROM company_mng WHERE business_registration_number = $1`,
|
||||
[req.body.business_registration_number?.trim()]
|
||||
);
|
||||
|
||||
if (existingCompany) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -2449,6 +2481,15 @@ export const createCompany = async (
|
|||
return;
|
||||
}
|
||||
|
||||
if (existingBusinessNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 사업자등록번호입니다.",
|
||||
errorCode: "DUPLICATE_BUSINESS_NUMBER",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용)
|
||||
const client = new Client({
|
||||
connectionString:
|
||||
|
|
@ -2474,11 +2515,17 @@ export const createCompany = async (
|
|||
const insertQuery = `
|
||||
INSERT INTO company_mng (
|
||||
company_code,
|
||||
company_name,
|
||||
company_name,
|
||||
business_registration_number,
|
||||
representative_name,
|
||||
representative_phone,
|
||||
email,
|
||||
website,
|
||||
address,
|
||||
writer,
|
||||
regdate,
|
||||
status
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -2488,6 +2535,12 @@ export const createCompany = async (
|
|||
const insertValues = [
|
||||
companyCode,
|
||||
company_name.trim(),
|
||||
req.body.business_registration_number?.trim() || null,
|
||||
req.body.representative_name?.trim() || null,
|
||||
req.body.representative_phone?.trim() || null,
|
||||
req.body.email?.trim() || null,
|
||||
req.body.website?.trim() || null,
|
||||
req.body.address?.trim() || null,
|
||||
writer,
|
||||
new Date(),
|
||||
"active",
|
||||
|
|
@ -2552,7 +2605,16 @@ export const updateCompany = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const { company_name, status } = req.body;
|
||||
const {
|
||||
company_name,
|
||||
business_registration_number,
|
||||
representative_name,
|
||||
representative_phone,
|
||||
email,
|
||||
website,
|
||||
address,
|
||||
status,
|
||||
} = req.body;
|
||||
|
||||
logger.info("회사 정보 수정 요청", {
|
||||
companyCode,
|
||||
|
|
@ -2586,13 +2648,61 @@ export const updateCompany = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외)
|
||||
if (business_registration_number && business_registration_number.trim()) {
|
||||
// 유효성 검증
|
||||
const businessNumberValidation = validateBusinessNumber(business_registration_number.trim());
|
||||
if (!businessNumberValidation.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: businessNumberValidation.message,
|
||||
errorCode: "INVALID_BUSINESS_NUMBER",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const duplicateBusinessNumber = await queryOne<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로 회사 정보 수정
|
||||
const result = await query<any>(
|
||||
`UPDATE company_mng
|
||||
SET company_name = $1, status = $2
|
||||
WHERE company_code = $3
|
||||
SET
|
||||
company_name = $1,
|
||||
business_registration_number = $2,
|
||||
representative_name = $3,
|
||||
representative_phone = $4,
|
||||
email = $5,
|
||||
website = $6,
|
||||
address = $7,
|
||||
status = $8
|
||||
WHERE company_code = $9
|
||||
RETURNING *`,
|
||||
[company_name.trim(), status || "active", companyCode]
|
||||
[
|
||||
company_name.trim(),
|
||||
business_registration_number?.trim() || null,
|
||||
representative_name?.trim() || null,
|
||||
representative_phone?.trim() || null,
|
||||
email?.trim() || null,
|
||||
website?.trim() || null,
|
||||
address?.trim() || null,
|
||||
status || "active",
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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: "주 부서 설정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12,6 +12,14 @@ export const saveFormData = async (
|
|||
const { companyCode, userId } = req.user as any;
|
||||
const { screenId, tableName, data } = req.body;
|
||||
|
||||
// 🔍 디버깅: 사용자 정보 확인
|
||||
console.log("🔍 [saveFormData] 사용자 정보:", {
|
||||
userId,
|
||||
companyCode,
|
||||
reqUser: req.user,
|
||||
dataWriter: data.writer,
|
||||
});
|
||||
|
||||
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
|
||||
if (screenId === undefined || screenId === null || !tableName || !data) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -25,9 +33,12 @@ export const saveFormData = async (
|
|||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
console.log("✅ [saveFormData] 최종 writer 값:", formDataWithMeta.writer);
|
||||
|
||||
// company_code는 사용자가 명시적으로 입력한 경우에만 추가
|
||||
if (data.company_code !== undefined) {
|
||||
formDataWithMeta.company_code = data.company_code;
|
||||
|
|
@ -86,6 +97,7 @@ export const saveFormDataEnhanced = async (
|
|||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
|
|
@ -134,6 +146,7 @@ export const updateFormData = async (
|
|||
const formDataWithMeta = {
|
||||
...data,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
|
|
@ -186,6 +199,7 @@ export const updateFormDataPartial = async (
|
|||
const newDataWithMeta = {
|
||||
...newData,
|
||||
updated_by: userId,
|
||||
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
};
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
ColumnListResponse,
|
||||
ColumnSettingsResponse,
|
||||
} from "../types/tableManagement";
|
||||
import { query } from "../database/db"; // 🆕 query 함수 import
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회
|
||||
|
|
@ -506,7 +507,91 @@ export async function updateColumnInputType(
|
|||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
* 단일 레코드 조회 (자동 입력용)
|
||||
*/
|
||||
export async function getTableRecord(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<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(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -520,12 +605,14 @@ export async function getTableData(
|
|||
search = {},
|
||||
sortBy,
|
||||
sortOrder = "asc",
|
||||
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
|
||||
} = req.body;
|
||||
|
||||
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
||||
logger.info(`페이징: page=${page}, size=${size}`);
|
||||
logger.info(`검색 조건:`, search);
|
||||
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
||||
logger.info(`자동 필터:`, autoFilter); // 🆕
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
|
|
@ -542,11 +629,35 @@ export async function getTableData(
|
|||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🆕 현재 사용자 필터 적용
|
||||
let enhancedSearch = { ...search };
|
||||
if (autoFilter?.enabled && req.user) {
|
||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||
const userField = autoFilter.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
if (userValue) {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
filterColumn,
|
||||
userField,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
} else {
|
||||
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
|
||||
userField,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 조회
|
||||
const result = await tableManagementService.getTableData(tableName, {
|
||||
page: parseInt(page),
|
||||
size: parseInt(size),
|
||||
search,
|
||||
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
|
@ -1216,9 +1327,7 @@ export async function getLogData(
|
|||
originalId: originalId as string,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`
|
||||
);
|
||||
logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`);
|
||||
|
||||
const response: ApiResponse<typeof result> = {
|
||||
success: true,
|
||||
|
|
@ -1254,7 +1363,9 @@ export async function toggleLogTable(
|
|||
const { tableName } = req.params;
|
||||
const { isActive } = req.body;
|
||||
|
||||
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
|
||||
logger.info(
|
||||
`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`
|
||||
);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
|
|
@ -1288,9 +1399,7 @@ export async function toggleLogTable(
|
|||
isActive === "Y" || isActive === true
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`
|
||||
);
|
||||
logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
updateColumnInputType,
|
||||
updateTableLabel,
|
||||
getTableData,
|
||||
getTableRecord, // 🆕 단일 레코드 조회
|
||||
addTableData,
|
||||
editTableData,
|
||||
deleteTableData,
|
||||
|
|
@ -134,6 +135,12 @@ router.get("/health", checkDatabaseConnection);
|
|||
*/
|
||||
router.post("/tables/:tableName/data", getTableData);
|
||||
|
||||
/**
|
||||
* 단일 레코드 조회 (자동 입력용)
|
||||
* POST /api/table-management/tables/:tableName/record
|
||||
*/
|
||||
router.post("/tables/:tableName/record", getTableRecord);
|
||||
|
||||
/**
|
||||
* 테이블 데이터 추가
|
||||
* POST /api/table-management/tables/:tableName/add
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ export class DDLExecutionService {
|
|||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500)`;
|
||||
|
||||
// 최종 CREATE TABLE 쿼리
|
||||
|
|
|
|||
|
|
@ -1502,6 +1502,9 @@ export class TableManagementService {
|
|||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`);
|
||||
|
||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||
|
||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +151,57 @@ export default function ScreenViewPage() {
|
|||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 🆕 autoFill 자동 입력 초기화
|
||||
useEffect(() => {
|
||||
const initAutoFill = async () => {
|
||||
if (!layout || !layout.components || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const comp of layout.components) {
|
||||
// type: "component" 또는 type: "widget" 모두 처리
|
||||
if (comp.type === 'widget' || comp.type === 'component') {
|
||||
const widget = comp as any;
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
|
||||
// autoFill 처리
|
||||
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||||
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||||
const currentValue = formData[fieldName];
|
||||
|
||||
if (currentValue === undefined || currentValue === '') {
|
||||
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
||||
|
||||
// 사용자 정보에서 필터 값 가져오기
|
||||
const userValue = user?.[userField as keyof typeof user];
|
||||
|
||||
if (userValue && sourceTable && filterColumn && displayColumn) {
|
||||
try {
|
||||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||
const result = await tableTypeApi.getTableRecord(
|
||||
sourceTable,
|
||||
filterColumn,
|
||||
userValue,
|
||||
displayColumn
|
||||
);
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: result.value,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAutoFill();
|
||||
}, [layout, user]);
|
||||
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
|
||||
useEffect(() => {
|
||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
|
||||
|
||||
interface CompanyFormModalProps {
|
||||
modalState: CompanyModalState;
|
||||
|
|
@ -29,6 +30,7 @@ export function CompanyFormModal({
|
|||
onClearError,
|
||||
}: CompanyFormModalProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [businessNumberError, setBusinessNumberError] = useState<string>("");
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!modalState.isOpen) return null;
|
||||
|
|
@ -36,15 +38,43 @@ export function CompanyFormModal({
|
|||
const { mode, formData, selectedCompany } = modalState;
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
// 사업자등록번호 변경 처리
|
||||
const handleBusinessNumberChange = (value: string) => {
|
||||
// 자동 포맷팅
|
||||
const formatted = formatBusinessNumber(value);
|
||||
onFormChange("business_registration_number", formatted);
|
||||
|
||||
// 유효성 검사 (10자리가 다 입력되었을 때만)
|
||||
const cleaned = formatted.replace(/-/g, "");
|
||||
if (cleaned.length === 10) {
|
||||
const validation = validateBusinessNumber(formatted);
|
||||
setBusinessNumberError(validation.isValid ? "" : validation.message);
|
||||
} else if (cleaned.length < 10 && businessNumberError) {
|
||||
// 10자리 미만이면 에러 초기화
|
||||
setBusinessNumberError("");
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async () => {
|
||||
// 입력값 검증
|
||||
// 입력값 검증 (필수 필드)
|
||||
if (!formData.company_name.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!formData.business_registration_number.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 사업자등록번호 최종 검증
|
||||
const validation = validateBusinessNumber(formData.business_registration_number);
|
||||
if (!validation.isValid) {
|
||||
setBusinessNumberError(validation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
onClearError();
|
||||
setBusinessNumberError("");
|
||||
|
||||
try {
|
||||
const success = await onSave();
|
||||
|
|
@ -81,7 +111,7 @@ export function CompanyFormModal({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 회사명 입력 */}
|
||||
{/* 회사명 입력 (필수) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
|
@ -97,10 +127,94 @@ export function CompanyFormModal({
|
|||
/>
|
||||
</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 && (
|
||||
<div className="bg-destructive/10 rounded-md p-3">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -129,7 +243,13 @@ export function CompanyFormModal({
|
|||
</Button>
|
||||
<Button
|
||||
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]"
|
||||
>
|
||||
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Edit, Trash2, HardDrive, FileText } from "lucide-react";
|
||||
import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react";
|
||||
import { Company } from "@/types/company";
|
||||
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface CompanyTableProps {
|
||||
companies: Company[];
|
||||
|
|
@ -17,11 +18,18 @@ interface CompanyTableProps {
|
|||
* 모바일/태블릿: 카드 뷰
|
||||
*/
|
||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 부서 관리 페이지로 이동
|
||||
const handleManageDepartments = (company: Company) => {
|
||||
router.push(`/admin/company/${company.company_code}/departments`);
|
||||
};
|
||||
|
||||
// 디스크 사용량 포맷팅 함수
|
||||
const formatDiskUsage = (company: Company) => {
|
||||
if (!company.diskUsage) {
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-xs">정보 없음</span>
|
||||
</div>
|
||||
|
|
@ -33,11 +41,11 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
return (
|
||||
<div className="flex flex-col 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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,7 +57,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
return (
|
||||
<>
|
||||
{/* 데스크톱 테이블 스켈레톤 */}
|
||||
<div className="hidden bg-card shadow-sm lg:block">
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -66,21 +74,21 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<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 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 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 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 className="h-16">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted"></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 className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -92,18 +100,18 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{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="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -117,9 +125,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
// 데이터가 없을 때
|
||||
if (companies.length === 0) {
|
||||
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">
|
||||
<p className="text-sm text-muted-foreground">등록된 회사가 없습니다.</p>
|
||||
<p className="text-muted-foreground text-sm">등록된 회사가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -129,28 +137,40 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
return (
|
||||
<>
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="hidden bg-card lg:block">
|
||||
<div className="bg-card hidden lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{COMPANY_TABLE_COLUMNS.map((column) => (
|
||||
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
||||
{column.label}
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{COMPANY_TABLE_COLUMNS.map((column) => (
|
||||
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
||||
{column.label}
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 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">{formatDiskUsage(company)}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3">
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -164,7 +184,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
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="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
|
@ -182,13 +202,13 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
{companies.map((company) => (
|
||||
<div
|
||||
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="flex-1">
|
||||
<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>
|
||||
|
||||
|
|
@ -209,9 +229,13 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(company)}
|
||||
onClick={() => handleManageDepartments(company)}
|
||||
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" />
|
||||
수정
|
||||
</Button>
|
||||
|
|
@ -219,7 +243,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(company)}
|
||||
className="h-9 flex-1 gap-2 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
||||
import Webcam from "react-webcam";
|
||||
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||
|
||||
export interface BarcodeScanModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetField?: string;
|
||||
barcodeFormat?: "all" | "1d" | "2d";
|
||||
autoSubmit?: boolean;
|
||||
onScanSuccess: (barcode: string) => void;
|
||||
}
|
||||
|
||||
export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetField,
|
||||
barcodeFormat = "all",
|
||||
autoSubmit = false,
|
||||
onScanSuccess,
|
||||
}) => {
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scannedCode, setScannedCode] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 바코드 리더 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopScanning();
|
||||
if (codeReaderRef.current) {
|
||||
codeReaderRef.current.reset();
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// 카메라 권한 요청
|
||||
const requestCameraPermission = async () => {
|
||||
console.log("🎥 카메라 권한 요청 시작...");
|
||||
|
||||
// navigator.mediaDevices 지원 확인
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
console.error("❌ navigator.mediaDevices를 사용할 수 없습니다.");
|
||||
console.log("현재 프로토콜:", window.location.protocol);
|
||||
console.log("현재 호스트:", window.location.host);
|
||||
setHasPermission(false);
|
||||
setError(
|
||||
"이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " +
|
||||
"현재 프로토콜: " + window.location.protocol
|
||||
);
|
||||
toast.error("카메라 접근이 불가능합니다. 콘솔을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔄 getUserMedia 호출 중...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
console.log("✅ 카메라 권한 허용됨!");
|
||||
setHasPermission(true);
|
||||
stream.getTracks().forEach((track) => track.stop()); // 권한 확인 후 스트림 종료
|
||||
toast.success("카메라 권한이 허용되었습니다.");
|
||||
} catch (err: any) {
|
||||
console.error("❌ 카메라 권한 오류:", err);
|
||||
console.error("에러 이름:", err.name);
|
||||
console.error("에러 메시지:", err.message);
|
||||
setHasPermission(false);
|
||||
|
||||
// 에러 타입에 따라 다른 메시지 표시
|
||||
if (err.name === "NotAllowedError") {
|
||||
setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요.");
|
||||
toast.error("카메라 권한이 거부되었습니다.");
|
||||
} else if (err.name === "NotFoundError") {
|
||||
setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요.");
|
||||
toast.error("카메라를 찾을 수 없습니다.");
|
||||
} else if (err.name === "NotReadableError") {
|
||||
setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다.");
|
||||
toast.error("카메라가 사용 중입니다.");
|
||||
} else if (err.name === "NotSupportedError") {
|
||||
setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다.");
|
||||
toast.error("HTTPS 환경이 필요합니다.");
|
||||
} else {
|
||||
setError(`카메라 접근 오류: ${err.name} - ${err.message}`);
|
||||
toast.error("카메라 접근 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 스캔 시작
|
||||
const startScanning = () => {
|
||||
setIsScanning(true);
|
||||
setError("");
|
||||
setScannedCode("");
|
||||
|
||||
// 주기적으로 스캔 시도 (500ms마다)
|
||||
scanIntervalRef.current = setInterval(() => {
|
||||
scanBarcode();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 스캔 중지
|
||||
const stopScanning = () => {
|
||||
setIsScanning(false);
|
||||
if (scanIntervalRef.current) {
|
||||
clearInterval(scanIntervalRef.current);
|
||||
scanIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 바코드 스캔
|
||||
const scanBarcode = async () => {
|
||||
if (!webcamRef.current || !codeReaderRef.current) return;
|
||||
|
||||
try {
|
||||
const imageSrc = webcamRef.current.getScreenshot();
|
||||
if (!imageSrc) return;
|
||||
|
||||
// 이미지를 HTMLImageElement로 변환
|
||||
const img = new Image();
|
||||
img.src = imageSrc;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
// 바코드 디코딩
|
||||
const result = await codeReaderRef.current.decodeFromImageElement(img);
|
||||
|
||||
if (result) {
|
||||
const barcode = result.getText();
|
||||
console.log("✅ 바코드 스캔 성공:", barcode);
|
||||
|
||||
setScannedCode(barcode);
|
||||
stopScanning();
|
||||
toast.success(`바코드 스캔 완료: ${barcode}`);
|
||||
|
||||
// 자동 제출 옵션이 켜져있으면 바로 콜백 실행
|
||||
if (autoSubmit) {
|
||||
onScanSuccess(barcode);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// NotFoundException은 정상적인 상황 (바코드가 아직 인식되지 않음)
|
||||
if (!(err instanceof NotFoundException)) {
|
||||
console.error("바코드 스캔 오류:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 수동 확인 버튼
|
||||
const handleConfirm = () => {
|
||||
if (scannedCode) {
|
||||
onScanSuccess(scannedCode);
|
||||
} else {
|
||||
toast.error("스캔된 바코드가 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
카메라로 바코드를 스캔하세요.
|
||||
{targetField && ` (대상 필드: ${targetField})`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 카메라 권한 요청 대기 중 */}
|
||||
{hasPermission === null && (
|
||||
<div className="rounded-md border border-primary bg-primary/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Camera className="mt-0.5 h-5 w-5 flex-shrink-0 text-primary" />
|
||||
<div className="flex-1 space-y-3 text-xs sm:text-sm">
|
||||
<div>
|
||||
<p className="font-semibold text-primary">카메라 권한이 필요합니다</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
바코드를 스캔하려면 카메라 접근 권한을 허용해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-background/50 p-3">
|
||||
<p className="mb-2 font-medium text-foreground">💡 권한 요청 안내:</p>
|
||||
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
|
||||
<li>아래 버튼을 클릭하면 브라우저에서 권한 요청 팝업이 표시됩니다</li>
|
||||
<li>팝업에서 <strong>"허용"</strong> 버튼을 클릭해주세요</li>
|
||||
<li>권한은 언제든지 브라우저 설정에서 변경할 수 있습니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={requestCameraPermission}
|
||||
className="h-9 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
카메라 권한 요청
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카메라 권한 거부됨 */}
|
||||
{hasPermission === false && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
||||
<div className="flex-1 space-y-3 text-xs sm:text-sm">
|
||||
<div>
|
||||
<p className="font-semibold text-destructive">카메라 접근 권한이 필요합니다</p>
|
||||
<p className="mt-1 text-destructive/80">{error}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-background/50 p-3">
|
||||
<p className="mb-2 font-medium text-foreground">📱 권한 허용 방법:</p>
|
||||
<ol className="ml-4 list-decimal space-y-1 text-muted-foreground">
|
||||
<li>브라우저 주소창 왼쪽의 <strong>🔒 자물쇠 아이콘</strong>을 클릭하세요</li>
|
||||
<li><strong>"카메라"</strong> 항목을 찾아 <strong>"허용"</strong>으로 변경하세요</li>
|
||||
<li>페이지를 새로고침하거나 다시 스캔을 시도하세요</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={requestCameraPermission}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 웹캠 뷰 */}
|
||||
{hasPermission && (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
screenshotFormat="image/jpeg"
|
||||
videoConstraints={{
|
||||
facingMode: "environment", // 후면 카메라 우선
|
||||
}}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
{/* 스캔 가이드 오버레이 */}
|
||||
{isScanning && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
||||
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||
스캔 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스캔 완료 오버레이 */}
|
||||
{scannedCode && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<div className="text-center">
|
||||
<CheckCircle2 className="mx-auto h-16 w-16 text-success" />
|
||||
<p className="mt-2 text-sm font-medium">스캔 완료!</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold text-primary">{scannedCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바코드 포맷 정보 */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
<p className="font-medium">지원 포맷</p>
|
||||
<p className="mt-1">
|
||||
{barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"}
|
||||
{barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"}
|
||||
{barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
|
||||
{!isScanning && !scannedCode && hasPermission && (
|
||||
<Button
|
||||
onClick={startScanning}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
스캔 시작
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isScanning && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={stopScanning}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<CameraOff className="mr-2 h-4 w-4" />
|
||||
스캔 중지
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{scannedCode && !autoSubmit && (
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
확인
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||
|
||||
export interface ExcelUploadModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tableName: string;
|
||||
uploadMode?: "insert" | "update" | "upsert";
|
||||
keyColumn?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
tableName,
|
||||
uploadMode = "insert",
|
||||
keyColumn,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
const [selectedSheet, setSelectedSheet] = useState<string>("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
// 파일 확장자 검증
|
||||
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
|
||||
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
|
||||
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
|
||||
try {
|
||||
// 시트 목록 가져오기
|
||||
const sheets = await getExcelSheetNames(selectedFile);
|
||||
setSheetNames(sheets);
|
||||
setSelectedSheet(sheets[0] || "");
|
||||
|
||||
// 미리보기 데이터 로드 (첫 5행)
|
||||
const data = await importFromExcel(selectedFile, sheets[0]);
|
||||
setPreviewData(data.slice(0, 5));
|
||||
|
||||
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
|
||||
} catch (error) {
|
||||
console.error("파일 읽기 오류:", error);
|
||||
toast.error("파일을 읽는 중 오류가 발생했습니다.");
|
||||
setFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 시트 변경 핸들러
|
||||
const handleSheetChange = async (sheetName: string) => {
|
||||
setSelectedSheet(sheetName);
|
||||
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const data = await importFromExcel(file, sheetName);
|
||||
setPreviewData(data.slice(0, 5));
|
||||
} catch (error) {
|
||||
console.error("시트 읽기 오류:", error);
|
||||
toast.error("시트를 읽는 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 업로드 핸들러
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
toast.error("파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
toast.error("테이블명이 지정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// 엑셀 데이터 읽기
|
||||
const data = await importFromExcel(file, selectedSheet);
|
||||
|
||||
console.log("📤 엑셀 업로드 시작:", {
|
||||
tableName,
|
||||
uploadMode,
|
||||
rowCount: data.length,
|
||||
});
|
||||
|
||||
// 업로드 모드에 따라 처리
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const row of data) {
|
||||
try {
|
||||
if (uploadMode === "insert") {
|
||||
// 삽입 모드
|
||||
const formData = { screenId: 0, tableName, data: row };
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("저장 실패:", result.message, row);
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "update" && keyColumn) {
|
||||
// 업데이트 모드
|
||||
const keyValue = row[keyColumn];
|
||||
if (keyValue) {
|
||||
await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row);
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "upsert" && keyColumn) {
|
||||
// Upsert 모드 (있으면 업데이트, 없으면 삽입)
|
||||
const keyValue = row[keyColumn];
|
||||
if (keyValue) {
|
||||
try {
|
||||
const updateResult = await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row);
|
||||
if (!updateResult.success) {
|
||||
// 업데이트 실패 시 삽입 시도
|
||||
const formData = { screenId: 0, tableName, data: row };
|
||||
const insertResult = await DynamicFormApi.saveFormData(formData);
|
||||
if (insertResult.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("Upsert 실패:", insertResult.message, row);
|
||||
failCount++;
|
||||
}
|
||||
} else {
|
||||
successCount++;
|
||||
}
|
||||
} catch {
|
||||
const formData = { screenId: 0, tableName, data: row };
|
||||
const insertResult = await DynamicFormApi.saveFormData(formData);
|
||||
if (insertResult.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("Upsert 실패:", insertResult.message, row);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const formData = { screenId: 0, tableName, data: row };
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("저장 실패:", result.message, row);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("행 처리 오류:", row, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 엑셀 업로드 완료:", {
|
||||
successCount,
|
||||
failCount,
|
||||
totalCount: data.length,
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`);
|
||||
// onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음
|
||||
onSuccess?.();
|
||||
// onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음
|
||||
} else {
|
||||
toast.error("업로드에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 업로드 실패:", error);
|
||||
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[900px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">엑셀 파일 업로드</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 파일을 선택하여 데이터를 업로드하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 파일 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||
파일 선택 *
|
||||
</Label>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{file ? file.name : "파일 선택"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
지원 형식: .xlsx, .xls, .csv
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 시트 선택 */}
|
||||
{sheetNames.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="sheet-select" className="text-xs sm:text-sm">
|
||||
시트 선택
|
||||
</Label>
|
||||
<Select value={selectedSheet} onValueChange={handleSheetChange}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시트를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sheetNames.map((sheetName) => (
|
||||
<SelectItem key={sheetName} value={sheetName} className="text-xs sm:text-sm">
|
||||
<FileSpreadsheet className="mr-2 inline h-4 w-4" />
|
||||
{sheetName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 모드 정보 */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
<p className="font-medium">업로드 모드: {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}</p>
|
||||
<p className="mt-1">
|
||||
{uploadMode === "insert" && "새로운 데이터로 삽입됩니다."}
|
||||
{uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`}
|
||||
{uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{previewData.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">미리보기 (최대 5행)</Label>
|
||||
<div className="mt-2 max-h-[300px] overflow-auto rounded-md border border-border">
|
||||
<table className="min-w-full text-[10px] sm:text-xs">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
{Object.keys(previewData[0]).map((key) => (
|
||||
<th key={key} className="whitespace-nowrap border-b border-border px-2 py-1 text-left font-medium">
|
||||
{key}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.map((row, index) => (
|
||||
<tr key={index} className="border-b border-border last:border-0">
|
||||
{Object.values(row).map((value, i) => (
|
||||
<td key={i} className="max-w-[150px] truncate whitespace-nowrap px-2 py-1" title={String(value)}>
|
||||
{String(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
<CheckCircle2 className="h-3 w-3 text-success" />
|
||||
<span>총 {previewData.length}개 행 (미리보기)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUploading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || isUploading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isUploading ? "업로드 중..." : "업로드"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -485,6 +485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
page,
|
||||
size: pageSize,
|
||||
search: searchParams,
|
||||
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
|
|
@ -576,7 +577,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[component.tableName, pageSize],
|
||||
[component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가
|
||||
);
|
||||
|
||||
// 현재 사용자 정보 로드
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
|
|||
import { FileUpload } from "./widgets/FileUpload";
|
||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
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 { enhancedFormService } from "@/lib/services/enhancedFormService";
|
||||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||
|
|
@ -237,14 +237,46 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
// 자동입력 필드들의 값을 formData에 초기 설정
|
||||
React.useEffect(() => {
|
||||
// console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
|
||||
const initAutoInputFields = () => {
|
||||
const initAutoInputFields = async () => {
|
||||
// console.log("🔧 initAutoInputFields 실행 시작");
|
||||
allComponents.forEach(comp => {
|
||||
if (comp.type === 'widget') {
|
||||
for (const comp of allComponents) {
|
||||
// 🆕 type: "component" 또는 type: "widget" 모두 처리
|
||||
if (comp.type === 'widget' || comp.type === 'component') {
|
||||
const widget = comp as WidgetComponent;
|
||||
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') &&
|
||||
widget.webTypeConfig) {
|
||||
const config = widget.webTypeConfig as TextTypeConfig;
|
||||
|
|
@ -278,12 +310,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드 시 자동입력 필드들 설정
|
||||
initAutoInputFields();
|
||||
}, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지)
|
||||
}, [allComponents, generateAutoValue, user]); // formData는 의존성에서 제외 (무한 루프 방지)
|
||||
|
||||
// 날짜 값 업데이트
|
||||
const updateDateValue = (fieldName: string, date: Date | undefined) => {
|
||||
|
|
@ -1221,6 +1253,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
const handleSaveAction = async () => {
|
||||
// console.log("💾 저장 시작");
|
||||
|
||||
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
|
||||
if (!user?.userId) {
|
||||
alert("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 개선된 검증 시스템이 활성화된 경우
|
||||
if (enhancedValidation) {
|
||||
// console.log("🔍 개선된 검증 시스템 사용");
|
||||
|
|
@ -1357,19 +1395,26 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
allComponents.find(c => c.columnName)?.tableName ||
|
||||
"dynamic_form_data"; // 기본값
|
||||
|
||||
// 🆕 자동으로 작성자 정보 추가
|
||||
const writerValue = user?.userId || userName || "unknown";
|
||||
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
||||
const writerValue = user.userId;
|
||||
const companyCodeValue = user.companyCode || "";
|
||||
|
||||
console.log("👤 현재 사용자 정보:", {
|
||||
userId: user?.userId,
|
||||
userId: user.userId,
|
||||
userName: userName,
|
||||
writerValue: writerValue,
|
||||
companyCode: user.companyCode, // ✅ 회사 코드
|
||||
formDataWriter: mappedData.writer, // ✅ 폼에서 입력한 writer 값
|
||||
formDataCompanyCode: mappedData.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||
defaultWriterValue: writerValue,
|
||||
companyCodeValue, // ✅ 최종 회사 코드 값
|
||||
});
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...mappedData,
|
||||
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
writer: mappedData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
||||
company_code: mappedData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||
};
|
||||
|
||||
const saveData: DynamicFormData = {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
|
||||
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(
|
||||
(autoValueType: string): string => {
|
||||
|
|
@ -105,6 +120,50 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
[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(() => {
|
||||
if (popupScreen?.screenId) {
|
||||
|
|
@ -142,15 +201,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
};
|
||||
|
||||
// 폼 데이터 변경 핸들러
|
||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||
// console.log(`🎯 InteractiveScreenViewerDynamic handleFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
// console.log(`📋 onFormDataChange 존재 여부:`, !!onFormDataChange);
|
||||
|
||||
const handleFormDataChange = (fieldName: string | any, value?: any) => {
|
||||
// 일반 필드 변경
|
||||
if (onFormDataChange) {
|
||||
// console.log(`📤 InteractiveScreenViewerDynamic -> onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
onFormDataChange(fieldName, value);
|
||||
} else {
|
||||
// console.log(`💾 InteractiveScreenViewerDynamic 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
||||
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}
|
||||
};
|
||||
|
|
@ -190,6 +245,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const handleSave = async () => {
|
||||
if (!screenData || !screenId) return;
|
||||
|
||||
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
|
||||
if (!user?.userId) {
|
||||
toast.error("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
|
|
@ -129,19 +135,26 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
// 저장할 데이터 준비
|
||||
const dataToSave = initialData ? changedData : formData;
|
||||
|
||||
// 🆕 자동으로 작성자 정보 추가
|
||||
const writerValue = user?.userId || userName || "unknown";
|
||||
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
||||
const writerValue = user.userId;
|
||||
const companyCodeValue = user.companyCode || "";
|
||||
|
||||
console.log("👤 현재 사용자 정보:", {
|
||||
userId: user?.userId,
|
||||
userId: user.userId,
|
||||
userName: userName,
|
||||
writerValue: writerValue,
|
||||
companyCode: user.companyCode, // ✅ 회사 코드
|
||||
formDataWriter: dataToSave.writer, // ✅ 폼에서 입력한 writer 값
|
||||
formDataCompanyCode: dataToSave.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||
defaultWriterValue: writerValue,
|
||||
companyCodeValue, // ✅ 최종 회사 코드 값
|
||||
});
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...dataToSave,
|
||||
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
writer: dataToSave.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
||||
company_code: dataToSave.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||
};
|
||||
|
||||
// 테이블명 결정
|
||||
|
|
@ -277,6 +290,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
}}
|
||||
screenId={screenId}
|
||||
tableName={screenData.tableName}
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
|
|
|
|||
|
|
@ -267,6 +267,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -709,6 +712,132 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 엑셀 다운로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_download" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">엑셀 다운로드 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="excel-filename">파일명 (선택사항)</Label>
|
||||
<Input
|
||||
id="excel-filename"
|
||||
placeholder="예: 데이터목록 (기본값: export)"
|
||||
value={config.action?.excelFileName || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.excelFileName", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">확장자(.xlsx)는 자동으로 추가됩니다</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="excel-sheetname">시트명 (선택사항)</Label>
|
||||
<Input
|
||||
id="excel-sheetname"
|
||||
placeholder="예: Sheet1 (기본값)"
|
||||
value={config.action?.excelSheetName || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.excelSheetName", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="excel-include-headers">헤더 포함</Label>
|
||||
<Switch
|
||||
id="excel-include-headers"
|
||||
checked={config.action?.excelIncludeHeaders !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.excelIncludeHeaders", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 엑셀 업로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_upload" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📤 엑셀 업로드 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="excel-upload-mode">업로드 모드</Label>
|
||||
<Select
|
||||
value={config.action?.excelUploadMode || "insert"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.excelUploadMode", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="insert">신규 삽입 (INSERT)</SelectItem>
|
||||
<SelectItem value="update">기존 수정 (UPDATE)</SelectItem>
|
||||
<SelectItem value="upsert">삽입/수정 (UPSERT)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && (
|
||||
<div>
|
||||
<Label htmlFor="excel-key-column">
|
||||
키 컬럼명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="excel-key-column"
|
||||
placeholder="예: id, code"
|
||||
value={config.action?.excelKeyColumn || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">UPDATE/UPSERT 시 기준이 되는 컬럼명</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바코드 스캔 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "barcode_scan" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📷 바코드 스캔 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="barcode-target-field">
|
||||
대상 필드명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="barcode-target-field"
|
||||
placeholder="예: barcode, qr_code"
|
||||
value={config.action?.barcodeTargetField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.barcodeTargetField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">스캔 결과가 입력될 폼 필드명</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="barcode-format">바코드 형식</Label>
|
||||
<Select
|
||||
value={config.action?.barcodeFormat || "all"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.barcodeFormat", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">모든 형식</SelectItem>
|
||||
<SelectItem value="1d">1D 바코드만 (CODE128, EAN13 등)</SelectItem>
|
||||
<SelectItem value="2d">2D 바코드만 (QR코드, DataMatrix 등)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="barcode-auto-submit">스캔 후 자동 저장</Label>
|
||||
<Switch
|
||||
id="barcode-auto-submit"
|
||||
checked={config.action?.barcodeAutoSubmit === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.barcodeAutoSubmit", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제어 기능 섹션 */}
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
|
|
|
|||
|
|
@ -2198,6 +2198,90 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<h4 className="text-sm font-medium">페이지네이션 설정</h4>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
"use client";
|
||||
|
||||
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 { 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 { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
|
||||
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>
|
||||
|
|
@ -1202,7 +1336,144 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,9 +118,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
{/* 안내 메시지 */}
|
||||
<Separator className="my-4" />
|
||||
<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" />
|
||||
<p className="text-[10px] text-muted-foreground">컴포넌트를 선택하여</p>
|
||||
<p className="text-[10px] text-muted-foreground">속성을 편집하세요</p>
|
||||
<Settings className="text-muted-foreground/30 mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-[10px]">컴포넌트를 선택하여</p>
|
||||
<p className="text-muted-foreground text-[10px]">속성을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -412,8 +412,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
|
||||
const renderDetailTab = () => {
|
||||
console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type);
|
||||
|
||||
// 1. DataTable 컴포넌트
|
||||
if (selectedComponent.type === "datatable") {
|
||||
console.log("✅ [renderDetailTab] DataTable 컴포넌트");
|
||||
return (
|
||||
<DataTableConfigPanel
|
||||
component={selectedComponent as DataTableComponent}
|
||||
|
|
@ -470,6 +473,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
// 5. 새로운 컴포넌트 시스템 (type: "component")
|
||||
if (selectedComponent.type === "component") {
|
||||
console.log("✅ [renderDetailTab] Component 타입");
|
||||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
|
||||
|
|
@ -479,7 +483,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
if (!componentId) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -511,7 +515,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<SelectItem key={option.value} value={option.value}>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Widget 컴포넌트
|
||||
if (selectedComponent.type === "widget") {
|
||||
console.log("✅ [renderDetailTab] Widget 타입");
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType);
|
||||
|
||||
// Widget에 webType이 있는 경우
|
||||
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 등)
|
||||
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크
|
||||
if (
|
||||
widget.widgetType &&
|
||||
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
|
||||
widget.widgetType,
|
||||
)
|
||||
) {
|
||||
console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)");
|
||||
return (
|
||||
<DynamicComponentConfigPanel
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -602,9 +871,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
return (
|
||||
<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" && (
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
<div className="text-muted-foreground truncate text-[10px]">
|
||||
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ export const MOCK_COMPANIES: Company[] = [
|
|||
// 새 회사 등록 시 기본값
|
||||
export const DEFAULT_COMPANY_FORM_DATA = {
|
||||
company_name: "",
|
||||
business_registration_number: "",
|
||||
representative_name: "",
|
||||
representative_phone: "",
|
||||
email: "",
|
||||
website: "",
|
||||
address: "",
|
||||
};
|
||||
|
||||
// 페이징 관련 상수
|
||||
|
|
|
|||
|
|
@ -144,6 +144,12 @@ export const useCompanyManagement = () => {
|
|||
selectedCompany: company,
|
||||
formData: {
|
||||
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("회사명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
if (!modalState.formData.business_registration_number.trim()) {
|
||||
setError("사업자등록번호를 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -199,6 +209,10 @@ export const useCompanyManagement = () => {
|
|||
setError("올바른 데이터를 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
if (!modalState.formData.business_registration_number.trim()) {
|
||||
setError("사업자등록번호를 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -206,6 +220,12 @@ export const useCompanyManagement = () => {
|
|||
try {
|
||||
await companyAPI.update(modalState.selectedCompany.company_code, {
|
||||
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,
|
||||
});
|
||||
closeModal();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -410,6 +410,128 @@ export class DynamicFormApi {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
* @param tableName 테이블명
|
||||
* @param params 검색 파라미터
|
||||
* @returns 테이블 데이터
|
||||
*/
|
||||
static async getTableData(
|
||||
tableName: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
filters?: Record<string, any>;
|
||||
},
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {});
|
||||
|
||||
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
|
||||
console.log("🔍 response.data 상세:", {
|
||||
type: typeof response.data,
|
||||
isArray: Array.isArray(response.data),
|
||||
keys: response.data ? Object.keys(response.data) : [],
|
||||
hasData: response.data?.data !== undefined,
|
||||
dataType: response.data?.data ? typeof response.data.data : "N/A",
|
||||
dataIsArray: response.data?.data ? Array.isArray(response.data.data) : false,
|
||||
dataLength: response.data?.data ? (Array.isArray(response.data.data) ? response.data.data.length : "not array") : "no data",
|
||||
// 중첩 구조 확인
|
||||
dataDataExists: response.data?.data?.data !== undefined,
|
||||
dataDataIsArray: response.data?.data?.data ? Array.isArray(response.data.data.data) : false,
|
||||
dataDataLength: response.data?.data?.data ? (Array.isArray(response.data.data.data) ? response.data.data.data.length : "not array") : "no nested data",
|
||||
});
|
||||
|
||||
// API 응답 구조: { data: [...], total, page, size, totalPages }
|
||||
// 또는 중첩: { success: true, data: { data: [...], total, ... } }
|
||||
// data 배열만 추출
|
||||
let tableData: any[] = [];
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// 케이스 1: 응답이 배열이면 그대로 사용
|
||||
console.log("✅ 케이스 1: 응답이 배열");
|
||||
tableData = response.data;
|
||||
} else if (response.data && Array.isArray(response.data.data)) {
|
||||
// 케이스 2: 응답이 { data: [...] } 구조면 data 배열 추출
|
||||
console.log("✅ 케이스 2: 응답이 { data: [...] } 구조");
|
||||
tableData = response.data.data;
|
||||
} else if (response.data?.data?.data && Array.isArray(response.data.data.data)) {
|
||||
// 케이스 2-1: 중첩 구조 { success: true, data: { data: [...] } }
|
||||
console.log("✅ 케이스 2-1: 중첩 구조 { data: { data: [...] } }");
|
||||
tableData = response.data.data.data;
|
||||
} else if (response.data && typeof response.data === "object") {
|
||||
// 케이스 3: 응답이 객체면 배열로 감싸기 (최후의 수단)
|
||||
console.log("⚠️ 케이스 3: 응답이 객체 (배열로 감싸기)");
|
||||
tableData = [response.data];
|
||||
}
|
||||
|
||||
console.log("✅ 테이블 데이터 추출 완료:", {
|
||||
originalType: typeof response.data,
|
||||
isArray: Array.isArray(response.data),
|
||||
hasDataProperty: response.data?.data !== undefined,
|
||||
extractedCount: tableData.length,
|
||||
firstRow: tableData[0],
|
||||
allRows: tableData,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tableData,
|
||||
message: "테이블 데이터 조회가 완료되었습니다.",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
|
||||
const errorMessage = error.response?.data?.message || error.message || "테이블 데이터 조회 중 오류가 발생했습니다.";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 (대량 데이터 삽입/업데이트)
|
||||
* @param payload 업로드 데이터
|
||||
* @returns 업로드 결과
|
||||
*/
|
||||
static async uploadExcelData(payload: {
|
||||
tableName: string;
|
||||
data: any[];
|
||||
uploadMode: "insert" | "update" | "upsert";
|
||||
keyColumn?: string;
|
||||
}): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
console.log("📤 엑셀 업로드 요청:", payload);
|
||||
|
||||
const response = await apiClient.post(`/dynamic-form/excel-upload`, payload);
|
||||
|
||||
console.log("✅ 엑셀 업로드 성공:", response.data);
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
message: "엑셀 파일이 성공적으로 업로드되었습니다.",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 엑셀 업로드 실패:", error);
|
||||
|
||||
const errorMessage = error.response?.data?.message || error.message || "엑셀 업로드 중 오류가 발생했습니다.";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 편의를 위한 기본 export
|
||||
|
|
|
|||
|
|
@ -313,6 +313,21 @@ export const tableTypeApi = {
|
|||
deleteTableData: async (tableName: string, data: Record<string, any>[] | { ids: string[] }): Promise<void> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 추가 안전장치: 모든 로딩 토스트 제거
|
||||
toast.dismiss();
|
||||
|
||||
// UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시
|
||||
const silentActions = ["edit", "modal", "navigate"];
|
||||
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
||||
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (!silentActions.includes(actionConfig.type)) {
|
||||
currentLoadingToastRef.current = toast.loading(
|
||||
actionConfig.type === "save"
|
||||
|
|
@ -274,9 +274,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 실패한 경우 오류 처리
|
||||
if (!success) {
|
||||
// UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리
|
||||
const silentActions = ["edit", "modal", "navigate"];
|
||||
if (silentActions.includes(actionConfig.type)) {
|
||||
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
|
||||
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (silentErrorActions.includes(actionConfig.type)) {
|
||||
return;
|
||||
}
|
||||
// 기본 에러 메시지 결정
|
||||
|
|
@ -302,8 +302,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
|
||||
// 성공한 경우에만 성공 토스트 표시
|
||||
// edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요)
|
||||
if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") {
|
||||
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리
|
||||
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시)
|
||||
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (!silentSuccessActions.includes(actionConfig.type)) {
|
||||
// 기본 성공 메시지 결정
|
||||
const defaultSuccessMessage =
|
||||
actionConfig.type === "save"
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 컬럼 라벨 가져오기
|
||||
// ========================================
|
||||
|
||||
const fetchColumnLabels = async () => {
|
||||
const fetchColumnLabels = useCallback(async () => {
|
||||
if (!tableConfig.selectedTable) return;
|
||||
|
||||
try {
|
||||
|
|
@ -339,13 +339,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
} catch (error) {
|
||||
console.error("컬럼 라벨 가져오기 실패:", error);
|
||||
}
|
||||
};
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// ========================================
|
||||
// 테이블 라벨 가져오기
|
||||
// ========================================
|
||||
|
||||
const fetchTableLabel = async () => {
|
||||
const fetchTableLabel = useCallback(async () => {
|
||||
if (!tableConfig.selectedTable) return;
|
||||
|
||||
try {
|
||||
|
|
@ -374,7 +374,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
} catch (error) {
|
||||
console.error("테이블 라벨 가져오기 실패:", error);
|
||||
}
|
||||
};
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// ========================================
|
||||
// 데이터 가져오기
|
||||
|
|
@ -531,7 +531,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData });
|
||||
onFormDataChange({
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
selectedRowsData,
|
||||
});
|
||||
}
|
||||
|
||||
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
|
|
@ -549,7 +552,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data });
|
||||
onFormDataChange({
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
selectedRowsData: data,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
|
|
@ -930,7 +936,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
useEffect(() => {
|
||||
fetchColumnLabels();
|
||||
fetchTableLabel();
|
||||
}, [tableConfig.selectedTable]);
|
||||
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && tableConfig.selectedTable) {
|
||||
|
|
@ -945,6 +951,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
searchTerm,
|
||||
refreshKey,
|
||||
isDesignMode,
|
||||
fetchTableDataDebounced,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ export type ButtonActionType =
|
|||
| "navigate" // 페이지 이동
|
||||
| "modal" // 모달 열기
|
||||
| "control" // 제어 흐름
|
||||
| "view_table_history"; // 테이블 이력 보기
|
||||
| "view_table_history" // 테이블 이력 보기
|
||||
| "excel_download" // 엑셀 다운로드
|
||||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan"; // 바코드 스캔
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
|
|
@ -56,6 +59,20 @@ export interface ButtonActionConfig {
|
|||
historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스
|
||||
historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항)
|
||||
historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
|
||||
|
||||
// 엑셀 다운로드 관련
|
||||
excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx)
|
||||
excelSheetName?: string; // 시트명 (기본: "Sheet1")
|
||||
excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true)
|
||||
|
||||
// 엑셀 업로드 관련
|
||||
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
|
||||
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
|
||||
|
||||
// 바코드 스캔 관련
|
||||
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
||||
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
|
||||
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -121,6 +138,15 @@ export class ButtonActionExecutor {
|
|||
case "view_table_history":
|
||||
return this.handleViewTableHistory(config, context);
|
||||
|
||||
case "excel_download":
|
||||
return await this.handleExcelDownload(config, context);
|
||||
|
||||
case "excel_upload":
|
||||
return await this.handleExcelUpload(config, context);
|
||||
|
||||
case "barcode_scan":
|
||||
return await this.handleBarcodeScan(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -203,15 +229,29 @@ export class ButtonActionExecutor {
|
|||
// INSERT 처리
|
||||
|
||||
// 🆕 자동으로 작성자 정보 추가
|
||||
const writerValue = context.userId || context.userName || "unknown";
|
||||
if (!context.userId) {
|
||||
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
|
||||
}
|
||||
|
||||
const writerValue = context.userId;
|
||||
const companyCodeValue = context.companyCode || "";
|
||||
|
||||
console.log("👤 [buttonActions] 사용자 정보:", {
|
||||
userId: context.userId,
|
||||
userName: context.userName,
|
||||
companyCode: context.companyCode, // ✅ 회사 코드
|
||||
formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값
|
||||
formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||
defaultWriterValue: writerValue,
|
||||
companyCodeValue, // ✅ 최종 회사 코드 값
|
||||
});
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...formData,
|
||||
writer: writerValue,
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
company_code: companyCodeValue,
|
||||
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
||||
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||
};
|
||||
|
||||
saveResult = await DynamicFormApi.saveFormData({
|
||||
|
|
@ -1632,6 +1672,226 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드 액션 처리
|
||||
*/
|
||||
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📥 엑셀 다운로드 시작:", { config, context });
|
||||
|
||||
// 동적 import로 엑셀 유틸리티 로드
|
||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||
|
||||
let dataToExport: any[] = [];
|
||||
|
||||
// 1순위: 선택된 행 데이터
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
dataToExport = context.selectedRowsData;
|
||||
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
||||
}
|
||||
// 2순위: 테이블 전체 데이터 (API 호출)
|
||||
else if (context.tableName) {
|
||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
||||
try {
|
||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||
page: 1,
|
||||
pageSize: 10000, // 최대 10,000개 행
|
||||
sortBy: "id", // 기본 정렬: id 컬럼
|
||||
sortOrder: "asc", // 오름차순
|
||||
});
|
||||
|
||||
console.log("📦 API 응답 구조:", {
|
||||
response,
|
||||
responseSuccess: response.success,
|
||||
responseData: response.data,
|
||||
responseDataType: typeof response.data,
|
||||
responseDataIsArray: Array.isArray(response.data),
|
||||
responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A",
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
dataToExport = response.data;
|
||||
console.log("✅ 테이블 전체 데이터 조회 완료:", {
|
||||
count: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
});
|
||||
} else {
|
||||
console.error("❌ API 응답에 데이터가 없습니다:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
// 4순위: 폼 데이터
|
||||
else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
dataToExport = [context.formData];
|
||||
console.log("✅ 폼 데이터 사용:", dataToExport);
|
||||
}
|
||||
|
||||
console.log("📊 최종 다운로드 데이터:", {
|
||||
selectedRowsData: context.selectedRowsData,
|
||||
selectedRowsLength: context.selectedRowsData?.length,
|
||||
formData: context.formData,
|
||||
tableName: context.tableName,
|
||||
dataToExport,
|
||||
dataToExportType: typeof dataToExport,
|
||||
dataToExportIsArray: Array.isArray(dataToExport),
|
||||
dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A",
|
||||
});
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
if (!Array.isArray(dataToExport)) {
|
||||
console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport);
|
||||
|
||||
// 객체인 경우 배열로 감싸기
|
||||
if (typeof dataToExport === "object" && dataToExport !== null) {
|
||||
dataToExport = [dataToExport];
|
||||
} else {
|
||||
toast.error("다운로드할 데이터 형식이 올바르지 않습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataToExport.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 파일명 생성
|
||||
const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||
const sheetName = config.excelSheetName || "Sheet1";
|
||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||
|
||||
console.log("📥 엑셀 다운로드 실행:", {
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
dataCount: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
});
|
||||
|
||||
// 엑셀 다운로드 실행
|
||||
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
||||
|
||||
toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 다운로드 실패:", error);
|
||||
toast.error(config.errorMessage || "엑셀 다운로드 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 액션 처리
|
||||
*/
|
||||
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📤 엑셀 업로드 모달 열기:", { config, context });
|
||||
|
||||
// 동적 import로 모달 컴포넌트 로드
|
||||
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
|
||||
// 모달 컨테이너 생성
|
||||
const modalContainer = document.createElement("div");
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
const root = createRoot(modalContainer);
|
||||
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
document.body.removeChild(modalContainer);
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(ExcelUploadModal, {
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) closeModal();
|
||||
},
|
||||
tableName: context.tableName || "",
|
||||
uploadMode: config.excelUploadMode || "insert",
|
||||
keyColumn: config.excelKeyColumn,
|
||||
onSuccess: () => {
|
||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||
context.onRefresh?.();
|
||||
closeModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 업로드 모달 열기 실패:", error);
|
||||
toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바코드 스캔 액션 처리
|
||||
*/
|
||||
private static async handleBarcodeScan(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📷 바코드 스캔 모달 열기:", { config, context });
|
||||
|
||||
// 동적 import로 모달 컴포넌트 로드
|
||||
const { BarcodeScanModal } = await import("@/components/common/BarcodeScanModal");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
|
||||
// 모달 컨테이너 생성
|
||||
const modalContainer = document.createElement("div");
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
const root = createRoot(modalContainer);
|
||||
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
document.body.removeChild(modalContainer);
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(BarcodeScanModal, {
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) closeModal();
|
||||
},
|
||||
targetField: config.barcodeTargetField,
|
||||
barcodeFormat: config.barcodeFormat || "all",
|
||||
autoSubmit: config.barcodeAutoSubmit || false,
|
||||
onScanSuccess: (barcode: string) => {
|
||||
console.log("✅ 바코드 스캔 성공:", barcode);
|
||||
|
||||
// 대상 필드에 값 입력
|
||||
if (config.barcodeTargetField && context.onFormDataChange) {
|
||||
context.onFormDataChange({
|
||||
...context.formData,
|
||||
[config.barcodeTargetField]: barcode,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`바코드 스캔 완료: ${barcode}`);
|
||||
|
||||
// 자동 제출 옵션이 켜져있으면 저장
|
||||
if (config.barcodeAutoSubmit) {
|
||||
this.handleSave(config, context);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 바코드 스캔 모달 열기 실패:", error);
|
||||
toast.error("바코드 스캔 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 유효성 검사
|
||||
*/
|
||||
|
|
@ -1703,4 +1963,22 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
historyRecordIdField: "id",
|
||||
historyRecordIdSource: "selected_row",
|
||||
},
|
||||
excel_download: {
|
||||
type: "excel_download",
|
||||
excelIncludeHeaders: true,
|
||||
successMessage: "엑셀 파일이 다운로드되었습니다.",
|
||||
errorMessage: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
},
|
||||
excel_upload: {
|
||||
type: "excel_upload",
|
||||
excelUploadMode: "insert",
|
||||
confirmMessage: "엑셀 파일을 업로드하시겠습니까?",
|
||||
successMessage: "엑셀 파일이 업로드되었습니다.",
|
||||
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
|
||||
},
|
||||
barcode_scan: {
|
||||
type: "barcode_scan",
|
||||
barcodeFormat: "all",
|
||||
barcodeAutoSubmit: false,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* 엑셀 내보내기 유틸리티
|
||||
* xlsx 라이브러리를 사용하여 데이터를 엑셀 파일로 변환
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
/**
|
||||
* 데이터를 엑셀 파일로 내보내기
|
||||
* @param data 내보낼 데이터 배열
|
||||
* @param fileName 파일명 (기본: "export.xlsx")
|
||||
* @param sheetName 시트명 (기본: "Sheet1")
|
||||
* @param includeHeaders 헤더 포함 여부 (기본: true)
|
||||
*/
|
||||
export async function exportToExcel(
|
||||
data: Record<string, any>[],
|
||||
fileName: string = "export.xlsx",
|
||||
sheetName: string = "Sheet1",
|
||||
includeHeaders: boolean = true
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("📥 엑셀 내보내기 시작:", {
|
||||
dataCount: data.length,
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error("내보낼 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// 데이터를 워크시트로 변환
|
||||
const worksheet = XLSX.utils.json_to_sheet(data, {
|
||||
header: includeHeaders ? undefined : [],
|
||||
skipHeader: !includeHeaders,
|
||||
});
|
||||
|
||||
// 컬럼 너비 자동 조정
|
||||
const columnWidths = autoSizeColumns(data);
|
||||
worksheet["!cols"] = columnWidths;
|
||||
|
||||
// 워크시트를 워크북에 추가
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
|
||||
console.log("✅ 엑셀 내보내기 완료:", fileName);
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 내보내기 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 너비 자동 조정
|
||||
*/
|
||||
function autoSizeColumns(data: Record<string, any>[]): Array<{ wch: number }> {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const keys = Object.keys(data[0]);
|
||||
const columnWidths: Array<{ wch: number }> = [];
|
||||
|
||||
keys.forEach((key) => {
|
||||
// 헤더 길이
|
||||
let maxWidth = key.length;
|
||||
|
||||
// 데이터 길이 확인
|
||||
data.forEach((row) => {
|
||||
const value = row[key];
|
||||
const valueLength = value ? String(value).length : 0;
|
||||
maxWidth = Math.max(maxWidth, valueLength);
|
||||
});
|
||||
|
||||
// 최소 10, 최대 50으로 제한
|
||||
columnWidths.push({ wch: Math.min(Math.max(maxWidth, 10), 50) });
|
||||
});
|
||||
|
||||
return columnWidths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일을 읽어서 JSON 데이터로 변환
|
||||
* @param file 읽을 파일
|
||||
* @param sheetName 읽을 시트명 (기본: 첫 번째 시트)
|
||||
* @returns JSON 데이터 배열
|
||||
*/
|
||||
export async function importFromExcel(
|
||||
file: File,
|
||||
sheetName?: string
|
||||
): Promise<Record<string, any>[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
if (!data) {
|
||||
reject(new Error("파일을 읽을 수 없습니다."));
|
||||
return;
|
||||
}
|
||||
|
||||
// 워크북 읽기
|
||||
const workbook = XLSX.read(data, { type: "binary" });
|
||||
|
||||
// 시트 선택 (지정된 시트 또는 첫 번째 시트)
|
||||
const targetSheetName = sheetName || workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[targetSheetName];
|
||||
|
||||
if (!worksheet) {
|
||||
reject(new Error(`시트 "${targetSheetName}"를 찾을 수 없습니다.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON으로 변환
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
console.log("✅ 엑셀 가져오기 완료:", {
|
||||
sheetName: targetSheetName,
|
||||
rowCount: jsonData.length,
|
||||
});
|
||||
|
||||
resolve(jsonData as Record<string, any>[]);
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 가져오기 실패:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("파일 읽기 중 오류가 발생했습니다."));
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일의 시트 목록 가져오기
|
||||
*/
|
||||
export async function getExcelSheetNames(file: File): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
if (!data) {
|
||||
reject(new Error("파일을 읽을 수 없습니다."));
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(data, { type: "binary" });
|
||||
resolve(workbook.SheetNames);
|
||||
} catch (error) {
|
||||
console.error("❌ 시트 목록 가져오기 실패:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("파일 읽기 중 오류가 발생했습니다."));
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,6 +52,7 @@
|
|||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"@zxing/library": "^0.21.3",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -77,6 +78,7 @@
|
|||
"react-hot-toast": "^2.6.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-webcam": "^7.2.0",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.2.1",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
export interface Company {
|
||||
company_code: string; // 회사 코드 (varchar 32) - PK
|
||||
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)
|
||||
regdate: string; // 등록일시 (timestamp -> ISO string)
|
||||
status: string; // 상태 (varchar 32)
|
||||
|
|
@ -20,7 +26,13 @@ export interface Company {
|
|||
|
||||
// 회사 등록/수정 폼 데이터
|
||||
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; // 회사 주소
|
||||
}
|
||||
|
||||
// 회사 검색 필터
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +84,15 @@ export interface WidgetComponent extends BaseComponent {
|
|||
entityConfig?: EntityTypeConfig;
|
||||
buttonConfig?: ButtonTypeConfig;
|
||||
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;
|
||||
sortable?: boolean;
|
||||
filters?: DataTableFilter[];
|
||||
|
||||
// 🆕 현재 사용자 정보로 자동 필터링
|
||||
autoFilter?: {
|
||||
enabled: boolean; // 자동 필터 활성화 여부
|
||||
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
|
||||
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue