Compare commits

...

19 Commits

Author SHA1 Message Date
kjs 37796ecc9d fix: FileComponentConfigPanel에 cn 함수 import 추가 2025-11-04 16:18:12 +09:00
kjs 6901baab8e feat(screen-designer): 그리드 컬럼 시스템 개선 및 컴포넌트 너비 렌더링 수정
주요 변경사항:
- 격자 설정을 편집 탭에서 항상 표시 (해상도 설정 하단)
- 그리드 컬럼 수 동적 조정 가능 (1-24)
- 컴포넌트 생성 시 현재 그리드 컬럼 수 기반 자동 계산
- 컴포넌트 너비가 설정한 컬럼 수대로 정확히 표시되도록 수정

수정된 파일:
- ScreenDesigner: 컴포넌트 드롭 시 gridColumns와 style.width 동적 계산
- UnifiedPropertiesPanel: 격자 설정 UI 통합, 차지 컬럼 수 설정 시 width 자동 계산
- RealtimePreviewDynamic: getWidth 우선순위 수정, DOM 크기 디버깅 로그 추가
- 8개 컴포넌트: componentStyle.width를 항상 100%로 고정
  * ButtonPrimaryComponent
  * TextInputComponent
  * NumberInputComponent
  * TextareaBasicComponent
  * DateInputComponent
  * TableListComponent
  * CardDisplayComponent

문제 해결:
- 컴포넌트 내부에서 component.style.width를 재사용하여 이중 축소 발생
- 해결: 부모 컨테이너(RealtimePreviewDynamic)가 width 제어, 컴포넌트는 항상 100%
- 결과: 파란 테두리와 내부 콘텐츠가 동일한 크기로 정확히 표시
2025-11-04 16:17:19 +09:00
kjs 9f131a80ab Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-11-04 15:16:48 +09:00
hyeonsu 1e7be6c61c Merge pull request '회사 보기 기능 구현' (#179) from feat/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/179
2025-11-04 14:34:50 +09:00
dohyeons 39080dff59 autofill 기능 구현 2025-11-04 14:33:39 +09:00
dohyeons 4dde008c6d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard 2025-11-04 09:44:09 +09:00
hjlee d08ae88a93 Merge pull request 'lhj' (#178) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/178
2025-11-04 09:43:45 +09:00
leeheejin 7425c37094 엑셀 다운로드, 업로드, 사진촬영(바코드 스캔기능) 추가 2025-11-04 09:41:58 +09:00
dohyeons d428a70b69 회원 검색 기능 보완 2025-11-04 09:34:22 +09:00
dohyeons c50c8d01df 삭제 후엔 부서 선택 해제 2025-11-03 17:42:46 +09:00
dohyeons 6b53cb414c 삭제를 alert에서 modal로 변경 2025-11-03 17:28:12 +09:00
dohyeons 0d6b018ec4 부서 추가 구현 2025-11-03 16:59:01 +09:00
dohyeons b468b51aa7 회사 정보 표시 및 뒤로가기 버튼 2025-11-03 16:40:45 +09:00
dohyeons 5629cd999f 화면비 수정 2025-11-03 16:37:34 +09:00
dohyeons 257912ea92 부서 read 기능 구현 2025-11-03 16:31:03 +09:00
leeheejin 94e5a5de0b 회사코드 입력, 작성자 입력가능하게 수정완료 2025-11-03 16:26:32 +09:00
dohyeons d7164531ef Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard 2025-11-03 14:43:53 +09:00
kjs 4dba7c0a16 Merge pull request 'feature/screen-management' (#177) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/177
2025-11-03 14:42:53 +09:00
dohyeons fd7fc754f4 회사 관리 - 등록 페이지 수정 2025-11-03 14:31:21 +09:00
68 changed files with 7034 additions and 1047 deletions

View File

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

View File

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

View File

@ -0,0 +1,534 @@
import { Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { query, queryOne } from "../database/db";
/**
* ()
*/
export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { companyCode } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("부서 목록 조회", { companyCode, userCompanyCode });
// 최고 관리자가 아니면 자신의 회사만 조회 가능
if (userCompanyCode !== "*" && userCompanyCode !== companyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 부서를 조회할 권한이 없습니다.",
});
return;
}
// 부서 목록 조회 (부서원 수 포함)
const departments = await query<any>(`
SELECT
d.dept_code,
d.dept_name,
d.company_code,
d.parent_dept_code,
COUNT(DISTINCT ud.user_id) as member_count
FROM dept_info d
LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code
WHERE d.company_code = $1
GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code
ORDER BY d.dept_name
`, [companyCode]);
// 응답 형식 변환
const formattedDepartments = departments.map((dept) => ({
dept_code: dept.dept_code,
dept_name: dept.dept_name,
company_code: dept.company_code,
parent_dept_code: dept.parent_dept_code,
memberCount: parseInt(dept.member_count || "0"),
}));
res.status(200).json({
success: true,
data: formattedDepartments,
});
} catch (error) {
logger.error("부서 목록 조회 실패", error);
res.status(500).json({
success: false,
message: "부서 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const department = await queryOne<any>(`
SELECT
dept_code,
dept_name,
company_code,
parent_dept_code
FROM dept_info
WHERE dept_code = $1
`, [deptCode]);
if (!department) {
res.status(404).json({
success: false,
message: "부서를 찾을 수 없습니다.",
});
return;
}
res.status(200).json({
success: true,
data: department,
});
} catch (error) {
logger.error("부서 상세 조회 실패", error);
res.status(500).json({
success: false,
message: "부서 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { companyCode } = req.params;
const { dept_name, parent_dept_code } = req.body;
if (!dept_name || !dept_name.trim()) {
res.status(400).json({
success: false,
message: "부서명을 입력해주세요.",
});
return;
}
// 같은 회사 내 중복 부서명 확인
const duplicate = await queryOne<any>(`
SELECT dept_code, dept_name
FROM dept_info
WHERE company_code = $1 AND dept_name = $2
`, [companyCode, dept_name.trim()]);
if (duplicate) {
res.status(409).json({
success: false,
message: `"${dept_name}" 부서가 이미 존재합니다.`,
isDuplicate: true,
});
return;
}
// 회사 이름 조회
const company = await queryOne<any>(`
SELECT company_name FROM company_mng WHERE company_code = $1
`, [companyCode]);
const companyName = company?.company_name || companyCode;
// 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...)
const codeResult = await queryOne<any>(`
SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number
FROM dept_info
WHERE dept_code ~ '^DEPT_[0-9]+$'
`);
const nextNumber = codeResult?.next_number || 1;
const deptCode = `DEPT_${nextNumber}`;
// 부서 생성
const result = await query<any>(`
INSERT INTO dept_info (
dept_code,
dept_name,
company_code,
company_name,
parent_dept_code,
status,
regdate
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING *
`, [
deptCode,
dept_name.trim(),
companyCode,
companyName,
parent_dept_code || null,
'active',
]);
logger.info("부서 생성 성공", { deptCode, dept_name });
res.status(201).json({
success: true,
message: "부서가 생성되었습니다.",
data: result[0],
});
} catch (error) {
logger.error("부서 생성 실패", error);
res.status(500).json({
success: false,
message: "부서 생성 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const { dept_name, parent_dept_code } = req.body;
if (!dept_name || !dept_name.trim()) {
res.status(400).json({
success: false,
message: "부서명을 입력해주세요.",
});
return;
}
const result = await query<any>(`
UPDATE dept_info
SET
dept_name = $1,
parent_dept_code = $2
WHERE dept_code = $3
RETURNING *
`, [dept_name.trim(), parent_dept_code || null, deptCode]);
if (result.length === 0) {
res.status(404).json({
success: false,
message: "부서를 찾을 수 없습니다.",
});
return;
}
logger.info("부서 수정 성공", { deptCode });
res.status(200).json({
success: true,
message: "부서가 수정되었습니다.",
data: result[0],
});
} catch (error) {
logger.error("부서 수정 실패", error);
res.status(500).json({
success: false,
message: "부서 수정 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
// 하위 부서 확인
const hasChildren = await queryOne<any>(`
SELECT COUNT(*) as count
FROM dept_info
WHERE parent_dept_code = $1
`, [deptCode]);
if (parseInt(hasChildren?.count || "0") > 0) {
res.status(400).json({
success: false,
message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.",
});
return;
}
// 부서원 삭제 (부서 삭제 전에 먼저 삭제)
const deletedMembers = await query<any>(`
DELETE FROM user_dept
WHERE dept_code = $1
RETURNING user_id
`, [deptCode]);
const memberCount = deletedMembers.length;
// 부서 삭제
const result = await query<any>(`
DELETE FROM dept_info
WHERE dept_code = $1
RETURNING dept_code, dept_name
`, [deptCode]);
if (result.length === 0) {
res.status(404).json({
success: false,
message: "부서를 찾을 수 없습니다.",
});
return;
}
logger.info("부서 삭제 성공", {
deptCode,
deptName: result[0].dept_name,
deletedMemberCount: memberCount
});
res.status(200).json({
success: true,
message: memberCount > 0
? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)`
: "부서가 삭제되었습니다.",
});
} catch (error) {
logger.error("부서 삭제 실패", error);
res.status(500).json({
success: false,
message: "부서 삭제 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const members = await query<any>(`
SELECT
u.user_id,
u.user_name,
u.email,
u.tel as phone,
u.cell_phone,
u.position_name,
ud.dept_code,
d.dept_name,
ud.is_primary
FROM user_dept ud
JOIN user_info u ON ud.user_id = u.user_id
JOIN dept_info d ON ud.dept_code = d.dept_code
WHERE ud.dept_code = $1
ORDER BY ud.is_primary DESC, u.user_name
`, [deptCode]);
res.status(200).json({
success: true,
data: members,
});
} catch (error) {
logger.error("부서원 목록 조회 실패", error);
res.status(500).json({
success: false,
message: "부서원 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
* ( )
*/
export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { companyCode } = req.params;
const { search } = req.query;
if (!search || typeof search !== 'string') {
res.status(400).json({
success: false,
message: "검색어를 입력해주세요.",
});
return;
}
// 사용자 검색 (ID 또는 이름)
const users = await query<any>(`
SELECT
user_id,
user_name,
email,
position_name,
company_code
FROM user_info
WHERE company_code = $1
AND (
user_id ILIKE $2 OR
user_name ILIKE $2
)
ORDER BY user_name
LIMIT 20
`, [companyCode, `%${search}%`]);
res.status(200).json({
success: true,
data: users,
});
} catch (error) {
logger.error("사용자 검색 실패", error);
res.status(500).json({
success: false,
message: "사용자 검색 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const { user_id } = req.body;
if (!user_id) {
res.status(400).json({
success: false,
message: "사용자 ID를 입력해주세요.",
});
return;
}
// 사용자 존재 확인
const user = await queryOne<any>(`
SELECT user_id, user_name
FROM user_info
WHERE user_id = $1
`, [user_id]);
if (!user) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
// 이미 부서원인지 확인
const existing = await queryOne<any>(`
SELECT *
FROM user_dept
WHERE user_id = $1 AND dept_code = $2
`, [user_id, deptCode]);
if (existing) {
res.status(409).json({
success: false,
message: "이미 해당 부서의 부서원입니다.",
isDuplicate: true,
});
return;
}
// 주 부서가 있는지 확인
const hasPrimary = await queryOne<any>(`
SELECT *
FROM user_dept
WHERE user_id = $1 AND is_primary = true
`, [user_id]);
// 부서원 추가
await query<any>(`
INSERT INTO user_dept (user_id, dept_code, is_primary, created_at)
VALUES ($1, $2, $3, NOW())
`, [user_id, deptCode, !hasPrimary]);
logger.info("부서원 추가 성공", { user_id, deptCode });
res.status(201).json({
success: true,
message: "부서원이 추가되었습니다.",
});
} catch (error) {
logger.error("부서원 추가 실패", error);
res.status(500).json({
success: false,
message: "부서원 추가 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode, userId } = req.params;
const result = await query<any>(`
DELETE FROM user_dept
WHERE user_id = $1 AND dept_code = $2
RETURNING *
`, [userId, deptCode]);
if (result.length === 0) {
res.status(404).json({
success: false,
message: "해당 부서원을 찾을 수 없습니다.",
});
return;
}
logger.info("부서원 제거 성공", { userId, deptCode });
res.status(200).json({
success: true,
message: "부서원이 제거되었습니다.",
});
} catch (error) {
logger.error("부서원 제거 실패", error);
res.status(500).json({
success: false,
message: "부서원 제거 중 오류가 발생했습니다.",
});
}
}
/**
*
*/
export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode, userId } = req.params;
// 다른 부서의 주 부서 해제
await query<any>(`
UPDATE user_dept
SET is_primary = false
WHERE user_id = $1
`, [userId]);
// 해당 부서를 주 부서로 설정
await query<any>(`
UPDATE user_dept
SET is_primary = true
WHERE user_id = $1 AND dept_code = $2
`, [userId, deptCode]);
logger.info("주 부서 설정 성공", { userId, deptCode });
res.status(200).json({
success: true,
message: "주 부서가 설정되었습니다.",
});
} catch (error) {
logger.error("주 부서 설정 실패", error);
res.status(500).json({
success: false,
message: "주 부서 설정 중 오류가 발생했습니다.",
});
}
}

View File

@ -12,6 +12,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(

View File

@ -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,

View File

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

View File

@ -11,6 +11,7 @@ import {
updateColumnInputType,
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

View File

@ -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 쿼리

View File

@ -51,6 +51,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -104,6 +106,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_id AS "menuId",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -153,8 +157,9 @@ class NumberingRuleService {
const insertRuleQuery = `
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
current_sequence, table_name, column_name, company_code,
menu_objid, scope_type, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING
rule_id AS "ruleId",
rule_name AS "ruleName",
@ -165,6 +170,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -180,6 +187,8 @@ class NumberingRuleService {
config.tableName || null,
config.columnName || null,
companyCode,
config.menuObjid || null,
config.scopeType || "global",
userId,
]);
@ -248,8 +257,10 @@ class NumberingRuleService {
reset_period = COALESCE($4, reset_period),
table_name = COALESCE($5, table_name),
column_name = COALESCE($6, column_name),
menu_objid = COALESCE($7, menu_objid),
scope_type = COALESCE($8, scope_type),
updated_at = NOW()
WHERE rule_id = $7 AND company_code = $8
WHERE rule_id = $9 AND company_code = $10
RETURNING
rule_id AS "ruleId",
rule_name AS "ruleName",
@ -260,6 +271,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -272,6 +285,8 @@ class NumberingRuleService {
updates.resetPeriod,
updates.tableName,
updates.columnName,
updates.menuObjid,
updates.scopeType,
ruleId,
companyCode,
]);

View File

@ -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]);
// 🎯 파일 컬럼이 있으면 파일 정보 보강

View File

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

View File

@ -0,0 +1,12 @@
"use client";
import { useParams } from "next/navigation";
import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
export default function DepartmentManagementPage() {
const params = useParams();
const companyCode = params.companyCode as string;
return <DepartmentManagement companyCode={companyCode} />;
}

View File

@ -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(() => {
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)

View File

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

View File

@ -1,8 +1,9 @@
import { Edit, Trash2, HardDrive, FileText } from "lucide-react";
import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react";
import { Company } from "@/types/company";
import { COMPANY_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" />

View File

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

View File

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

View File

@ -0,0 +1,383 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import type { Department } from "@/types/department";
import * as departmentAPI from "@/lib/api/department";
interface DepartmentStructureProps {
companyCode: string;
selectedDepartment: Department | null;
onSelectDepartment: (department: Department | null) => void;
refreshTrigger?: number;
}
/**
* ( )
*/
export function DepartmentStructure({
companyCode,
selectedDepartment,
onSelectDepartment,
refreshTrigger,
}: DepartmentStructureProps) {
const { toast } = useToast();
const [departments, setDepartments] = useState<Department[]>([]);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
// 부서 추가 모달
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
const [newDeptName, setNewDeptName] = useState("");
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
// 부서 삭제 확인 모달
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드
useEffect(() => {
loadDepartments();
}, [companyCode, refreshTrigger]);
const loadDepartments = async () => {
setIsLoading(true);
try {
const response = await departmentAPI.getDepartments(companyCode);
if (response.success && response.data) {
setDepartments(response.data);
} else {
console.error("부서 목록 로드 실패:", response.error);
setDepartments([]);
}
} catch (error) {
console.error("부서 목록 로드 실패:", error);
setDepartments([]);
} finally {
setIsLoading(false);
}
};
// 부서 트리 구조 생성
const buildTree = (parentCode: string | null): Department[] => {
return departments
.filter((dept) => dept.parent_dept_code === parentCode)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
};
// 부서 추가 핸들러
const handleAddDepartment = (parentDeptCode: string | null = null) => {
setParentDeptForAdd(parentDeptCode);
setNewDeptName("");
setIsAddModalOpen(true);
};
// 부서 저장
const handleSaveDepartment = async () => {
if (!newDeptName.trim()) return;
try {
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: newDeptName,
parent_dept_code: parentDeptForAdd,
});
if (response.success) {
setIsAddModalOpen(false);
setNewDeptName("");
setParentDeptForAdd(null);
loadDepartments();
// 성공 Toast 표시
toast({
title: "부서 생성 완료",
description: `"${newDeptName}" 부서가 생성되었습니다.`,
variant: "default",
});
} else {
if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 존재하는 부서명입니다.");
} else {
toast({
title: "부서 생성 실패",
description: response.error || "부서 추가에 실패했습니다.",
variant: "destructive",
});
}
}
} catch (error) {
console.error("부서 추가 실패:", error);
toast({
title: "부서 생성 실패",
description: "부서 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 삭제 확인 요청
const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => {
setDeptToDelete({ code: deptCode, name: deptName });
setDeleteConfirmOpen(true);
};
// 부서 삭제 실행
const handleDeleteDepartmentConfirm = async () => {
if (!deptToDelete) return;
try {
const response = await departmentAPI.deleteDepartment(deptToDelete.code);
if (response.success) {
// 삭제된 부서가 선택되어 있었다면 선택 해제
if (selectedDepartment?.dept_code === deptToDelete.code) {
onSelectDepartment(null);
}
setDeleteConfirmOpen(false);
setDeptToDelete(null);
loadDepartments();
// 성공 메시지 Toast로 표시 (부서원 수 포함)
toast({
title: "부서 삭제 완료",
description: response.message || "부서가 삭제되었습니다.",
variant: "default",
});
} else {
// 삭제 확인 모달을 닫고 에러 모달을 표시
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage(response.error || "부서 삭제에 실패했습니다.");
}
} catch (error) {
console.error("부서 삭제 실패:", error);
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다.");
}
};
// 확장/축소 토글
const toggleExpand = (deptCode: string) => {
const newExpanded = new Set(expandedDepts);
if (newExpanded.has(deptCode)) {
newExpanded.delete(deptCode);
} else {
newExpanded.add(deptCode);
}
setExpandedDepts(newExpanded);
};
// 부서 트리 렌더링 (재귀)
const renderDepartmentTree = (parentCode: string | null, level: number = 0) => {
const children = buildTree(parentCode);
return children.map((dept) => {
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
const isExpanded = expandedDepts.has(dept.dept_code);
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
return (
<div key={dept.dept_code}>
{/* 부서 항목 */}
<div
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : ""
}`}
style={{ marginLeft: `${level * 16}px` }}
>
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(dept.dept_code);
}}
className="h-4 w-4"
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<div className="h-4 w-4" />
)}
{/* 부서명 */}
<span className="font-medium">{dept.dept_name}</span>
{/* 인원수 */}
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<Users className="h-3 w-3" />
<span>{dept.memberCount || 0}</span>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 하위 부서 (재귀) */}
{hasChildren && isExpanded && renderDepartmentTree(dept.dept_code, level + 1)}
</div>
);
});
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 부서 트리 */}
<div className="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : departments.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
. .
</div>
) : (
renderDepartmentTree(null)
)}
</div>
{/* 부서 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="dept_name">
<span className="text-destructive">*</span>
</Label>
<Input
id="dept_name"
value={newDeptName}
onChange={(e) => setNewDeptName(e.target.value)}
placeholder="부서명을 입력하세요"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
</Button>
<Button onClick={handleSaveDepartment} disabled={!newDeptName.trim()}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 중복 알림 모달 */}
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{duplicateMessage}</p>
</div>
<DialogFooter>
<Button onClick={() => setDuplicateMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서 삭제 확인 모달 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false);
setDeptToDelete(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteDepartmentConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서 삭제 에러 모달 */}
<Dialog open={!!deleteErrorMessage} onOpenChange={() => setDeleteErrorMessage(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{deleteErrorMessage}</p>
</div>
<DialogFooter>
<Button onClick={() => setDeleteErrorMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

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

View File

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

View File

@ -19,24 +19,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
onChange,
isPreview = false,
}) => {
if (partType === "prefix") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Input
value={config.prefix || ""}
onChange={(e) => onChange({ ...config, prefix: e.target.value })}
placeholder="예: PROD"
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
);
}
// 1. 순번 (자동 증가)
if (partType === "sequence") {
return (
<div className="space-y-3 sm:space-y-4">
@ -46,15 +29,15 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
type="number"
min={1}
max={10}
value={config.sequenceLength || 4}
value={config.sequenceLength || 3}
onChange={(e) =>
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 4 })
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
: 4 0001, 5 00001
: 3 001, 4 0001
</p>
</div>
<div>
@ -69,11 +52,56 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
</div>
);
}
// 2. 숫자 (고정 자릿수)
if (partType === "number") {
return (
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> 릿</Label>
<Input
type="number"
min={1}
max={10}
value={config.numberLength || 4}
onChange={(e) =>
onChange({ ...config, numberLength: parseInt(e.target.value) || 4 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
: 4 0001, 5 00001
</p>
</div>
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Input
type="number"
min={0}
value={config.numberValue || 0}
onChange={(e) =>
onChange({ ...config, numberValue: parseInt(e.target.value) || 0 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
</div>
);
}
// 3. 날짜
if (partType === "date") {
return (
<div>
@ -94,53 +122,28 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
))}
</SelectContent>
</Select>
</div>
);
}
if (partType === "year") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.dateFormat || "YYYY"}
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY" className="text-xs sm:text-sm">4 (2025)</SelectItem>
<SelectItem value="YY" className="text-xs sm:text-sm">2 (25)</SelectItem>
</SelectContent>
</Select>
</div>
);
}
if (partType === "month") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
2 (01-12)
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
);
}
if (partType === "custom") {
// 4. 문자
if (partType === "text") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Input
value={config.value || ""}
onChange={(e) => onChange({ ...config, value: e.target.value })}
placeholder="입력값"
value={config.textValue || ""}
onChange={(e) => onChange({ ...config, textValue: e.target.value })}
placeholder="예: PRJ, CODE, PROD"
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
);
}

View File

@ -35,7 +35,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
variant="ghost"
size="icon"
onClick={onDelete}
className="h-7 w-7 text-destructive sm:h-8 sm:w-8"
className="text-destructive h-7 w-7 sm:h-8 sm:w-8"
disabled={isPreview}
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
@ -75,8 +75,12 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs sm:text-sm"> </SelectItem>
<SelectItem value="manual" className="text-xs sm:text-sm"> </SelectItem>
<SelectItem value="auto" className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="manual" className="text-xs sm:text-sm">
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
@ -80,9 +81,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const newPart: NumberingRulePart = {
id: `part-${Date.now()}`,
order: currentRule.parts.length + 1,
partType: "prefix",
partType: "text",
generationMethod: "auto",
autoConfig: { prefix: "CODE" },
autoConfig: { textValue: "CODE" },
};
setCurrentRule((prev) => {
@ -201,6 +202,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "global",
};
setSelectedRuleId(newRule.ruleId);
@ -342,6 +344,30 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Select
value={currentRule.scopeType || "global"}
onValueChange={(value: "global" | "menu") =>
setCurrentRule((prev) => ({ ...prev!, scopeType: value }))
}
disabled={isPreview}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global"> </SelectItem>
<SelectItem value="menu"></SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
{currentRule.scopeType === "menu"
? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다"
: "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"}
</p>
</div>
<Card className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>

View File

@ -27,15 +27,21 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "prefix":
return autoConfig.prefix || "PREFIX";
// 1. 순번 (자동 증가)
case "sequence": {
const length = autoConfig.sequenceLength || 4;
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
return String(startFrom).padStart(length, "0");
}
// 2. 숫자 (고정 자릿수)
case "number": {
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
return String(value).padStart(length, "0");
}
// 3. 날짜
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
const now = new Date();
@ -54,21 +60,9 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
}
}
case "year": {
const now = new Date();
const format = autoConfig.dateFormat || "YYYY";
return format === "YY"
? String(now.getFullYear()).slice(-2)
: String(now.getFullYear());
}
case "month": {
const now = new Date();
return String(now.getMonth() + 1).padStart(2, "0");
}
case "custom":
return autoConfig.value || "CUSTOM";
// 4. 문자
case "text":
return autoConfig.textValue || "TEXT";
default:
return "XXX";

View File

@ -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 추가
);
// 현재 사용자 정보 로드

View File

@ -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 = {

View File

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

View File

@ -399,13 +399,26 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
willUse100Percent: positionX === 0,
});
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
const getWidth = () => {
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
if (style?.width) {
return style.width;
}
// 2순위: left가 0이면 100%
if (positionX === 0) {
return "100%";
}
// 3순위: size.width 픽셀 값
return size?.width || 200;
};
const componentStyle = {
position: "absolute" as const,
...style, // 먼저 적용하고
left: positionX,
top: position?.y || 0,
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
width: positionX === 0 ? "100%" : (size?.width || 200),
width: getWidth(), // 우선순위에 따른 너비
height: finalHeight,
zIndex: position?.z || 1,
// right 속성 강제 제거

View File

@ -200,19 +200,58 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
: {};
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
// 너비 우선순위: style.width > size.width (픽셀값)
// 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값)
const getWidth = () => {
// 1순위: style.width가 있으면 우선 사용
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
if (componentStyle?.width) {
console.log("✅ [getWidth] style.width 사용:", {
componentId: id,
label: component.label,
styleWidth: componentStyle.width,
gridColumns: (component as any).gridColumns,
componentStyle: componentStyle,
baseStyle: {
left: `${position.x}px`,
top: `${position.y}px`,
width: componentStyle.width,
height: getHeight(),
},
});
return componentStyle.width;
}
// 2순위: size.width (픽셀)
if (component.componentConfig?.type === "table-list") {
return `${Math.max(size?.width || 120, 120)}px`;
// 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
const isButtonComponent =
(component.type === "widget" && (component as WidgetComponent).widgetType === "button") ||
(component.type === "component" && (component as any).componentType?.includes("button"));
if (position.x === 0 && !isButtonComponent) {
console.log("⚠️ [getWidth] 100% 사용 (x=0):", {
componentId: id,
label: component.label,
});
return "100%";
}
return `${size?.width || 100}px`;
// 3순위: size.width (픽셀)
if (component.componentConfig?.type === "table-list") {
const width = `${Math.max(size?.width || 120, 120)}px`;
console.log("📏 [getWidth] 픽셀 사용 (table-list):", {
componentId: id,
label: component.label,
width,
});
return width;
}
const width = `${size?.width || 100}px`;
console.log("📏 [getWidth] 픽셀 사용 (기본):", {
componentId: id,
label: component.label,
width,
sizeWidth: size?.width,
});
return width;
};
const getHeight = () => {
@ -235,35 +274,54 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return `${size?.height || 40}px`;
};
// 버튼 컴포넌트인지 확인
const isButtonComponent =
(component.type === "widget" && (component as WidgetComponent).widgetType === "button") ||
(component.type === "component" && (component as any).componentType?.includes("button"));
// 버튼일 경우 로그 출력 (편집기)
if (isButtonComponent && isDesignMode) {
console.log("🎨 [편집기] 버튼 위치:", {
label: component.label,
positionX: position.x,
positionY: position.y,
sizeWidth: size?.width,
sizeHeight: size?.height,
});
}
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
// x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
width: (position.x === 0 && !isButtonComponent) ? "100%" : getWidth(),
width: getWidth(), // getWidth()가 모든 우선순위를 처리
height: getHeight(),
zIndex: component.type === "layout" ? 1 : position.z || 2,
...componentStyle,
// x=0인 컴포넌트는 100% 너비 강제 (버튼 제외)
...(position.x === 0 && !isButtonComponent && { width: "100%" }),
right: undefined,
};
// 🔍 DOM 렌더링 후 실제 크기 측정
const innerDivRef = React.useRef<HTMLDivElement>(null);
const outerDivRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (outerDivRef.current && innerDivRef.current) {
const outerRect = outerDivRef.current.getBoundingClientRect();
const innerRect = innerDivRef.current.getBoundingClientRect();
const computedOuter = window.getComputedStyle(outerDivRef.current);
const computedInner = window.getComputedStyle(innerDivRef.current);
console.log("📐 [DOM 실제 크기 상세]:", {
componentId: id,
label: component.label,
gridColumns: (component as any).gridColumns,
"1. baseStyle.width": baseStyle.width,
"2. 외부 div (파란 테두리)": {
width: `${outerRect.width}px`,
height: `${outerRect.height}px`,
computedWidth: computedOuter.width,
computedHeight: computedOuter.height,
},
"3. 내부 div (컨텐츠 래퍼)": {
width: `${innerRect.width}px`,
height: `${innerRect.height}px`,
computedWidth: computedInner.width,
computedHeight: computedInner.height,
className: innerDivRef.current.className,
inlineStyle: innerDivRef.current.getAttribute("style"),
},
"4. 너비 비교": {
"외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`,
"비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
},
});
}
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
@ -285,7 +343,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return (
<div
ref={outerDivRef}
id={`component-${id}`}
data-component-id={id}
className="absolute cursor-pointer transition-all duration-200 ease-out"
style={{ ...baseStyle, ...selectionStyle }}
onClick={handleClick}
@ -296,10 +356,15 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
>
{/* 동적 컴포넌트 렌더링 */}
<div
ref={
component.type === "component" && (component as any).componentType === "flow-widget" ? contentRef : undefined
}
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} w-full max-w-full overflow-visible`}
ref={(node) => {
// 멀티 ref 처리
innerDivRef.current = node;
if (component.type === "component" && (component as any).componentType === "flow-widget") {
(contentRef as any).current = node;
}
}}
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
component={component}

View File

@ -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) => {

View File

@ -2012,76 +2012,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const isTableList = component.id === "table-list";
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
let gridColumns = 1; // 기본값
// 특수 컴포넌트
if (isCardDisplay) {
gridColumns = 8;
gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67%
} else if (isTableList) {
gridColumns = 12; // 테이블은 전체 너비
gridColumns = currentGridColumns; // 테이블은 전체 너비
} else {
// 웹타입별 적절한 그리드 컬럼 수 설정
const webType = component.webType;
const componentId = component.id;
// 웹타입별 기본 컬럼 수 매핑
const gridColumnsMap: Record<string, number> = {
// 웹타입별 기본 비율 매핑 (12컬럼 기준 비율)
const gridColumnsRatioMap: Record<string, number> = {
// 입력 컴포넌트 (INPUT 카테고리)
"text-input": 4, // 텍스트 입력 (33%)
"number-input": 2, // 숫자 입력 (16.67%)
"email-input": 4, // 이메일 입력 (33%)
"tel-input": 3, // 전화번호 입력 (25%)
"date-input": 3, // 날짜 입력 (25%)
"datetime-input": 4, // 날짜시간 입력 (33%)
"time-input": 2, // 시간 입력 (16.67%)
"textarea-basic": 6, // 텍스트 영역 (50%)
"select-basic": 3, // 셀렉트 (25%)
"checkbox-basic": 2, // 체크박스 (16.67%)
"radio-basic": 3, // 라디오 (25%)
"file-basic": 4, // 파일 (33%)
"file-upload": 4, // 파일 업로드 (33%)
"slider-basic": 3, // 슬라이더 (25%)
"toggle-switch": 2, // 토글 스위치 (16.67%)
"repeater-field-group": 6, // 반복 필드 그룹 (50%)
"text-input": 4 / 12, // 텍스트 입력 (33%)
"number-input": 2 / 12, // 숫자 입력 (16.67%)
"email-input": 4 / 12, // 이메일 입력 (33%)
"tel-input": 3 / 12, // 전화번호 입력 (25%)
"date-input": 3 / 12, // 날짜 입력 (25%)
"datetime-input": 4 / 12, // 날짜시간 입력 (33%)
"time-input": 2 / 12, // 시간 입력 (16.67%)
"textarea-basic": 6 / 12, // 텍스트 영역 (50%)
"select-basic": 3 / 12, // 셀렉트 (25%)
"checkbox-basic": 2 / 12, // 체크박스 (16.67%)
"radio-basic": 3 / 12, // 라디오 (25%)
"file-basic": 4 / 12, // 파일 (33%)
"file-upload": 4 / 12, // 파일 업로드 (33%)
"slider-basic": 3 / 12, // 슬라이더 (25%)
"toggle-switch": 2 / 12, // 토글 스위치 (16.67%)
"repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%)
// 표시 컴포넌트 (DISPLAY 카테고리)
"label-basic": 2, // 라벨 (16.67%)
"text-display": 3, // 텍스트 표시 (25%)
"card-display": 8, // 카드 (66.67%)
"badge-basic": 1, // 배지 (8.33%)
"alert-basic": 6, // 알림 (50%)
"divider-basic": 12, // 구분선 (100%)
"divider-line": 12, // 구분선 (100%)
"accordion-basic": 12, // 아코디언 (100%)
"table-list": 12, // 테이블 리스트 (100%)
"image-display": 4, // 이미지 표시 (33%)
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
"flow-widget": 12, // 플로우 위젯 (100%)
"label-basic": 2 / 12, // 라벨 (16.67%)
"text-display": 3 / 12, // 텍스트 표시 (25%)
"card-display": 8 / 12, // 카드 (66.67%)
"badge-basic": 1 / 12, // 배지 (8.33%)
"alert-basic": 6 / 12, // 알림 (50%)
"divider-basic": 1, // 구분선 (100%)
"divider-line": 1, // 구분선 (100%)
"accordion-basic": 1, // 아코디언 (100%)
"table-list": 1, // 테이블 리스트 (100%)
"image-display": 4 / 12, // 이미지 표시 (33%)
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
"flow-widget": 1, // 플로우 위젯 (100%)
// 액션 컴포넌트 (ACTION 카테고리)
"button-basic": 1, // 버튼 (8.33%)
"button-primary": 1, // 프라이머리 버튼 (8.33%)
"button-secondary": 1, // 세컨더리 버튼 (8.33%)
"icon-button": 1, // 아이콘 버튼 (8.33%)
"button-basic": 1 / 12, // 버튼 (8.33%)
"button-primary": 1 / 12, // 프라이머리 버튼 (8.33%)
"button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%)
"icon-button": 1 / 12, // 아이콘 버튼 (8.33%)
// 레이아웃 컴포넌트
"container-basic": 6, // 컨테이너 (50%)
"section-basic": 12, // 섹션 (100%)
"panel-basic": 6, // 패널 (50%)
"container-basic": 6 / 12, // 컨테이너 (50%)
"section-basic": 1, // 섹션 (100%)
"panel-basic": 6 / 12, // 패널 (50%)
// 기타
"image-basic": 4, // 이미지 (33%)
"icon-basic": 1, // 아이콘 (8.33%)
"progress-bar": 4, // 프로그레스 바 (33%)
"chart-basic": 6, // 차트 (50%)
"image-basic": 4 / 12, // 이미지 (33%)
"icon-basic": 1 / 12, // 아이콘 (8.33%)
"progress-bar": 4 / 12, // 프로그레스 바 (33%)
"chart-basic": 6 / 12, // 차트 (50%)
};
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
// defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용
if (component.defaultSize?.gridColumnSpan === "full") {
gridColumns = 12;
gridColumns = currentGridColumns;
} else {
// componentId 또는 webType으로 매핑, 없으면 기본값 3
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
// componentId 또는 webType으로 비율 찾기, 없으면 기본값 25%
const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25;
// 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns)
gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns)));
}
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
@ -2141,6 +2144,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
}
// gridColumns에 맞춰 width를 퍼센트로 계산
const widthPercent = (gridColumns / currentGridColumns) * 100;
console.log("🎨 [컴포넌트 생성] 너비 계산:", {
componentName: component.name,
componentId: component.id,
currentGridColumns,
gridColumns,
widthPercent: `${widthPercent}%`,
calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`,
});
const newComponent: ComponentData = {
id: generateComponentId(),
type: "component", // ✅ 새 컴포넌트 시스템 사용
@ -2162,6 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
labelColor: "#212121",
labelFontWeight: "500",
labelMarginBottom: "4px",
width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비
},
};
@ -4238,7 +4254,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<UnifiedPropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
gridSettings={layout.gridSettings}
onUpdateProperty={updateComponentProperty}
onGridSettingsChange={(newSettings) => {
setLayout((prev) => ({
...prev,
gridSettings: newSettings,
}));
}}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide
import { Button } from "@/components/ui/button";
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { formatFileSize } from "@/lib/utils";
import { formatFileSize, cn } from "@/lib/utils";
import { toast } from "sonner";
interface FileComponentConfigPanelProps {

View File

@ -127,10 +127,27 @@ export const GridPanel: React.FC<GridPanelProps> = ({
<div className="space-y-2">
<Label htmlFor="columns" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.columns}</span>
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={24}
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 24) {
updateSetting("columns", value);
}
}}
className="h-8 text-xs"
/>
<span className="text-muted-foreground text-xs">/ 24</span>
</div>
<Slider
id="columns"
id="columns-slider"
min={1}
max={24}
step={1}
@ -139,8 +156,8 @@ export const GridPanel: React.FC<GridPanelProps> = ({
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>1</span>
<span>24</span>
<span>1</span>
<span>24</span>
</div>
</div>

View File

@ -109,6 +109,12 @@ interface PropertiesPanelProps {
draggedComponent: ComponentData | null;
currentPosition: { x: number; y: number; z: number };
};
gridSettings?: {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
};
onUpdateProperty: (path: string, value: unknown) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
selectedComponent,
tables = [],
dragState,
gridSettings,
onUpdateProperty,
onDeleteComponent,
onCopyComponent,
@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
<>
{/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
{/* 🆕 그리드 컬럼 수 직접 입력 */}
<div className="col-span-2">
<Label className="text-sm font-medium"> </Label>
<Label htmlFor="gridColumns" className="text-sm font-medium">
</Label>
<div className="mt-1 flex items-center gap-2">
<Input
id="gridColumns"
type="number"
min={1}
max={gridSettings?.columns || 12}
value={(selectedComponent as any)?.gridColumns || 1}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
const maxColumns = gridSettings?.columns || 12;
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
// gridColumns 업데이트
onUpdateProperty("gridColumns", value);
// width를 퍼센트로 계산하여 업데이트
const widthPercent = (value / maxColumns) * 100;
onUpdateProperty("style.width", `${widthPercent}%`);
// localWidthSpan도 업데이트
setLocalWidthSpan(calculateWidthSpan(`${widthPercent}%`, value));
}
}}
className="h-8 text-xs"
/>
<span className="text-muted-foreground text-xs">
/ {gridSettings?.columns || 12}
</span>
</div>
<p className="text-muted-foreground mt-1 text-xs">
(1-{gridSettings?.columns || 12})
</p>
</div>
{/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */}
<div className="col-span-2">
<Label className="text-sm font-medium"> </Label>
<Select
value={localWidthSpan}
onValueChange={(value) => {

View File

@ -24,7 +24,10 @@ import {
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
// 컬럼 스팬 숫자 배열 (1~12)
const COLUMN_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
// 동적으로 컬럼 수 배열 생성 (gridSettings.columns 기반)
const generateColumnNumbers = (maxColumns: number) => {
return Array.from({ length: maxColumns }, (_, i) => i + 1);
};
import { cn } from "@/lib/utils";
import DataTableConfigPanel from "./DataTableConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
@ -52,11 +55,23 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import ResolutionPanel from "./ResolutionPanel";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
interface UnifiedPropertiesPanelProps {
selectedComponent?: ComponentData;
tables: TableInfo[];
gridSettings?: {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
showGrid: boolean;
gridColor?: string;
gridOpacity?: number;
};
onUpdateProperty: (componentId: string, path: string, value: any) => void;
onGridSettingsChange?: (settings: any) => void;
onDeleteComponent?: (componentId: string) => void;
onCopyComponent?: (componentId: string) => void;
currentTable?: TableInfo;
@ -74,7 +89,9 @@ interface UnifiedPropertiesPanelProps {
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
selectedComponent,
tables,
gridSettings,
onUpdateProperty,
onGridSettingsChange,
onDeleteComponent,
onCopyComponent,
currentTable,
@ -98,29 +115,160 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
// 컴포넌트가 선택되지 않았을 때도 해상도 설정은 표시
// 격자 설정 업데이트 함수 (early return 이전에 정의)
const updateGridSetting = (key: string, value: any) => {
if (onGridSettingsChange && gridSettings) {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
}
};
// 격자 설정 렌더링 (early return 이전에 정의)
const renderGridSettings = () => {
if (!gridSettings || !onGridSettingsChange) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Grid3X3 className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<div className="space-y-3">
{/* 토글들 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{gridSettings.showGrid ? (
<Eye className="text-primary h-3 w-3" />
) : (
<EyeOff className="text-muted-foreground h-3 w-3" />
)}
<Label htmlFor="showGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="text-primary h-3 w-3" />
<Label htmlFor="snapToGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked)}
/>
</div>
{/* 컬럼 수 */}
<div className="space-y-1">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={24}
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 24) {
updateGridSetting("columns", value);
}
}}
className="h-6 px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">/ 24</span>
</div>
<Slider
min={1}
max={24}
step={1}
value={[gridSettings.columns]}
onValueChange={([value]) => updateGridSetting("columns", value)}
className="w-full"
/>
</div>
{/* 간격 */}
<div className="space-y-1">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateGridSetting("gap", value)}
className="w-full"
/>
</div>
{/* 여백 */}
<div className="space-y-1">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateGridSetting("padding", value)}
className="w-full"
/>
</div>
</div>
</div>
);
};
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
if (!selectedComponent) {
return (
<div className="flex h-full flex-col bg-white">
{/* 해상도 설정만 표시 */}
{/* 해상도 설정과 격자 설정 표시 */}
<div className="flex-1 overflow-y-auto p-2">
<div className="space-y-4 text-xs">
{/* 해상도 설정 */}
{currentResolution && onResolutionChange && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Monitor className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
<>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Monitor className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
</div>
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
</div>
<Separator className="my-2" />
</>
)}
{/* 격자 설정 */}
{renderGridSettings()}
{/* 안내 메시지 */}
<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>
@ -283,22 +431,31 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="grid grid-cols-2 gap-2">
{(selectedComponent as any).gridColumns !== undefined && (
<div className="space-y-1">
<Label className="text-xs">Grid</Label>
<Select
value={((selectedComponent as any).gridColumns || 12).toString()}
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
>
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_NUMBERS.map((span) => (
<SelectItem key={span} value={span.toString()} style={{ fontSize: "12px" }}>
{span}
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs"> </Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={1}
max={gridSettings?.columns || 12}
value={(selectedComponent as any).gridColumns || 1}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
const maxColumns = gridSettings?.columns || 12;
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
handleUpdate("gridColumns", value);
// width를 퍼센트로 계산하여 업데이트
const widthPercent = (value / maxColumns) * 100;
handleUpdate("style.width", `${widthPercent}%`);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
/{gridSettings?.columns || 12}
</span>
</div>
</div>
)}
<div className="space-y-1">
@ -412,8 +569,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 +630,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 +640,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 +672,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 +696,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 +859,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 +1028,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>
)}
@ -627,6 +1053,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</>
)}
{/* 격자 설정 - 해상도 설정 아래 표시 */}
{renderGridSettings()}
{gridSettings && onGridSettingsChange && <Separator className="my-2" />}
{/* 기본 설정 */}
{renderBasicTab()}

View File

@ -0,0 +1,77 @@
/**
* 릿
*
*/
import { Hash } from "lucide-react";
export const getDefaultNumberingRuleConfig = () => ({
template_code: "numbering-rule-designer",
template_name: "코드 채번 규칙",
template_name_eng: "Numbering Rule Designer",
description: "코드 자동 채번 규칙을 설정하는 컴포넌트",
category: "admin" as const,
icon_name: "hash",
default_size: {
width: 1200,
height: 800,
},
layout_config: {
components: [
{
type: "numbering-rule" as const,
label: "채번 규칙 설정",
position: { x: 0, y: 0 },
size: { width: 1200, height: 800 },
ruleConfig: {
ruleId: "new-rule",
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
},
maxRules: 6,
style: {
padding: "16px",
backgroundColor: "#ffffff",
},
},
],
},
});
/**
* 릿
*/
export const numberingRuleTemplate = {
id: "numbering-rule",
name: "채번 규칙",
description: "코드 자동 채번 규칙 설정",
category: "admin" as const,
icon: Hash,
defaultSize: { width: 1200, height: 800 },
components: [
{
type: "numbering-rule" as const,
widgetType: undefined,
label: "채번 규칙 설정",
position: { x: 0, y: 0 },
size: { width: 1200, height: 800 },
style: {
padding: "16px",
backgroundColor: "#ffffff",
},
ruleConfig: {
ruleId: "new-rule",
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
},
maxRules: 6,
},
],
};

View File

@ -0,0 +1,210 @@
"use client";
import React, { useState, useEffect } from "react";
import { TabsComponent, TabItem, ScreenDefinition } from "@/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, FileQuestion } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
interface TabsWidgetProps {
component: TabsComponent;
isPreview?: boolean;
}
/**
*
*
*/
export const TabsWidget: React.FC<TabsWidgetProps> = ({ component, isPreview = false }) => {
// componentConfig에서 설정 읽기 (새 컴포넌트 시스템)
const config = (component as any).componentConfig || component;
const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config;
// console.log("🔍 TabsWidget 렌더링:", {
// component,
// componentConfig: (component as any).componentConfig,
// tabs,
// tabsLength: tabs.length
// });
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.id || "");
const [loadedScreens, setLoadedScreens] = useState<Record<string, any>>({});
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 탭 변경 시 화면 로드
useEffect(() => {
if (!activeTab) return;
const currentTab = tabs.find((tab) => tab.id === activeTab);
if (!currentTab || !currentTab.screenId) return;
// 이미 로드된 화면이면 스킵
if (loadedScreens[activeTab]) return;
// 이미 로딩 중이면 스킵
if (loadingScreens[activeTab]) return;
// 화면 로드 시작
loadScreen(activeTab, currentTab.screenId);
}, [activeTab, tabs]);
const loadScreen = async (tabId: string, screenId: number) => {
setLoadingScreens((prev) => ({ ...prev, [tabId]: true }));
setScreenErrors((prev) => ({ ...prev, [tabId]: "" }));
try {
const layoutData = await screenApi.getLayout(screenId);
if (layoutData) {
setLoadedScreens((prev) => ({
...prev,
[tabId]: {
screenId,
layout: layoutData,
},
}));
} else {
setScreenErrors((prev) => ({
...prev,
[tabId]: "화면을 불러올 수 없습니다",
}));
}
} catch (error: any) {
setScreenErrors((prev) => ({
...prev,
[tabId]: error.message || "화면 로드 중 오류가 발생했습니다",
}));
} finally {
setLoadingScreens((prev) => ({ ...prev, [tabId]: false }));
}
};
// 탭 콘텐츠 렌더링
const renderTabContent = (tab: TabItem) => {
const isLoading = loadingScreens[tab.id];
const error = screenErrors[tab.id];
const screenData = loadedScreens[tab.id];
// 로딩 중
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
);
}
// 에러 발생
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<FileQuestion className="h-12 w-12 text-destructive" />
<div className="text-center">
<p className="mb-2 font-medium text-destructive"> </p>
<p className="text-muted-foreground text-sm">{error}</p>
</div>
</div>
);
}
// 화면 ID가 없는 경우
if (!tab.screenId) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<FileQuestion className="text-muted-foreground h-12 w-12" />
<div className="text-center">
<p className="text-muted-foreground mb-2 text-sm"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
);
}
// 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링
if (screenData && screenData.layout && screenData.layout.components) {
const components = screenData.layout.components;
const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 };
return (
<div className="bg-white" style={{ width: `${screenResolution.width}px`, height: '100%' }}>
<div className="relative h-full">
{components.map((comp) => (
<InteractiveScreenViewerDynamic
key={comp.id}
component={comp}
allComponents={components}
screenInfo={{ id: tab.screenId }}
/>
))}
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<FileQuestion className="text-muted-foreground h-12 w-12" />
<div className="text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
);
};
// 빈 탭 목록
if (tabs.length === 0) {
return (
<Card className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-sm"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
</Card>
);
}
return (
<div className="h-full w-full overflow-auto">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={orientation}
className="flex h-full w-full flex-col"
>
<TabsList className={orientation === "horizontal" ? "justify-start shrink-0" : "flex-col shrink-0"}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
disabled={tab.disabled}
className={orientation === "horizontal" ? "" : "w-full justify-start"}
>
<span>{tab.label}</span>
{tab.screenName && (
<Badge variant="secondary" className="ml-2 text-[10px]">
{tab.screenName}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.id}
value={tab.id}
className="flex-1 mt-0 data-[state=inactive]:hidden"
>
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>
</div>
);
};

View File

@ -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: "",
};
// 페이징 관련 상수

View File

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

View File

@ -0,0 +1,157 @@
/**
* API
*/
import { apiClient } from "./client";
import { Department, DepartmentMember, DepartmentFormData } from "@/types/department";
/**
* ()
*/
export async function getDepartments(companyCode: string) {
try {
const url = `/departments/companies/${companyCode}/departments`;
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url);
return response.data;
} catch (error: any) {
console.error("부서 목록 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*/
export async function getDepartment(deptCode: string) {
try {
const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`);
return response.data;
} catch (error: any) {
console.error("부서 상세 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*/
export async function createDepartment(companyCode: string, data: DepartmentFormData) {
try {
const response = await apiClient.post<{ success: boolean; data: Department }>(
`/departments/companies/${companyCode}/departments`,
data,
);
return response.data;
} catch (error: any) {
console.error("부서 생성 실패:", error);
const isDuplicate = error.response?.status === 409;
return {
success: false,
error: error.response?.data?.message || error.message,
isDuplicate,
};
}
}
/**
*
*/
export async function updateDepartment(deptCode: string, data: DepartmentFormData) {
try {
const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data);
return response.data;
} catch (error: any) {
console.error("부서 수정 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*/
export async function deleteDepartment(deptCode: string) {
try {
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`);
return response.data;
} catch (error: any) {
console.error("부서 삭제 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*/
export async function getDepartmentMembers(deptCode: string) {
try {
const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>(
`/departments/${deptCode}/members`,
);
return response.data;
} catch (error: any) {
console.error("부서원 목록 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* ( )
*/
export async function searchUsers(companyCode: string, search: string) {
try {
const response = await apiClient.get<{ success: boolean; data: any[] }>(
`/departments/companies/${companyCode}/users/search`,
{ params: { search } },
);
return response.data;
} catch (error: any) {
console.error("사용자 검색 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*/
export async function addDepartmentMember(deptCode: string, userId: string) {
try {
const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, {
user_id: userId,
});
return response.data;
} catch (error: any) {
console.error("부서원 추가 실패:", error);
const isDuplicate = error.response?.status === 409;
return {
success: false,
error: error.response?.data?.message || error.message,
isDuplicate,
};
}
}
/**
*
*/
export async function removeDepartmentMember(deptCode: string, userId: string) {
try {
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`);
return response.data;
} catch (error: any) {
console.error("부서원 제거 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*/
export async function setPrimaryDepartment(deptCode: string, userId: string) {
try {
const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`);
return response.data;
} catch (error: any) {
console.error("주 부서 설정 실패:", error);
return { success: false, error: error.message };
}
}

View File

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

View File

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

View File

@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
@ -247,8 +249,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 +276,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 +304,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"

View File

@ -185,6 +185,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
position: "relative",
backgroundColor: "transparent",
};
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
// 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정
if (isDesignMode) {
componentStyle.border = "1px dashed hsl(var(--border))";

View File

@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -204,6 +204,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -234,6 +234,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: "hsl(var(--background))",
overflow: "hidden",
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// ========================================
@ -284,7 +286,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 컬럼 라벨 가져오기
// ========================================
const fetchColumnLabels = async () => {
const fetchColumnLabels = useCallback(async () => {
if (!tableConfig.selectedTable) return;
try {
@ -339,13 +341,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 +376,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} catch (error) {
console.error("테이블 라벨 가져오기 실패:", error);
}
};
}, [tableConfig.selectedTable]);
// ========================================
// 데이터 가져오기
@ -531,7 +533,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 +554,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 +938,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable]);
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
useEffect(() => {
if (!isDesignMode && tableConfig.selectedTable) {
@ -945,6 +953,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchTerm,
refreshKey,
isDesignMode,
fetchTableDataDebounced,
]);
useEffect(() => {
@ -1160,7 +1169,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
<div style={{ flex: 1, overflow: "hidden" }}>
<div className="mt-10" style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
@ -1254,7 +1263,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 테이블 컨테이너 */}
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full">
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full mt-10">
{/* 스크롤 영역 */}
<div
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"

View File

@ -117,7 +117,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
height: "100%",
...component.style,
...style,
// 숨김 기능: 편집 모드에서만 연하게 표시
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
// 숨김 기능: 편집 모드에서만 연하게 표시
...(isHidden &&
isDesignMode && {
opacity: 0.4,

View File

@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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; // 회사 주소
}
// 회사 검색 필터

View File

@ -0,0 +1,69 @@
/**
*
*/
// 부서 정보 (dept_info 테이블 기반)
export interface Department {
dept_code: string; // 부서 코드
dept_name: string; // 부서명
company_code: string; // 회사 코드
parent_dept_code?: string | null; // 상위 부서 코드
sort_order?: number; // 정렬 순서
created_at?: string;
updated_at?: string;
// UI용 추가 필드
children?: Department[]; // 하위 부서 목록
memberCount?: number; // 부서원 수
}
// 부서원 정보
export interface DepartmentMember {
user_id: string; // 사용자 ID
user_name: string; // 사용자명
dept_code: string; // 부서 코드
dept_name: string; // 부서명
is_primary: boolean; // 주 부서 여부
position_name?: string; // 직책명
email?: string; // 이메일
phone?: string; // 전화번호
cell_phone?: string; // 휴대폰
}
// 사용자-부서 매핑 (겸직 지원)
export interface UserDepartmentMapping {
user_id: string;
dept_code: string;
is_primary: boolean; // 주 부서 여부
created_at?: string;
}
// 부서 등록/수정 폼 데이터
export interface DepartmentFormData {
dept_name: string; // 부서명 (필수)
parent_dept_code?: string | null; // 상위 부서 코드
}
// 부서 트리 노드 (UI용)
export interface DepartmentTreeNode {
dept_code: string;
dept_name: string;
parent_dept_code?: string | null;
children: DepartmentTreeNode[];
memberCount: number;
isExpanded: boolean;
}
// 부서 API 응답
export interface DepartmentApiResponse {
success: boolean;
message: string;
data?: Department | Department[];
}
// 부서원 API 응답
export interface DepartmentMemberApiResponse {
success: boolean;
message: string;
data?: DepartmentMember | DepartmentMember[];
}

View File

@ -4,15 +4,13 @@
*/
/**
*
* (4)
*/
export type CodePartType =
| "prefix" // 접두사 (고정 문자열)
| "sequence" // 순번 (자동 증가)
| "date" // 날짜 (YYYYMMDD 등)
| "year" // 연도 (YYYY)
| "month" // 월 (MM)
| "custom"; // 사용자 정의
| "sequence" // 순번 (자동 증가 숫자)
| "number" // 숫자 (고정 자릿수)
| "date" // 날짜 (다양한 날짜 형식)
| "text"; // 문자 (텍스트)
/**
*
@ -43,11 +41,19 @@ export interface NumberingRulePart {
// 자동 생성 설정
autoConfig?: {
prefix?: string; // 접두사
sequenceLength?: number; // 순번 자릿수
startFrom?: number; // 시작 번호
// 순번용
sequenceLength?: number; // 순번 자릿수 (예: 3 → 001)
startFrom?: number; // 시작 번호 (기본: 1)
// 숫자용
numberLength?: number; // 숫자 자릿수 (예: 4 → 0001)
numberValue?: number; // 숫자 값
// 날짜용
dateFormat?: DateFormat; // 날짜 형식
value?: string; // 커스텀 값
// 문자용
textValue?: string; // 텍스트 값 (예: "PRJ", "CODE")
};
// 직접 입력 설정
@ -74,6 +80,10 @@ export interface NumberingRuleConfig {
resetPeriod?: "none" | "daily" | "monthly" | "yearly";
currentSequence?: number; // 현재 시퀀스
// 적용 범위
scopeType?: "global" | "menu"; // 적용 범위 (전역/메뉴별)
menuObjid?: number; // 적용할 메뉴 OBJID (상위 메뉴 기준)
// 적용 대상
tableName?: string; // 적용할 테이블명
columnName?: string; // 적용할 컬럼명
@ -88,13 +98,11 @@ export interface NumberingRuleConfig {
/**
* UI
*/
export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string }> = [
{ value: "prefix", label: "접두사" },
{ value: "sequence", label: "순번" },
{ value: "date", label: "날짜" },
{ value: "year", label: "연도" },
{ value: "month", label: "월" },
{ value: "custom", label: "사용자 정의" },
export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; description: string }> = [
{ value: "sequence", label: "순번", description: "자동 증가 순번 (1, 2, 3...)" },
{ value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" },
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
];
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [

View File

@ -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'; // 사용자 정보에서 가져올 필드
};
}
/**

View File

@ -0,0 +1,494 @@
# 코드 채번 규칙 컴포넌트 구현 계획서
## 문서 정보
- **작성일**: 2025-11-03
- **목적**: Shadcn/ui 가이드라인 기반 코드 채번 규칙 컴포넌트 구현
- **우선순위**: 중간
- **디자인 원칙**: 심플하고 깔끔한 UI, 중첩 박스 금지, 일관된 컬러 시스템
---
## 1. 기능 요구사항
### 1.1 핵심 기능
- 코드 채번 규칙 생성/수정/삭제
- 동적 규칙 파트 추가/삭제 (최대 6개)
- 실시간 코드 미리보기
- 규칙 순서 조정
- 데이터베이스 저장 및 불러오기
### 1.2 UI 요구사항
- 좌측: 코드 목록 (선택적)
- 우측: 규칙 설정 영역
- 상단: 코드 미리보기 + 규칙명
- 중앙: 규칙 카드 리스트
- 하단: 규칙 추가 + 저장 버튼
---
## 2. 디자인 시스템 (Shadcn/ui 기반)
### 2.1 색상 사용 규칙
```tsx
// 배경
bg-background // 페이지 배경
bg-card // 카드 배경
bg-muted // 약한 배경 (미리보기 등)
// 텍스트
text-foreground // 기본 텍스트
text-muted-foreground // 보조 텍스트
text-primary // 강조 텍스트
// 테두리
border-border // 기본 테두리
border-input // 입력 필드 테두리
// 버튼
bg-primary // 주요 버튼 (저장, 추가)
bg-destructive // 삭제 버튼
variant="outline" // 보조 버튼 (취소)
variant="ghost" // 아이콘 버튼
```
### 2.2 간격 시스템
```tsx
// 카드 간 간격
gap-6 // 24px (카드 사이)
// 카드 내부 패딩
p-6 // 24px (CardContent)
// 폼 필드 간격
space-y-4 // 16px (입력 필드들)
space-y-3 // 12px (모바일)
// 섹션 간격
space-y-6 // 24px (큰 섹션)
```
### 2.3 타이포그래피
```tsx
// 페이지 제목
text-2xl font-semibold
// 섹션 제목
text-lg font-semibold
// 카드 제목
text-base font-semibold
// 라벨
text-sm font-medium
// 본문 텍스트
text-sm text-muted-foreground
// 작은 텍스트
text-xs text-muted-foreground
```
### 2.4 반응형 설정
```tsx
// 모바일 우선 + 데스크톱 최적화
className="text-xs sm:text-sm" // 폰트 크기
className="h-8 sm:h-10" // 입력 필드 높이
className="flex-col md:flex-row" // 레이아웃
className="gap-2 sm:gap-4" // 간격
```
### 2.5 중첩 박스 금지 원칙
**❌ 잘못된 예시**:
```tsx
<Card>
<CardContent>
<div className="border rounded-lg p-4"> {/* 중첩 박스! */}
<div className="border rounded p-2"> {/* 또 중첩! */}
내용
</div>
</div>
</CardContent>
</Card>
```
**✅ 올바른 예시**:
```tsx
<Card>
<CardHeader>
<CardTitle>제목</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 직접 컨텐츠 배치 */}
<div>내용 1</div>
<div>내용 2</div>
</CardContent>
</Card>
```
---
## 3. 데이터 구조
### 3.1 타입 정의
```typescript
// frontend/types/numbering-rule.ts
import { BaseComponent } from "./screen-management";
/**
* 코드 파트 유형
*/
export type CodePartType =
| "prefix" // 접두사 (고정 문자열)
| "sequence" // 순번 (자동 증가)
| "date" // 날짜 (YYYYMMDD 등)
| "year" // 연도 (YYYY)
| "month" // 월 (MM)
| "custom"; // 사용자 정의
/**
* 생성 방식
*/
export type GenerationMethod =
| "auto" // 자동 생성
| "manual"; // 직접 입력
/**
* 날짜 형식
*/
export type DateFormat =
| "YYYY" // 2025
| "YY" // 25
| "YYYYMM" // 202511
| "YYMM" // 2511
| "YYYYMMDD" // 20251103
| "YYMMDD"; // 251103
/**
* 단일 규칙 파트
*/
export interface NumberingRulePart {
id: string; // 고유 ID
order: number; // 순서 (1-6)
partType: CodePartType; // 파트 유형
generationMethod: GenerationMethod; // 생성 방식
// 자동 생성 설정
autoConfig?: {
// 접두사 설정
prefix?: string; // 예: "ITM"
// 순번 설정
sequenceLength?: number; // 자릿수 (예: 4 → 0001)
startFrom?: number; // 시작 번호 (기본: 1)
// 날짜 설정
dateFormat?: DateFormat; // 날짜 형식
};
// 직접 입력 설정
manualConfig?: {
value: string; // 입력값
placeholder?: string; // 플레이스홀더
};
// 생성된 값 (미리보기용)
generatedValue?: string;
}
/**
* 전체 채번 규칙
*/
export interface NumberingRuleConfig {
ruleId: string; // 규칙 ID
ruleName: string; // 규칙명
description?: string; // 설명
parts: NumberingRulePart[]; // 규칙 파트 배열 (최대 6개)
// 설정
separator?: string; // 구분자 (기본: "-")
resetPeriod?: "none" | "daily" | "monthly" | "yearly"; // 초기화 주기
currentSequence?: number; // 현재 시퀀스
// 적용 대상
tableName?: string; // 적용할 테이블명
columnName?: string; // 적용할 컬럼명
// 메타 정보
companyCode?: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
}
/**
* 화면관리 컴포넌트 인터페이스
*/
export interface NumberingRuleComponent extends BaseComponent {
type: "numbering-rule";
// 채번 규칙 설정
ruleConfig: NumberingRuleConfig;
// UI 설정
showRuleList?: boolean; // 좌측 목록 표시 여부
maxRules?: number; // 최대 규칙 개수 (기본: 6)
enableReorder?: boolean; // 순서 변경 허용 여부
// 스타일
cardLayout?: "vertical" | "horizontal"; // 카드 레이아웃
}
```
### 3.2 데이터베이스 스키마
```sql
-- db/migrations/034_create_numbering_rules.sql
-- 채번 규칙 마스터 테이블
CREATE TABLE IF NOT EXISTS numbering_rules (
rule_id VARCHAR(50) PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL,
description TEXT,
separator VARCHAR(10) DEFAULT '-',
reset_period VARCHAR(20) DEFAULT 'none',
current_sequence INTEGER DEFAULT 1,
table_name VARCHAR(100),
column_name VARCHAR(100),
company_code VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
CONSTRAINT fk_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code)
);
-- 채번 규칙 상세 테이블
CREATE TABLE IF NOT EXISTS numbering_rule_parts (
id SERIAL PRIMARY KEY,
rule_id VARCHAR(50) NOT NULL,
part_order INTEGER NOT NULL,
part_type VARCHAR(50) NOT NULL,
generation_method VARCHAR(20) NOT NULL,
auto_config JSONB,
manual_config JSONB,
company_code VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_numbering_rule FOREIGN KEY (rule_id)
REFERENCES numbering_rules(rule_id) ON DELETE CASCADE,
CONSTRAINT fk_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code),
CONSTRAINT unique_rule_order UNIQUE (rule_id, part_order, company_code)
);
-- 인덱스
CREATE INDEX idx_numbering_rules_company ON numbering_rules(company_code);
CREATE INDEX idx_numbering_rule_parts_rule ON numbering_rule_parts(rule_id);
CREATE INDEX idx_numbering_rules_table ON numbering_rules(table_name, column_name);
-- 샘플 데이터
INSERT INTO numbering_rules (rule_id, rule_name, description, company_code, created_by)
VALUES ('SAMPLE_RULE', '샘플 채번 규칙', '제품 코드 자동 생성', '*', 'system')
ON CONFLICT (rule_id) DO NOTHING;
INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, company_code)
VALUES
('SAMPLE_RULE', 1, 'prefix', 'auto', '{"prefix": "PROD"}', '*'),
('SAMPLE_RULE', 2, 'date', 'auto', '{"dateFormat": "YYYYMMDD"}', '*'),
('SAMPLE_RULE', 3, 'sequence', 'auto', '{"sequenceLength": 4, "startFrom": 1}', '*')
ON CONFLICT (rule_id, part_order, company_code) DO NOTHING;
```
---
## 4. 구현 순서
### Phase 1: 타입 정의 및 스키마 생성 ✅
1. 타입 정의 파일 생성
2. 데이터베이스 마이그레이션 실행
3. 샘플 데이터 삽입
### Phase 2: 백엔드 API 구현
1. Controller 생성
2. Service 레이어 구현
3. API 테스트
### Phase 3: 프론트엔드 기본 컴포넌트
1. NumberingRuleDesigner (메인)
2. NumberingRulePreview (미리보기)
3. NumberingRuleCard (단일 규칙 카드)
### Phase 4: 상세 설정 패널
1. PartTypeSelector (파트 유형 선택)
2. AutoConfigPanel (자동 생성 설정)
3. ManualConfigPanel (직접 입력 설정)
### Phase 5: 화면관리 통합
1. ComponentType에 "numbering-rule" 추가
2. RealtimePreview 렌더링 추가
3. 템플릿 등록
4. 속성 패널 구현
### Phase 6: 테스트 및 최적화
1. 기능 테스트
2. 반응형 테스트
3. 성능 최적화
4. 문서화
---
## 5. 구현 완료 ✅
### Phase 1: 타입 정의 및 스키마 생성 ✅
- ✅ `frontend/types/numbering-rule.ts` 생성
- ✅ `db/migrations/034_create_numbering_rules.sql` 생성 및 실행
- ✅ 샘플 데이터 삽입 완료
### Phase 2: 백엔드 API 구현 ✅
- ✅ `backend-node/src/services/numberingRuleService.ts` 생성
- ✅ `backend-node/src/controllers/numberingRuleController.ts` 생성
- ✅ `app.ts`에 라우터 등록 (`/api/numbering-rules`)
- ✅ 백엔드 재시작 완료
### Phase 3: 프론트엔드 기본 컴포넌트 ✅
- ✅ `NumberingRulePreview.tsx` - 코드 미리보기
- ✅ `NumberingRuleCard.tsx` - 단일 규칙 카드
- ✅ `AutoConfigPanel.tsx` - 자동 생성 설정
- ✅ `ManualConfigPanel.tsx` - 직접 입력 설정
- ✅ `NumberingRuleDesigner.tsx` - 메인 디자이너
### Phase 4: 상세 설정 패널 ✅
- ✅ 파트 유형별 설정 UI (접두사, 순번, 날짜, 연도, 월, 커스텀)
- ✅ 자동 생성 / 직접 입력 모드 전환
- ✅ 실시간 미리보기 업데이트
### Phase 5: 화면관리 시스템 통합 ✅
- ✅ `unified-core.ts`에 "numbering-rule" ComponentType 추가
- ✅ `screen-management.ts`에 ComponentData 유니온 타입 추가
- ✅ `RealtimePreview.tsx`에 렌더링 로직 추가
- ✅ `TemplatesPanel.tsx`에 "관리자" 카테고리 및 템플릿 추가
- ✅ `NumberingRuleTemplate.ts` 생성
### Phase 6: 완료 ✅
모든 단계가 성공적으로 완료되었습니다!
---
## 6. 사용 방법
### 6.1 화면관리에서 사용하기
1. **화면관리** 페이지로 이동
2. 좌측 **템플릿 패널**에서 **관리자** 카테고리 선택
3. **코드 채번 규칙** 템플릿을 캔버스로 드래그
4. 규칙 파트 추가 및 설정
5. 저장
### 6.2 API 사용하기
#### 규칙 목록 조회
```bash
GET /api/numbering-rules
```
#### 규칙 생성
```bash
POST /api/numbering-rules
{
"ruleId": "PROD_CODE",
"ruleName": "제품 코드 규칙",
"parts": [
{
"id": "part-1",
"order": 1,
"partType": "prefix",
"generationMethod": "auto",
"autoConfig": { "prefix": "PROD" }
},
{
"id": "part-2",
"order": 2,
"partType": "date",
"generationMethod": "auto",
"autoConfig": { "dateFormat": "YYYYMMDD" }
},
{
"id": "part-3",
"order": 3,
"partType": "sequence",
"generationMethod": "auto",
"autoConfig": { "sequenceLength": 4, "startFrom": 1 }
}
],
"separator": "-"
}
```
#### 코드 생성
```bash
POST /api/numbering-rules/PROD_CODE/generate
응답: { "success": true, "data": { "code": "PROD-20251103-0001" } }
```
---
## 7. 구현된 파일 목록
### 프론트엔드
```
frontend/
├── types/
│ └── numbering-rule.ts ✅
├── components/
│ └── numbering-rule/
│ ├── NumberingRuleDesigner.tsx ✅
│ ├── NumberingRuleCard.tsx ✅
│ ├── NumberingRulePreview.tsx ✅
│ ├── AutoConfigPanel.tsx ✅
│ └── ManualConfigPanel.tsx ✅
└── components/screen/
├── RealtimePreview.tsx ✅ (수정됨)
├── panels/
│ └── TemplatesPanel.tsx ✅ (수정됨)
└── templates/
└── NumberingRuleTemplate.ts ✅
```
### 백엔드
```
backend-node/
├── src/
│ ├── services/
│ │ └── numberingRuleService.ts ✅
│ ├── controllers/
│ │ └── numberingRuleController.ts ✅
│ └── app.ts ✅ (수정됨)
```
### 데이터베이스
```
db/
└── migrations/
└── 034_create_numbering_rules.sql ✅
```
---
## 8. 다음 개선 사항 (선택사항)
- [ ] 규칙 순서 드래그앤드롭으로 변경
- [ ] 규칙 복제 기능
- [ ] 규칙 템플릿 제공 (자주 사용하는 패턴)
- [ ] 코드 검증 로직
- [ ] 테이블 생성 시 자동 채번 컬럼 추가 통합
- [ ] 화면관리에서 입력 폼에 자동 코드 생성 버튼 추가