Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
ec2436e7dd
|
|
@ -64,6 +64,8 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
|
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
|
||||||
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -222,6 +224,8 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
|
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
|
||||||
|
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import config from "../config/environment";
|
||||||
import { AdminService } from "../services/adminService";
|
import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||||
|
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 메뉴 목록 조회
|
* 관리자 메뉴 목록 조회
|
||||||
|
|
@ -609,9 +610,15 @@ export const getCompanyList = async (
|
||||||
|
|
||||||
// Raw Query로 회사 목록 조회
|
// Raw Query로 회사 목록 조회
|
||||||
const companies = await query<any>(
|
const companies = await query<any>(
|
||||||
`SELECT
|
` SELECT
|
||||||
company_code,
|
company_code,
|
||||||
company_name,
|
company_name,
|
||||||
|
business_registration_number,
|
||||||
|
representative_name,
|
||||||
|
representative_phone,
|
||||||
|
email,
|
||||||
|
website,
|
||||||
|
address,
|
||||||
status,
|
status,
|
||||||
writer,
|
writer,
|
||||||
regdate
|
regdate
|
||||||
|
|
@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB(
|
||||||
|
|
||||||
// Raw Query로 회사 목록 조회
|
// Raw Query로 회사 목록 조회
|
||||||
const companies = await query<any>(
|
const companies = await query<any>(
|
||||||
`SELECT
|
` SELECT
|
||||||
company_code,
|
company_code,
|
||||||
company_name,
|
company_name,
|
||||||
|
business_registration_number,
|
||||||
|
representative_name,
|
||||||
|
representative_phone,
|
||||||
|
email,
|
||||||
|
website,
|
||||||
|
address,
|
||||||
writer,
|
writer,
|
||||||
regdate,
|
regdate,
|
||||||
status
|
status
|
||||||
|
|
@ -2440,6 +2453,25 @@ export const createCompany = async (
|
||||||
[company_name.trim()]
|
[company_name.trim()]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 사업자등록번호 유효성 검증
|
||||||
|
const businessNumberValidation = validateBusinessNumber(
|
||||||
|
req.body.business_registration_number?.trim() || ""
|
||||||
|
);
|
||||||
|
if (!businessNumberValidation.isValid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: businessNumberValidation.message,
|
||||||
|
errorCode: "INVALID_BUSINESS_NUMBER",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw Query로 사업자등록번호 중복 체크
|
||||||
|
const existingBusinessNumber = await queryOne<any>(
|
||||||
|
`SELECT company_code FROM company_mng WHERE business_registration_number = $1`,
|
||||||
|
[req.body.business_registration_number?.trim()]
|
||||||
|
);
|
||||||
|
|
||||||
if (existingCompany) {
|
if (existingCompany) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -2449,6 +2481,15 @@ export const createCompany = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingBusinessNumber) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 사업자등록번호입니다.",
|
||||||
|
errorCode: "DUPLICATE_BUSINESS_NUMBER",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용)
|
// PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용)
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
connectionString:
|
connectionString:
|
||||||
|
|
@ -2474,11 +2515,17 @@ export const createCompany = async (
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT INTO company_mng (
|
INSERT INTO company_mng (
|
||||||
company_code,
|
company_code,
|
||||||
company_name,
|
company_name,
|
||||||
|
business_registration_number,
|
||||||
|
representative_name,
|
||||||
|
representative_phone,
|
||||||
|
email,
|
||||||
|
website,
|
||||||
|
address,
|
||||||
writer,
|
writer,
|
||||||
regdate,
|
regdate,
|
||||||
status
|
status
|
||||||
) VALUES ($1, $2, $3, $4, $5)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -2488,6 +2535,12 @@ export const createCompany = async (
|
||||||
const insertValues = [
|
const insertValues = [
|
||||||
companyCode,
|
companyCode,
|
||||||
company_name.trim(),
|
company_name.trim(),
|
||||||
|
req.body.business_registration_number?.trim() || null,
|
||||||
|
req.body.representative_name?.trim() || null,
|
||||||
|
req.body.representative_phone?.trim() || null,
|
||||||
|
req.body.email?.trim() || null,
|
||||||
|
req.body.website?.trim() || null,
|
||||||
|
req.body.address?.trim() || null,
|
||||||
writer,
|
writer,
|
||||||
new Date(),
|
new Date(),
|
||||||
"active",
|
"active",
|
||||||
|
|
@ -2552,7 +2605,16 @@ export const updateCompany = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode } = req.params;
|
const { companyCode } = req.params;
|
||||||
const { company_name, status } = req.body;
|
const {
|
||||||
|
company_name,
|
||||||
|
business_registration_number,
|
||||||
|
representative_name,
|
||||||
|
representative_phone,
|
||||||
|
email,
|
||||||
|
website,
|
||||||
|
address,
|
||||||
|
status,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
logger.info("회사 정보 수정 요청", {
|
logger.info("회사 정보 수정 요청", {
|
||||||
companyCode,
|
companyCode,
|
||||||
|
|
@ -2586,13 +2648,61 @@ export const updateCompany = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외)
|
||||||
|
if (business_registration_number && business_registration_number.trim()) {
|
||||||
|
// 유효성 검증
|
||||||
|
const businessNumberValidation = validateBusinessNumber(business_registration_number.trim());
|
||||||
|
if (!businessNumberValidation.isValid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: businessNumberValidation.message,
|
||||||
|
errorCode: "INVALID_BUSINESS_NUMBER",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
const duplicateBusinessNumber = await queryOne<any>(
|
||||||
|
`SELECT company_code FROM company_mng
|
||||||
|
WHERE business_registration_number = $1 AND company_code != $2`,
|
||||||
|
[business_registration_number.trim(), companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateBusinessNumber) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 사업자등록번호입니다.",
|
||||||
|
errorCode: "DUPLICATE_BUSINESS_NUMBER",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Raw Query로 회사 정보 수정
|
// Raw Query로 회사 정보 수정
|
||||||
const result = await query<any>(
|
const result = await query<any>(
|
||||||
`UPDATE company_mng
|
`UPDATE company_mng
|
||||||
SET company_name = $1, status = $2
|
SET
|
||||||
WHERE company_code = $3
|
company_name = $1,
|
||||||
|
business_registration_number = $2,
|
||||||
|
representative_name = $3,
|
||||||
|
representative_phone = $4,
|
||||||
|
email = $5,
|
||||||
|
website = $6,
|
||||||
|
address = $7,
|
||||||
|
status = $8
|
||||||
|
WHERE company_code = $9
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[company_name.trim(), status || "active", companyCode]
|
[
|
||||||
|
company_name.trim(),
|
||||||
|
business_registration_number?.trim() || null,
|
||||||
|
representative_name?.trim() || null,
|
||||||
|
representative_phone?.trim() || null,
|
||||||
|
email?.trim() || null,
|
||||||
|
website?.trim() || null,
|
||||||
|
address?.trim() || null,
|
||||||
|
status || "active",
|
||||||
|
companyCode,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { ApiResponse } from "../types/common";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 목록 조회 (회사별)
|
||||||
|
*/
|
||||||
|
export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.params;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
logger.info("부서 목록 조회", { companyCode, userCompanyCode });
|
||||||
|
|
||||||
|
// 최고 관리자가 아니면 자신의 회사만 조회 가능
|
||||||
|
if (userCompanyCode !== "*" && userCompanyCode !== companyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "해당 회사의 부서를 조회할 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서 목록 조회 (부서원 수 포함)
|
||||||
|
const departments = await query<any>(`
|
||||||
|
SELECT
|
||||||
|
d.dept_code,
|
||||||
|
d.dept_name,
|
||||||
|
d.company_code,
|
||||||
|
d.parent_dept_code,
|
||||||
|
COUNT(DISTINCT ud.user_id) as member_count
|
||||||
|
FROM dept_info d
|
||||||
|
LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code
|
||||||
|
WHERE d.company_code = $1
|
||||||
|
GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code
|
||||||
|
ORDER BY d.dept_name
|
||||||
|
`, [companyCode]);
|
||||||
|
|
||||||
|
// 응답 형식 변환
|
||||||
|
const formattedDepartments = departments.map((dept) => ({
|
||||||
|
dept_code: dept.dept_code,
|
||||||
|
dept_name: dept.dept_name,
|
||||||
|
company_code: dept.company_code,
|
||||||
|
parent_dept_code: dept.parent_dept_code,
|
||||||
|
memberCount: parseInt(dept.member_count || "0"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: formattedDepartments,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서 목록 조회 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
|
||||||
|
const department = await queryOne<any>(`
|
||||||
|
SELECT
|
||||||
|
dept_code,
|
||||||
|
dept_name,
|
||||||
|
company_code,
|
||||||
|
parent_dept_code
|
||||||
|
FROM dept_info
|
||||||
|
WHERE dept_code = $1
|
||||||
|
`, [deptCode]);
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: department,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서 상세 조회 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 생성
|
||||||
|
*/
|
||||||
|
export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.params;
|
||||||
|
const { dept_name, parent_dept_code } = req.body;
|
||||||
|
|
||||||
|
if (!dept_name || !dept_name.trim()) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서명을 입력해주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 회사 내 중복 부서명 확인
|
||||||
|
const duplicate = await queryOne<any>(`
|
||||||
|
SELECT dept_code, dept_name
|
||||||
|
FROM dept_info
|
||||||
|
WHERE company_code = $1 AND dept_name = $2
|
||||||
|
`, [companyCode, dept_name.trim()]);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: `"${dept_name}" 부서가 이미 존재합니다.`,
|
||||||
|
isDuplicate: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 이름 조회
|
||||||
|
const company = await queryOne<any>(`
|
||||||
|
SELECT company_name FROM company_mng WHERE company_code = $1
|
||||||
|
`, [companyCode]);
|
||||||
|
|
||||||
|
const companyName = company?.company_name || companyCode;
|
||||||
|
|
||||||
|
// 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...)
|
||||||
|
const codeResult = await queryOne<any>(`
|
||||||
|
SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number
|
||||||
|
FROM dept_info
|
||||||
|
WHERE dept_code ~ '^DEPT_[0-9]+$'
|
||||||
|
`);
|
||||||
|
|
||||||
|
const nextNumber = codeResult?.next_number || 1;
|
||||||
|
const deptCode = `DEPT_${nextNumber}`;
|
||||||
|
|
||||||
|
// 부서 생성
|
||||||
|
const result = await query<any>(`
|
||||||
|
INSERT INTO dept_info (
|
||||||
|
dept_code,
|
||||||
|
dept_name,
|
||||||
|
company_code,
|
||||||
|
company_name,
|
||||||
|
parent_dept_code,
|
||||||
|
status,
|
||||||
|
regdate
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
deptCode,
|
||||||
|
dept_name.trim(),
|
||||||
|
companyCode,
|
||||||
|
companyName,
|
||||||
|
parent_dept_code || null,
|
||||||
|
'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("부서 생성 성공", { deptCode, dept_name });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "부서가 생성되었습니다.",
|
||||||
|
data: result[0],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서 생성 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서 생성 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 수정
|
||||||
|
*/
|
||||||
|
export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
const { dept_name, parent_dept_code } = req.body;
|
||||||
|
|
||||||
|
if (!dept_name || !dept_name.trim()) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서명을 입력해주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<any>(`
|
||||||
|
UPDATE dept_info
|
||||||
|
SET
|
||||||
|
dept_name = $1,
|
||||||
|
parent_dept_code = $2
|
||||||
|
WHERE dept_code = $3
|
||||||
|
RETURNING *
|
||||||
|
`, [dept_name.trim(), parent_dept_code || null, deptCode]);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("부서 수정 성공", { deptCode });
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "부서가 수정되었습니다.",
|
||||||
|
data: result[0],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서 수정 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서 수정 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
|
||||||
|
// 하위 부서 확인
|
||||||
|
const hasChildren = await queryOne<any>(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM dept_info
|
||||||
|
WHERE parent_dept_code = $1
|
||||||
|
`, [deptCode]);
|
||||||
|
|
||||||
|
if (parseInt(hasChildren?.count || "0") > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서원 삭제 (부서 삭제 전에 먼저 삭제)
|
||||||
|
const deletedMembers = await query<any>(`
|
||||||
|
DELETE FROM user_dept
|
||||||
|
WHERE dept_code = $1
|
||||||
|
RETURNING user_id
|
||||||
|
`, [deptCode]);
|
||||||
|
|
||||||
|
const memberCount = deletedMembers.length;
|
||||||
|
|
||||||
|
// 부서 삭제
|
||||||
|
const result = await query<any>(`
|
||||||
|
DELETE FROM dept_info
|
||||||
|
WHERE dept_code = $1
|
||||||
|
RETURNING dept_code, dept_name
|
||||||
|
`, [deptCode]);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("부서 삭제 성공", {
|
||||||
|
deptCode,
|
||||||
|
deptName: result[0].dept_name,
|
||||||
|
deletedMemberCount: memberCount
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: memberCount > 0
|
||||||
|
? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)`
|
||||||
|
: "부서가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서 삭제 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서 삭제 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서원 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
|
||||||
|
const members = await query<any>(`
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
u.user_name,
|
||||||
|
u.email,
|
||||||
|
u.tel as phone,
|
||||||
|
u.cell_phone,
|
||||||
|
u.position_name,
|
||||||
|
ud.dept_code,
|
||||||
|
d.dept_name,
|
||||||
|
ud.is_primary
|
||||||
|
FROM user_dept ud
|
||||||
|
JOIN user_info u ON ud.user_id = u.user_id
|
||||||
|
JOIN dept_info d ON ud.dept_code = d.dept_code
|
||||||
|
WHERE ud.dept_code = $1
|
||||||
|
ORDER BY ud.is_primary DESC, u.user_name
|
||||||
|
`, [deptCode]);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: members,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서원 목록 조회 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서원 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 검색 (부서원 추가용)
|
||||||
|
*/
|
||||||
|
export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.params;
|
||||||
|
const { search } = req.query;
|
||||||
|
|
||||||
|
if (!search || typeof search !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "검색어를 입력해주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 검색 (ID 또는 이름)
|
||||||
|
const users = await query<any>(`
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
user_name,
|
||||||
|
email,
|
||||||
|
position_name,
|
||||||
|
company_code
|
||||||
|
FROM user_info
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND (
|
||||||
|
user_id ILIKE $2 OR
|
||||||
|
user_name ILIKE $2
|
||||||
|
)
|
||||||
|
ORDER BY user_name
|
||||||
|
LIMIT 20
|
||||||
|
`, [companyCode, `%${search}%`]);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("사용자 검색 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자 검색 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서원 추가
|
||||||
|
*/
|
||||||
|
export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode } = req.params;
|
||||||
|
const { user_id } = req.body;
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자 ID를 입력해주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 존재 확인
|
||||||
|
const user = await queryOne<any>(`
|
||||||
|
SELECT user_id, user_name
|
||||||
|
FROM user_info
|
||||||
|
WHERE user_id = $1
|
||||||
|
`, [user_id]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 부서원인지 확인
|
||||||
|
const existing = await queryOne<any>(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_dept
|
||||||
|
WHERE user_id = $1 AND dept_code = $2
|
||||||
|
`, [user_id, deptCode]);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 해당 부서의 부서원입니다.",
|
||||||
|
isDuplicate: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 주 부서가 있는지 확인
|
||||||
|
const hasPrimary = await queryOne<any>(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_dept
|
||||||
|
WHERE user_id = $1 AND is_primary = true
|
||||||
|
`, [user_id]);
|
||||||
|
|
||||||
|
// 부서원 추가
|
||||||
|
await query<any>(`
|
||||||
|
INSERT INTO user_dept (user_id, dept_code, is_primary, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
`, [user_id, deptCode, !hasPrimary]);
|
||||||
|
|
||||||
|
logger.info("부서원 추가 성공", { user_id, deptCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "부서원이 추가되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서원 추가 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서원 추가 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서원 제거
|
||||||
|
*/
|
||||||
|
export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode, userId } = req.params;
|
||||||
|
|
||||||
|
const result = await query<any>(`
|
||||||
|
DELETE FROM user_dept
|
||||||
|
WHERE user_id = $1 AND dept_code = $2
|
||||||
|
RETURNING *
|
||||||
|
`, [userId, deptCode]);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "해당 부서원을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("부서원 제거 성공", { userId, deptCode });
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "부서원이 제거되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("부서원 제거 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부서원 제거 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주 부서 설정
|
||||||
|
*/
|
||||||
|
export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { deptCode, userId } = req.params;
|
||||||
|
|
||||||
|
// 다른 부서의 주 부서 해제
|
||||||
|
await query<any>(`
|
||||||
|
UPDATE user_dept
|
||||||
|
SET is_primary = false
|
||||||
|
WHERE user_id = $1
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
// 해당 부서를 주 부서로 설정
|
||||||
|
await query<any>(`
|
||||||
|
UPDATE user_dept
|
||||||
|
SET is_primary = true
|
||||||
|
WHERE user_id = $1 AND dept_code = $2
|
||||||
|
`, [userId, deptCode]);
|
||||||
|
|
||||||
|
logger.info("주 부서 설정 성공", { userId, deptCode });
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "주 부서가 설정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("주 부서 설정 실패", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "주 부서 설정 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -12,6 +12,14 @@ export const saveFormData = async (
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { screenId, tableName, data } = req.body;
|
const { screenId, tableName, data } = req.body;
|
||||||
|
|
||||||
|
// 🔍 디버깅: 사용자 정보 확인
|
||||||
|
console.log("🔍 [saveFormData] 사용자 정보:", {
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
reqUser: req.user,
|
||||||
|
dataWriter: data.writer,
|
||||||
|
});
|
||||||
|
|
||||||
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
|
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
|
||||||
if (screenId === undefined || screenId === null || !tableName || !data) {
|
if (screenId === undefined || screenId === null || !tableName || !data) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -25,9 +33,12 @@ export const saveFormData = async (
|
||||||
...data,
|
...data,
|
||||||
created_by: userId,
|
created_by: userId,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
|
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||||
screen_id: screenId,
|
screen_id: screenId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("✅ [saveFormData] 최종 writer 값:", formDataWithMeta.writer);
|
||||||
|
|
||||||
// company_code는 사용자가 명시적으로 입력한 경우에만 추가
|
// company_code는 사용자가 명시적으로 입력한 경우에만 추가
|
||||||
if (data.company_code !== undefined) {
|
if (data.company_code !== undefined) {
|
||||||
formDataWithMeta.company_code = data.company_code;
|
formDataWithMeta.company_code = data.company_code;
|
||||||
|
|
@ -86,6 +97,7 @@ export const saveFormDataEnhanced = async (
|
||||||
...data,
|
...data,
|
||||||
created_by: userId,
|
created_by: userId,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
|
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||||
screen_id: screenId,
|
screen_id: screenId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -134,6 +146,7 @@ export const updateFormData = async (
|
||||||
const formDataWithMeta = {
|
const formDataWithMeta = {
|
||||||
...data,
|
...data,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
|
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -186,6 +199,7 @@ export const updateFormDataPartial = async (
|
||||||
const newDataWithMeta = {
|
const newDataWithMeta = {
|
||||||
...newData,
|
...newData,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
|
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await dynamicFormService.updateFormDataPartial(
|
const result = await dynamicFormService.updateFormDataPartial(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* 채번 규칙 관리 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { numberingRuleService } from "../services/numberingRuleService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rules = await numberingRuleService.getRuleList(companyCode);
|
||||||
|
return res.json({ success: true, data: rules });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("규칙 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 특정 규칙 조회
|
||||||
|
router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||||
|
if (!rule) {
|
||||||
|
return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
return res.json({ success: true, data: rule });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("규칙 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const ruleConfig = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||||
|
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
||||||
|
return res.status(201).json({ success: true, data: newRule });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||||
|
}
|
||||||
|
logger.error("규칙 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { ruleId } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
|
||||||
|
return res.json({ success: true, data: updatedRule });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes("찾을 수 없거나")) {
|
||||||
|
return res.status(404).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
logger.error("규칙 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||||
|
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes("찾을 수 없거나")) {
|
||||||
|
return res.status(404).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
logger.error("규칙 삭제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 코드 생성
|
||||||
|
router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode);
|
||||||
|
return res.json({ success: true, data: { code: generatedCode } });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("코드 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 시퀀스 초기화
|
||||||
|
router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await numberingRuleService.resetSequence(ruleId, companyCode);
|
||||||
|
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("시퀀스 초기화 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ColumnListResponse,
|
ColumnListResponse,
|
||||||
ColumnSettingsResponse,
|
ColumnSettingsResponse,
|
||||||
} from "../types/tableManagement";
|
} from "../types/tableManagement";
|
||||||
|
import { query } from "../database/db"; // 🆕 query 함수 import
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 목록 조회
|
* 테이블 목록 조회
|
||||||
|
|
@ -506,7 +507,91 @@ export async function updateColumnInputType(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 데이터 조회 (페이징 + 검색)
|
* 단일 레코드 조회 (자동 입력용)
|
||||||
|
*/
|
||||||
|
export async function getTableRecord(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { filterColumn, filterValue, displayColumn } = req.body;
|
||||||
|
|
||||||
|
logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`);
|
||||||
|
logger.info(`필터: ${filterColumn} = ${filterValue}`);
|
||||||
|
logger.info(`표시 컬럼: ${displayColumn}`);
|
||||||
|
|
||||||
|
if (!tableName || !filterColumn || !filterValue || !displayColumn) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_PARAMETERS",
|
||||||
|
details:
|
||||||
|
"tableName, filterColumn, filterValue, displayColumn이 필요합니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
|
// 단일 레코드 조회 (WHERE filterColumn = filterValue)
|
||||||
|
const result = await tableManagementService.getTableData(tableName, {
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: {
|
||||||
|
[filterColumn]: filterValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data || result.data.length === 0) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "데이터를 찾을 수 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = result.data[0];
|
||||||
|
const displayValue = record[displayColumn];
|
||||||
|
|
||||||
|
logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`);
|
||||||
|
|
||||||
|
const response: ApiResponse<{ value: any; record: any }> = {
|
||||||
|
success: true,
|
||||||
|
message: "레코드를 성공적으로 조회했습니다.",
|
||||||
|
data: {
|
||||||
|
value: displayValue,
|
||||||
|
record: record,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("레코드 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "레코드 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "RECORD_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 데이터 조회 (페이징 + 검색 + 필터링)
|
||||||
*/
|
*/
|
||||||
export async function getTableData(
|
export async function getTableData(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -520,12 +605,14 @@ export async function getTableData(
|
||||||
search = {},
|
search = {},
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder = "asc",
|
sortOrder = "asc",
|
||||||
|
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
||||||
logger.info(`페이징: page=${page}, size=${size}`);
|
logger.info(`페이징: page=${page}, size=${size}`);
|
||||||
logger.info(`검색 조건:`, search);
|
logger.info(`검색 조건:`, search);
|
||||||
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
||||||
|
logger.info(`자동 필터:`, autoFilter); // 🆕
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
|
|
@ -542,11 +629,35 @@ export async function getTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
|
// 🆕 현재 사용자 필터 적용
|
||||||
|
let enhancedSearch = { ...search };
|
||||||
|
if (autoFilter?.enabled && req.user) {
|
||||||
|
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||||
|
const userField = autoFilter.userField || "companyCode";
|
||||||
|
const userValue = (req.user as any)[userField];
|
||||||
|
|
||||||
|
if (userValue) {
|
||||||
|
enhancedSearch[filterColumn] = userValue;
|
||||||
|
|
||||||
|
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||||
|
filterColumn,
|
||||||
|
userField,
|
||||||
|
userValue,
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
|
||||||
|
userField,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회
|
||||||
const result = await tableManagementService.getTableData(tableName, {
|
const result = await tableManagementService.getTableData(tableName, {
|
||||||
page: parseInt(page),
|
page: parseInt(page),
|
||||||
size: parseInt(size),
|
size: parseInt(size),
|
||||||
search,
|
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
});
|
});
|
||||||
|
|
@ -1216,9 +1327,7 @@ export async function getLogData(
|
||||||
originalId: originalId as string,
|
originalId: originalId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`);
|
||||||
`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`
|
|
||||||
);
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof result> = {
|
const response: ApiResponse<typeof result> = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -1254,7 +1363,9 @@ export async function toggleLogTable(
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const { isActive } = req.body;
|
const { isActive } = req.body;
|
||||||
|
|
||||||
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
|
logger.info(
|
||||||
|
`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`
|
||||||
|
);
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
|
|
@ -1288,9 +1399,7 @@ export async function toggleLogTable(
|
||||||
isActive === "Y" || isActive === true
|
isActive === "Y" || isActive === true
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`);
|
||||||
`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as departmentController from "../controllers/departmentController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 관리 API 라우트
|
||||||
|
* 기본 경로: /api/departments
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 부서 목록 조회 (회사별)
|
||||||
|
router.get("/companies/:companyCode/departments", departmentController.getDepartments);
|
||||||
|
|
||||||
|
// 부서 상세 조회
|
||||||
|
router.get("/:deptCode", departmentController.getDepartment);
|
||||||
|
|
||||||
|
// 부서 생성
|
||||||
|
router.post("/companies/:companyCode/departments", departmentController.createDepartment);
|
||||||
|
|
||||||
|
// 부서 수정
|
||||||
|
router.put("/:deptCode", departmentController.updateDepartment);
|
||||||
|
|
||||||
|
// 부서 삭제
|
||||||
|
router.delete("/:deptCode", departmentController.deleteDepartment);
|
||||||
|
|
||||||
|
// 부서원 목록 조회
|
||||||
|
router.get("/:deptCode/members", departmentController.getDepartmentMembers);
|
||||||
|
|
||||||
|
// 사용자 검색 (부서원 추가용)
|
||||||
|
router.get("/companies/:companyCode/users/search", departmentController.searchUsers);
|
||||||
|
|
||||||
|
// 부서원 추가
|
||||||
|
router.post("/:deptCode/members", departmentController.addDepartmentMember);
|
||||||
|
|
||||||
|
// 부서원 제거
|
||||||
|
router.delete("/:deptCode/members/:userId", departmentController.removeDepartmentMember);
|
||||||
|
|
||||||
|
// 주 부서 설정
|
||||||
|
router.put("/:deptCode/members/:userId/primary", departmentController.setPrimaryDepartment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
updateColumnInputType,
|
updateColumnInputType,
|
||||||
updateTableLabel,
|
updateTableLabel,
|
||||||
getTableData,
|
getTableData,
|
||||||
|
getTableRecord, // 🆕 단일 레코드 조회
|
||||||
addTableData,
|
addTableData,
|
||||||
editTableData,
|
editTableData,
|
||||||
deleteTableData,
|
deleteTableData,
|
||||||
|
|
@ -134,6 +135,12 @@ router.get("/health", checkDatabaseConnection);
|
||||||
*/
|
*/
|
||||||
router.post("/tables/:tableName/data", getTableData);
|
router.post("/tables/:tableName/data", getTableData);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 레코드 조회 (자동 입력용)
|
||||||
|
* POST /api/table-management/tables/:tableName/record
|
||||||
|
*/
|
||||||
|
router.post("/tables/:tableName/record", getTableRecord);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 데이터 추가
|
* 테이블 데이터 추가
|
||||||
* POST /api/table-management/tables/:tableName/add
|
* POST /api/table-management/tables/:tableName/add
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,7 @@ export class DDLExecutionService {
|
||||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
"created_date" timestamp DEFAULT now(),
|
"created_date" timestamp DEFAULT now(),
|
||||||
"updated_date" timestamp DEFAULT now(),
|
"updated_date" timestamp DEFAULT now(),
|
||||||
"writer" varchar(500),
|
"writer" varchar(500) DEFAULT NULL,
|
||||||
"company_code" varchar(500)`;
|
"company_code" varchar(500)`;
|
||||||
|
|
||||||
// 최종 CREATE TABLE 쿼리
|
// 최종 CREATE TABLE 쿼리
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* 채번 규칙 관리 서비스
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface NumberingRulePart {
|
||||||
|
id?: number;
|
||||||
|
order: number;
|
||||||
|
partType: string;
|
||||||
|
generationMethod: string;
|
||||||
|
autoConfig?: any;
|
||||||
|
manualConfig?: any;
|
||||||
|
generatedValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberingRuleConfig {
|
||||||
|
ruleId: string;
|
||||||
|
ruleName: string;
|
||||||
|
description?: string;
|
||||||
|
parts: NumberingRulePart[];
|
||||||
|
separator?: string;
|
||||||
|
resetPeriod?: string;
|
||||||
|
currentSequence?: number;
|
||||||
|
tableName?: string;
|
||||||
|
columnName?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NumberingRuleService {
|
||||||
|
/**
|
||||||
|
* 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
async getRuleList(companyCode: string): Promise<NumberingRuleConfig[]> {
|
||||||
|
try {
|
||||||
|
logger.info("채번 규칙 목록 조회 시작", { companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
rule_id AS "ruleId",
|
||||||
|
rule_name AS "ruleName",
|
||||||
|
description,
|
||||||
|
separator,
|
||||||
|
reset_period AS "resetPeriod",
|
||||||
|
current_sequence AS "currentSequence",
|
||||||
|
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"
|
||||||
|
FROM numbering_rules
|
||||||
|
WHERE company_code = $1 OR company_code = '*'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [companyCode]);
|
||||||
|
|
||||||
|
// 각 규칙의 파트 정보 조회
|
||||||
|
for (const rule of result.rows) {
|
||||||
|
const partsQuery = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
part_order AS "order",
|
||||||
|
part_type AS "partType",
|
||||||
|
generation_method AS "generationMethod",
|
||||||
|
auto_config AS "autoConfig",
|
||||||
|
manual_config AS "manualConfig"
|
||||||
|
FROM numbering_rule_parts
|
||||||
|
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||||
|
ORDER BY part_order
|
||||||
|
`;
|
||||||
|
|
||||||
|
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||||
|
rule.parts = partsResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode });
|
||||||
|
return result.rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 규칙 조회
|
||||||
|
*/
|
||||||
|
async getRuleById(ruleId: string, companyCode: string): Promise<NumberingRuleConfig | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
rule_id AS "ruleId",
|
||||||
|
rule_name AS "ruleName",
|
||||||
|
description,
|
||||||
|
separator,
|
||||||
|
reset_period AS "resetPeriod",
|
||||||
|
current_sequence AS "currentSequence",
|
||||||
|
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"
|
||||||
|
FROM numbering_rules
|
||||||
|
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [ruleId, companyCode]);
|
||||||
|
if (result.rowCount === 0) return null;
|
||||||
|
|
||||||
|
const rule = result.rows[0];
|
||||||
|
|
||||||
|
const partsQuery = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
part_order AS "order",
|
||||||
|
part_type AS "partType",
|
||||||
|
generation_method AS "generationMethod",
|
||||||
|
auto_config AS "autoConfig",
|
||||||
|
manual_config AS "manualConfig"
|
||||||
|
FROM numbering_rule_parts
|
||||||
|
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||||
|
ORDER BY part_order
|
||||||
|
`;
|
||||||
|
|
||||||
|
const partsResult = await pool.query(partsQuery, [ruleId, companyCode]);
|
||||||
|
rule.parts = partsResult.rows;
|
||||||
|
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙 생성
|
||||||
|
*/
|
||||||
|
async createRule(
|
||||||
|
config: NumberingRuleConfig,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<NumberingRuleConfig> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 마스터 삽입
|
||||||
|
const insertRuleQuery = `
|
||||||
|
INSERT INTO numbering_rules (
|
||||||
|
rule_id, rule_name, description, separator, reset_period,
|
||||||
|
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",
|
||||||
|
description,
|
||||||
|
separator,
|
||||||
|
reset_period AS "resetPeriod",
|
||||||
|
current_sequence AS "currentSequence",
|
||||||
|
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"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ruleResult = await client.query(insertRuleQuery, [
|
||||||
|
config.ruleId,
|
||||||
|
config.ruleName,
|
||||||
|
config.description || null,
|
||||||
|
config.separator || "-",
|
||||||
|
config.resetPeriod || "none",
|
||||||
|
config.currentSequence || 1,
|
||||||
|
config.tableName || null,
|
||||||
|
config.columnName || null,
|
||||||
|
companyCode,
|
||||||
|
config.menuObjid || null,
|
||||||
|
config.scopeType || "global",
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 파트 삽입
|
||||||
|
const parts: NumberingRulePart[] = [];
|
||||||
|
for (const part of config.parts) {
|
||||||
|
const insertPartQuery = `
|
||||||
|
INSERT INTO numbering_rule_parts (
|
||||||
|
rule_id, part_order, part_type, generation_method,
|
||||||
|
auto_config, manual_config, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
part_order AS "order",
|
||||||
|
part_type AS "partType",
|
||||||
|
generation_method AS "generationMethod",
|
||||||
|
auto_config AS "autoConfig",
|
||||||
|
manual_config AS "manualConfig"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const partResult = await client.query(insertPartQuery, [
|
||||||
|
config.ruleId,
|
||||||
|
part.order,
|
||||||
|
part.partType,
|
||||||
|
part.generationMethod,
|
||||||
|
JSON.stringify(part.autoConfig || {}),
|
||||||
|
JSON.stringify(part.manualConfig || {}),
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
parts.push(partResult.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode });
|
||||||
|
return { ...ruleResult.rows[0], parts };
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("채번 규칙 생성 실패", { error: error.message });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙 수정
|
||||||
|
*/
|
||||||
|
async updateRule(
|
||||||
|
ruleId: string,
|
||||||
|
updates: Partial<NumberingRuleConfig>,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<NumberingRuleConfig> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const updateRuleQuery = `
|
||||||
|
UPDATE numbering_rules
|
||||||
|
SET
|
||||||
|
rule_name = COALESCE($1, rule_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
separator = COALESCE($3, separator),
|
||||||
|
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 = $9 AND company_code = $10
|
||||||
|
RETURNING
|
||||||
|
rule_id AS "ruleId",
|
||||||
|
rule_name AS "ruleName",
|
||||||
|
description,
|
||||||
|
separator,
|
||||||
|
reset_period AS "resetPeriod",
|
||||||
|
current_sequence AS "currentSequence",
|
||||||
|
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"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ruleResult = await client.query(updateRuleQuery, [
|
||||||
|
updates.ruleName,
|
||||||
|
updates.description,
|
||||||
|
updates.separator,
|
||||||
|
updates.resetPeriod,
|
||||||
|
updates.tableName,
|
||||||
|
updates.columnName,
|
||||||
|
updates.menuObjid,
|
||||||
|
updates.scopeType,
|
||||||
|
ruleId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (ruleResult.rowCount === 0) {
|
||||||
|
throw new Error("규칙을 찾을 수 없거나 권한이 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파트 업데이트
|
||||||
|
let parts: NumberingRulePart[] = [];
|
||||||
|
if (updates.parts) {
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
|
||||||
|
[ruleId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const part of updates.parts) {
|
||||||
|
const insertPartQuery = `
|
||||||
|
INSERT INTO numbering_rule_parts (
|
||||||
|
rule_id, part_order, part_type, generation_method,
|
||||||
|
auto_config, manual_config, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
part_order AS "order",
|
||||||
|
part_type AS "partType",
|
||||||
|
generation_method AS "generationMethod",
|
||||||
|
auto_config AS "autoConfig",
|
||||||
|
manual_config AS "manualConfig"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const partResult = await client.query(insertPartQuery, [
|
||||||
|
ruleId,
|
||||||
|
part.order,
|
||||||
|
part.partType,
|
||||||
|
part.generationMethod,
|
||||||
|
JSON.stringify(part.autoConfig || {}),
|
||||||
|
JSON.stringify(part.manualConfig || {}),
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
parts.push(partResult.rows[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("채번 규칙 수정 완료", { ruleId, companyCode });
|
||||||
|
return { ...ruleResult.rows[0], parts };
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("채번 규칙 수정 실패", { error: error.message });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙 삭제
|
||||||
|
*/
|
||||||
|
async deleteRule(ruleId: string, companyCode: string): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
const query = `
|
||||||
|
DELETE FROM numbering_rules
|
||||||
|
WHERE rule_id = $1 AND company_code = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [ruleId, companyCode]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("규칙을 찾을 수 없거나 권한이 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("채번 규칙 삭제 완료", { ruleId, companyCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성
|
||||||
|
*/
|
||||||
|
async generateCode(ruleId: string, companyCode: string): Promise<string> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const rule = await this.getRuleById(ruleId, companyCode);
|
||||||
|
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||||
|
|
||||||
|
const parts = rule.parts
|
||||||
|
.sort((a: any, b: any) => a.order - b.order)
|
||||||
|
.map((part: any) => {
|
||||||
|
if (part.generationMethod === "manual") {
|
||||||
|
return part.manualConfig?.value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoConfig = part.autoConfig || {};
|
||||||
|
|
||||||
|
switch (part.partType) {
|
||||||
|
case "prefix":
|
||||||
|
return autoConfig.prefix || "PREFIX";
|
||||||
|
|
||||||
|
case "sequence": {
|
||||||
|
const length = autoConfig.sequenceLength || 4;
|
||||||
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD");
|
||||||
|
|
||||||
|
case "year": {
|
||||||
|
const format = autoConfig.dateFormat || "YYYY";
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return format === "YY" ? String(year).slice(-2) : String(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "month":
|
||||||
|
return String(new Date().getMonth() + 1).padStart(2, "0");
|
||||||
|
|
||||||
|
case "custom":
|
||||||
|
return autoConfig.value || "CUSTOM";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const generatedCode = parts.join(rule.separator || "");
|
||||||
|
|
||||||
|
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||||
|
if (hasSequence) {
|
||||||
|
await client.query(
|
||||||
|
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||||
|
[ruleId, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("코드 생성 완료", { ruleId, generatedCode });
|
||||||
|
return generatedCode;
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("코드 생성 실패", { error: error.message });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(date: Date, format: string): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "YYYY": return String(year);
|
||||||
|
case "YY": return String(year).slice(-2);
|
||||||
|
case "YYYYMM": return `${year}${month}`;
|
||||||
|
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||||
|
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||||
|
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||||
|
default: return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetSequence(ruleId: string, companyCode: string): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2",
|
||||||
|
[ruleId, companyCode]
|
||||||
|
);
|
||||||
|
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const numberingRuleService = new NumberingRuleService();
|
||||||
|
|
@ -1502,6 +1502,9 @@ export class TableManagementService {
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
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]);
|
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||||
|
|
||||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||||
|
|
@ -2980,20 +2983,20 @@ export class TableManagementService {
|
||||||
try {
|
try {
|
||||||
logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
|
logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
|
||||||
|
|
||||||
// table_type_columns에서 입력타입 정보 조회
|
// column_labels에서 입력타입 정보 조회
|
||||||
const rawInputTypes = await query<any>(
|
const rawInputTypes = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
ttc.column_name as "columnName",
|
cl.column_name as "columnName",
|
||||||
ttc.column_name as "displayName",
|
cl.column_label as "displayName",
|
||||||
COALESCE(ttc.input_type, 'text') as "inputType",
|
COALESCE(cl.input_type, 'text') as "inputType",
|
||||||
COALESCE(ttc.detail_settings, '{}') as "detailSettings",
|
'{}'::jsonb as "detailSettings",
|
||||||
ttc.is_nullable as "isNullable",
|
ic.is_nullable as "isNullable",
|
||||||
ic.data_type as "dataType"
|
ic.data_type as "dataType"
|
||||||
FROM table_type_columns ttc
|
FROM column_labels cl
|
||||||
LEFT JOIN information_schema.columns ic
|
LEFT JOIN information_schema.columns ic
|
||||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
ON cl.table_name = ic.table_name AND cl.column_name = ic.column_name
|
||||||
WHERE ttc.table_name = $1
|
WHERE cl.table_name = $1
|
||||||
ORDER BY ttc.display_order, ttc.column_name`,
|
ORDER BY cl.column_name`,
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* 사업자등록번호 유효성 검사 유틸리티 (백엔드)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사업자등록번호 포맷 검증
|
||||||
|
*/
|
||||||
|
export function validateBusinessNumberFormat(value: string): boolean {
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하이픈 제거
|
||||||
|
const cleaned = value.replace(/-/g, "");
|
||||||
|
|
||||||
|
// 숫자 10자리인지 확인
|
||||||
|
if (!/^\d{10}$/.test(cleaned)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사업자등록번호 종합 검증 (포맷만 검사)
|
||||||
|
* 실제 국세청 검증은 API 호출로 처리하는 것을 권장
|
||||||
|
*/
|
||||||
|
export function validateBusinessNumber(value: string): {
|
||||||
|
isValid: boolean;
|
||||||
|
message: string;
|
||||||
|
} {
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: "사업자등록번호를 입력해주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateBusinessNumberFormat(value)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: "사업자등록번호는 10자리 숫자여야 합니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 포맷만 검증하고 통과
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
# 채번규칙 컴포넌트 구현 완료
|
||||||
|
|
||||||
|
> **작성일**: 2025-11-04
|
||||||
|
> **상태**: 백엔드 및 프론트엔드 핵심 구현 완료 (화면관리 통합 대기)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 개요
|
||||||
|
|
||||||
|
채번규칙(Numbering Rule) 컴포넌트는 시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다.
|
||||||
|
|
||||||
|
**생성 코드 예시**:
|
||||||
|
- 제품 코드: `PROD-20251104-0001`
|
||||||
|
- 프로젝트 코드: `PRJ-2025-001`
|
||||||
|
- 거래처 코드: `CUST-A-0001`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료된 구현 항목
|
||||||
|
|
||||||
|
### 1. 데이터베이스 레이어 ✅
|
||||||
|
|
||||||
|
**파일**: `db/migrations/034_create_numbering_rules.sql`
|
||||||
|
|
||||||
|
- [x] `numbering_rules` 마스터 테이블 생성
|
||||||
|
- [x] `numbering_rule_parts` 파트 테이블 생성
|
||||||
|
- [x] 멀티테넌시 지원 (company_code 필드)
|
||||||
|
- [x] 인덱스 생성 (성능 최적화)
|
||||||
|
- [x] 샘플 데이터 삽입
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
- 규칙 ID, 규칙명, 구분자, 초기화 주기
|
||||||
|
- 현재 시퀀스 번호 관리
|
||||||
|
- 적용 대상 테이블/컬럼 지정
|
||||||
|
- 최대 6개 파트 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 백엔드 레이어 ✅
|
||||||
|
|
||||||
|
#### 2.1 서비스 레이어
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/services/numberingRuleService.ts`
|
||||||
|
|
||||||
|
**구현된 메서드**:
|
||||||
|
- [x] `getRuleList(companyCode)` - 규칙 목록 조회
|
||||||
|
- [x] `getRuleById(ruleId, companyCode)` - 특정 규칙 조회
|
||||||
|
- [x] `createRule(config, companyCode, userId)` - 규칙 생성
|
||||||
|
- [x] `updateRule(ruleId, updates, companyCode)` - 규칙 수정
|
||||||
|
- [x] `deleteRule(ruleId, companyCode)` - 규칙 삭제
|
||||||
|
- [x] `generateCode(ruleId, companyCode)` - 코드 생성
|
||||||
|
- [x] `resetSequence(ruleId, companyCode)` - 시퀀스 초기화
|
||||||
|
|
||||||
|
**핵심 로직**:
|
||||||
|
- 트랜잭션 관리 (BEGIN/COMMIT/ROLLBACK)
|
||||||
|
- 멀티테넌시 필터링 (company_code 기반)
|
||||||
|
- JSON 설정 직렬화/역직렬화
|
||||||
|
- 날짜 형식 변환 (YYYY, YYYYMMDD 등)
|
||||||
|
- 순번 자동 증가 및 제로 패딩
|
||||||
|
|
||||||
|
#### 2.2 컨트롤러 레이어
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/numberingRuleController.ts`
|
||||||
|
|
||||||
|
**구현된 엔드포인트**:
|
||||||
|
- [x] `GET /api/numbering-rules` - 규칙 목록 조회
|
||||||
|
- [x] `GET /api/numbering-rules/:ruleId` - 특정 규칙 조회
|
||||||
|
- [x] `POST /api/numbering-rules` - 규칙 생성
|
||||||
|
- [x] `PUT /api/numbering-rules/:ruleId` - 규칙 수정
|
||||||
|
- [x] `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제
|
||||||
|
- [x] `POST /api/numbering-rules/:ruleId/generate` - 코드 생성
|
||||||
|
- [x] `POST /api/numbering-rules/:ruleId/reset` - 시퀀스 초기화
|
||||||
|
|
||||||
|
**보안 및 검증**:
|
||||||
|
- `authenticateToken` 미들웨어로 인증 확인
|
||||||
|
- 입력값 검증 (필수 필드, 파트 최소 개수)
|
||||||
|
- 에러 핸들링 및 적절한 HTTP 상태 코드 반환
|
||||||
|
|
||||||
|
#### 2.3 라우터 등록
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/app.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import numberingRuleController from "./controllers/numberingRuleController";
|
||||||
|
app.use("/api/numbering-rules", numberingRuleController);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 프론트엔드 레이어 ✅
|
||||||
|
|
||||||
|
#### 3.1 타입 정의
|
||||||
|
|
||||||
|
**파일**: `frontend/types/numbering-rule.ts`
|
||||||
|
|
||||||
|
**정의된 타입**:
|
||||||
|
- [x] `CodePartType` - 파트 유형 (prefix/sequence/date/year/month/custom)
|
||||||
|
- [x] `GenerationMethod` - 생성 방식 (auto/manual)
|
||||||
|
- [x] `DateFormat` - 날짜 형식 (YYYY/YYYYMMDD 등)
|
||||||
|
- [x] `NumberingRulePart` - 단일 파트 인터페이스
|
||||||
|
- [x] `NumberingRuleConfig` - 전체 규칙 인터페이스
|
||||||
|
- [x] 상수 옵션 배열 (UI용)
|
||||||
|
|
||||||
|
#### 3.2 API 클라이언트
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/api/numberingRule.ts`
|
||||||
|
|
||||||
|
**구현된 함수**:
|
||||||
|
- [x] `getNumberingRules()` - 규칙 목록 조회
|
||||||
|
- [x] `getNumberingRuleById(ruleId)` - 특정 규칙 조회
|
||||||
|
- [x] `createNumberingRule(config)` - 규칙 생성
|
||||||
|
- [x] `updateNumberingRule(ruleId, config)` - 규칙 수정
|
||||||
|
- [x] `deleteNumberingRule(ruleId)` - 규칙 삭제
|
||||||
|
- [x] `generateCode(ruleId)` - 코드 생성
|
||||||
|
- [x] `resetSequence(ruleId)` - 시퀀스 초기화
|
||||||
|
|
||||||
|
**기술 스택**:
|
||||||
|
- Axios 기반 API 클라이언트
|
||||||
|
- 에러 핸들링 및 응답 타입 정의
|
||||||
|
|
||||||
|
#### 3.3 컴포넌트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/components/numbering-rule/
|
||||||
|
├── NumberingRuleDesigner.tsx # 메인 디자이너 (좌우 분할)
|
||||||
|
├── NumberingRulePreview.tsx # 실시간 미리보기
|
||||||
|
├── NumberingRuleCard.tsx # 단일 파트 카드
|
||||||
|
├── AutoConfigPanel.tsx # 자동 생성 설정
|
||||||
|
└── ManualConfigPanel.tsx # 직접 입력 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4 주요 컴포넌트 기능
|
||||||
|
|
||||||
|
**NumberingRuleDesigner** (메인 컴포넌트):
|
||||||
|
- [x] 좌측: 저장된 규칙 목록 (카드 리스트)
|
||||||
|
- [x] 우측: 규칙 편집 영역 (파트 추가/수정/삭제)
|
||||||
|
- [x] 실시간 미리보기
|
||||||
|
- [x] 규칙 저장/불러오기/삭제
|
||||||
|
- [x] 타이틀 편집 기능
|
||||||
|
- [x] 로딩 상태 관리
|
||||||
|
|
||||||
|
**NumberingRulePreview**:
|
||||||
|
- [x] 설정된 규칙에 따라 실시간 코드 생성
|
||||||
|
- [x] 컴팩트 모드 지원
|
||||||
|
- [x] useMemo로 성능 최적화
|
||||||
|
|
||||||
|
**NumberingRuleCard**:
|
||||||
|
- [x] 파트 유형 선택 (Select)
|
||||||
|
- [x] 생성 방식 선택 (자동/수동)
|
||||||
|
- [x] 동적 설정 패널 표시
|
||||||
|
- [x] 삭제 버튼
|
||||||
|
|
||||||
|
**AutoConfigPanel**:
|
||||||
|
- [x] 파트 유형별 설정 UI
|
||||||
|
- [x] 접두사, 순번, 날짜, 연도, 월, 커스텀
|
||||||
|
- [x] 입력값 검증 및 가이드 텍스트
|
||||||
|
|
||||||
|
**ManualConfigPanel**:
|
||||||
|
- [x] 직접 입력값 설정
|
||||||
|
- [x] 플레이스홀더 설정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술적 특징
|
||||||
|
|
||||||
|
### Shadcn/ui 스타일 가이드 준수
|
||||||
|
|
||||||
|
- 반응형 크기: `h-8 sm:h-10`, `text-xs sm:text-sm`
|
||||||
|
- 색상 토큰: `bg-muted`, `text-muted-foreground`, `border-border`
|
||||||
|
- 간격: `space-y-3 sm:space-y-4`, `gap-4`
|
||||||
|
- 상태: `hover:bg-accent`, `disabled:opacity-50`
|
||||||
|
|
||||||
|
### 실시간 속성 편집 패턴
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentRule) {
|
||||||
|
onChange?.(currentRule); // 상위 컴포넌트로 실시간 전파
|
||||||
|
}
|
||||||
|
}, [currentRule, onChange]);
|
||||||
|
|
||||||
|
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
|
||||||
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 멀티테넌시 지원
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 백엔드 쿼리
|
||||||
|
WHERE company_code = $1 OR company_code = '*'
|
||||||
|
|
||||||
|
// 일반 회사는 자신의 데이터만 조회
|
||||||
|
// company_code = "*"는 최고 관리자 전용 데이터
|
||||||
|
```
|
||||||
|
|
||||||
|
### 에러 처리 및 사용자 피드백
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const response = await createNumberingRule(config);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("채번 규칙이 저장되었습니다");
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`저장 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 남은 작업
|
||||||
|
|
||||||
|
### 화면관리 시스템 통합 (TODO)
|
||||||
|
|
||||||
|
다음 파일들을 생성하여 화면관리 시스템에 컴포넌트를 등록해야 합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/numbering-rule/
|
||||||
|
├── index.ts # 컴포넌트 정의 및 등록
|
||||||
|
├── NumberingRuleComponent.tsx # 래퍼 컴포넌트
|
||||||
|
├── NumberingRuleConfigPanel.tsx # 속성 설정 패널
|
||||||
|
└── types.ts # 컴포넌트 설정 타입
|
||||||
|
```
|
||||||
|
|
||||||
|
**등록 예시**:
|
||||||
|
```typescript
|
||||||
|
export const NumberingRuleDefinition = createComponentDefinition({
|
||||||
|
id: "numbering-rule",
|
||||||
|
name: "코드 채번 규칙",
|
||||||
|
category: ComponentCategory.ADMIN,
|
||||||
|
component: NumberingRuleWrapper,
|
||||||
|
configPanel: NumberingRuleConfigPanel,
|
||||||
|
defaultSize: { width: 1200, height: 800 },
|
||||||
|
icon: "Hash",
|
||||||
|
tags: ["코드", "채번", "규칙", "관리자"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 가이드
|
||||||
|
|
||||||
|
### 백엔드 API 테스트 (Postman/Thunder Client)
|
||||||
|
|
||||||
|
#### 1. 규칙 목록 조회
|
||||||
|
```bash
|
||||||
|
GET http://localhost:8080/api/numbering-rules
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 규칙 생성
|
||||||
|
```bash
|
||||||
|
POST http://localhost:8080/api/numbering-rules
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
|
||||||
|
{
|
||||||
|
"ruleId": "PROD_CODE",
|
||||||
|
"ruleName": "제품 코드 규칙",
|
||||||
|
"separator": "-",
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"partType": "prefix",
|
||||||
|
"generationMethod": "auto",
|
||||||
|
"autoConfig": { "prefix": "PROD" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"partType": "date",
|
||||||
|
"generationMethod": "auto",
|
||||||
|
"autoConfig": { "dateFormat": "YYYYMMDD" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"partType": "sequence",
|
||||||
|
"generationMethod": "auto",
|
||||||
|
"autoConfig": { "sequenceLength": 4, "startFrom": 1 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 코드 생성
|
||||||
|
```bash
|
||||||
|
POST http://localhost:8080/api/numbering-rules/PROD_CODE/generate
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"code": "PROD-20251104-0001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 테스트
|
||||||
|
|
||||||
|
1. **새 규칙 생성**:
|
||||||
|
- "새 규칙 생성" 버튼 클릭
|
||||||
|
- 규칙명 입력
|
||||||
|
- "규칙 추가" 버튼으로 파트 추가
|
||||||
|
- 각 파트의 설정 변경
|
||||||
|
- "저장" 버튼 클릭
|
||||||
|
|
||||||
|
2. **미리보기 확인**:
|
||||||
|
- 파트 추가/수정 시 실시간으로 코드 미리보기 업데이트 확인
|
||||||
|
- 구분자 변경 시 반영 확인
|
||||||
|
|
||||||
|
3. **규칙 편집**:
|
||||||
|
- 좌측 목록에서 규칙 선택
|
||||||
|
- 우측 편집 영역에서 수정
|
||||||
|
- 저장 후 목록에 반영 확인
|
||||||
|
|
||||||
|
4. **규칙 삭제**:
|
||||||
|
- 목록 카드의 삭제 버튼 클릭
|
||||||
|
- 목록에서 제거 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 목록
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
- `db/migrations/034_create_numbering_rules.sql` (마이그레이션)
|
||||||
|
- `backend-node/src/services/numberingRuleService.ts` (서비스)
|
||||||
|
- `backend-node/src/controllers/numberingRuleController.ts` (컨트롤러)
|
||||||
|
- `backend-node/src/app.ts` (라우터 등록)
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
- `frontend/types/numbering-rule.ts` (타입 정의)
|
||||||
|
- `frontend/lib/api/numberingRule.ts` (API 클라이언트)
|
||||||
|
- `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
|
||||||
|
- `frontend/components/numbering-rule/NumberingRulePreview.tsx`
|
||||||
|
- `frontend/components/numbering-rule/NumberingRuleCard.tsx`
|
||||||
|
- `frontend/components/numbering-rule/AutoConfigPanel.tsx`
|
||||||
|
- `frontend/components/numbering-rule/ManualConfigPanel.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
1. **마이그레이션 실행**:
|
||||||
|
```sql
|
||||||
|
psql -U postgres -d ilshin -f db/migrations/034_create_numbering_rules.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **백엔드 서버 확인** (이미 실행 중이면 자동 반영)
|
||||||
|
|
||||||
|
3. **화면관리 통합**:
|
||||||
|
- 레지스트리 컴포넌트 파일 생성
|
||||||
|
- 컴포넌트 등록 및 화면 디자이너에서 사용 가능하도록 설정
|
||||||
|
|
||||||
|
4. **테스트**:
|
||||||
|
- API 테스트 (Postman)
|
||||||
|
- UI 테스트 (브라우저)
|
||||||
|
- 멀티테넌시 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성 완료**: 2025-11-04
|
||||||
|
**문의**: 백엔드 및 프론트엔드 핵심 기능 완료, 화면관리 통합만 남음
|
||||||
|
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -13,18 +13,9 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
|
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
interface DashboardListClientProps {
|
interface DashboardListClientProps {
|
||||||
|
|
@ -190,14 +181,19 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
||||||
<>
|
<>
|
||||||
{/* 검색 및 액션 */}
|
{/* 검색 및 액션 */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="flex items-center gap-4">
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Input
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
placeholder="대시보드 검색..."
|
<Input
|
||||||
value={searchTerm}
|
placeholder="대시보드 검색..."
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
value={searchTerm}
|
||||||
className="h-10 pl-10 text-sm"
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||||
|
|
@ -206,12 +202,65 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
{/* 대시보드 목록 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
|
<>
|
||||||
<div className="text-center">
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
<div className="text-sm font-medium">로딩 중...</div>
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<TableRow key={index} className="border-b">
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<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="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="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
|
@ -229,70 +278,137 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : dashboards.length === 0 ? (
|
) : dashboards.length === 0 ? (
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-card rounded-lg border shadow-sm">
|
<>
|
||||||
<Table>
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
<TableHeader>
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
<Table>
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
<TableBody>
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
|
||||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
|
||||||
{dashboard.description || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.createdAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.updatedAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
삭제
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{dashboards.map((dashboard) => (
|
||||||
</div>
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
|
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
|
{dashboard.description || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||||
|
className="gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<div
|
||||||
|
key={dashboard.id}
|
||||||
|
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">{dashboard.title}</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">설명</span>
|
||||||
|
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">수정일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 */}
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => handleCopy(dashboard)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
|
|
@ -307,26 +423,18 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
{/* 삭제 확인 모달 */}
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<DeleteConfirmModal
|
||||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
open={deleteDialogOpen}
|
||||||
<AlertDialogHeader>
|
onOpenChange={setDeleteDialogOpen}
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
title="대시보드 삭제"
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
description={
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
<>
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
</AlertDialogDescription>
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogHeader>
|
</>
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
}
|
||||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
onConfirm={handleDeleteConfirm}
|
||||||
<AlertDialogAction
|
/>
|
||||||
onClick={handleDeleteConfirm}
|
|
||||||
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
// 🆕 현재 로그인한 사용자 정보
|
// 🆕 현재 로그인한 사용자 정보
|
||||||
const { user, userName, companyCode } = useAuth();
|
const { user, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
// 🆕 모바일 환경 감지
|
// 🆕 모바일 환경 감지
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
|
||||||
|
|
@ -61,6 +61,9 @@ export default function ScreenViewPage() {
|
||||||
modalDescription?: string;
|
modalDescription?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
|
// 레이아웃 준비 완료 상태 (버튼 위치 계산 완료 후 화면 표시)
|
||||||
|
const [layoutReady, setLayoutReady] = useState(true);
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
|
@ -106,6 +109,7 @@ export default function ScreenViewPage() {
|
||||||
const loadScreen = async () => {
|
const loadScreen = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLayoutReady(false); // 화면 로드 시 레이아웃 준비 초기화
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 화면 정보 로드
|
// 화면 정보 로드
|
||||||
|
|
@ -147,6 +151,57 @@ export default function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
|
// 🆕 autoFill 자동 입력 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
const initAutoFill = async () => {
|
||||||
|
if (!layout || !layout.components || !user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const comp of layout.components) {
|
||||||
|
// type: "component" 또는 type: "widget" 모두 처리
|
||||||
|
if (comp.type === 'widget' || comp.type === 'component') {
|
||||||
|
const widget = comp as any;
|
||||||
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
|
||||||
|
// autoFill 처리
|
||||||
|
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||||||
|
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||||||
|
const currentValue = formData[fieldName];
|
||||||
|
|
||||||
|
if (currentValue === undefined || currentValue === '') {
|
||||||
|
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
||||||
|
|
||||||
|
// 사용자 정보에서 필터 값 가져오기
|
||||||
|
const userValue = user?.[userField as keyof typeof user];
|
||||||
|
|
||||||
|
if (userValue && sourceTable && filterColumn && displayColumn) {
|
||||||
|
try {
|
||||||
|
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||||
|
const result = await tableTypeApi.getTableRecord(
|
||||||
|
sourceTable,
|
||||||
|
filterColumn,
|
||||||
|
userValue,
|
||||||
|
displayColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: result.value,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAutoFill();
|
||||||
|
}, [layout, user]);
|
||||||
|
|
||||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
|
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||||
|
|
@ -174,6 +229,9 @@ export default function ScreenViewPage() {
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
// 컨테이너 너비 업데이트
|
// 컨테이너 너비 업데이트
|
||||||
setContainerWidth(containerWidth);
|
setContainerWidth(containerWidth);
|
||||||
|
|
||||||
|
// 스케일 계산 완료 후 레이아웃 준비 완료 표시
|
||||||
|
setLayoutReady(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -189,10 +247,10 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
<div className="from-muted to-muted/50 flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br">
|
||||||
<div className="rounded-xl border border-border bg-background p-8 text-center shadow-lg">
|
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
<Loader2 className="text-primary mx-auto h-10 w-10 animate-spin" />
|
||||||
<p className="mt-4 font-medium text-foreground">화면을 불러오는 중...</p>
|
<p className="text-foreground mt-4 font-medium">화면을 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -200,13 +258,13 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (error || !screen) {
|
if (error || !screen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
<div className="from-muted to-muted/50 flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br">
|
||||||
<div className="max-w-md rounded-xl border border-border bg-background p-8 text-center shadow-lg">
|
<div className="border-border bg-background max-w-md rounded-xl border p-8 text-center shadow-lg">
|
||||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-destructive/20 to-warning/20 shadow-sm">
|
<div className="from-destructive/20 to-warning/20 mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br shadow-sm">
|
||||||
<span className="text-3xl">⚠️</span>
|
<span className="text-3xl">⚠️</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-3 text-xl font-bold text-foreground">화면을 찾을 수 없습니다</h2>
|
<h2 className="text-foreground mb-3 text-xl font-bold">화면을 찾을 수 없습니다</h2>
|
||||||
<p className="mb-6 leading-relaxed text-muted-foreground">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
<p className="text-muted-foreground mb-6 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||||
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||||||
이전으로 돌아가기
|
이전으로 돌아가기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -222,10 +280,20 @@ export default function ScreenViewPage() {
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<div ref={containerRef} className="bg-background h-full w-full overflow-hidden">
|
<div ref={containerRef} className="bg-background h-full w-full overflow-hidden">
|
||||||
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
|
{!layoutReady && (
|
||||||
|
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||||
|
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||||||
|
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-foreground mt-4 text-sm font-medium">화면 준비 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 절대 위치 기반 렌더링 */}
|
{/* 절대 위치 기반 렌더링 */}
|
||||||
{layout && layout.components.length > 0 ? (
|
{layoutReady && layout && layout.components.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="bg-background relative origin-top-left h-full flex justify-start items-start"
|
className="bg-background relative flex h-full origin-top-left items-start justify-start"
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
transformOrigin: "top left",
|
transformOrigin: "top left",
|
||||||
|
|
@ -238,27 +306,76 @@ export default function ScreenViewPage() {
|
||||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||||
|
|
||||||
|
// 버튼은 scale에 맞춰 위치만 조정하면 됨 (scale = 1.0이면 그대로, scale < 1.0이면 왼쪽으로)
|
||||||
|
// 하지만 x=0 컴포넌트는 width: 100%로 확장되므로, 그만큼 버튼을 오른쪽으로 이동
|
||||||
|
const leftmostComponent = topLevelComponents.find((c) => c.position.x === 0);
|
||||||
|
let widthOffset = 0;
|
||||||
|
|
||||||
|
if (leftmostComponent && containerWidth > 0) {
|
||||||
|
const originalWidth = leftmostComponent.size?.width || screenWidth;
|
||||||
|
const actualWidth = containerWidth / scale;
|
||||||
|
widthOffset = Math.max(0, actualWidth - originalWidth);
|
||||||
|
|
||||||
|
console.log("📊 widthOffset 계산:", {
|
||||||
|
containerWidth,
|
||||||
|
scale,
|
||||||
|
screenWidth,
|
||||||
|
originalWidth,
|
||||||
|
actualWidth,
|
||||||
|
widthOffset,
|
||||||
|
leftmostType: leftmostComponent.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const buttonGroups: Record<string, any[]> = {};
|
const buttonGroups: Record<string, any[]> = {};
|
||||||
const processedButtonIds = new Set<string>();
|
const processedButtonIds = new Set<string>();
|
||||||
|
// 🔍 전체 버튼 목록 확인
|
||||||
|
const allButtons = topLevelComponents.filter((component) => {
|
||||||
|
const isButton =
|
||||||
|
(component.type === "component" &&
|
||||||
|
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||||||
|
(component.type === "widget" && (component as any).widgetType === "button");
|
||||||
|
return isButton;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"🔍 메뉴에서 발견된 전체 버튼:",
|
||||||
|
allButtons.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
label: b.label,
|
||||||
|
positionX: b.position.x,
|
||||||
|
positionY: b.position.y,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
topLevelComponents.forEach((component) => {
|
topLevelComponents.forEach((component) => {
|
||||||
const isButton =
|
const isButton =
|
||||||
component.type === "button" ||
|
|
||||||
(component.type === "component" &&
|
(component.type === "component" &&
|
||||||
["button-primary", "button-secondary"].includes((component as any).componentType));
|
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||||||
|
(component.type === "widget" && (component as any).widgetType === "button");
|
||||||
|
|
||||||
if (isButton) {
|
if (isButton) {
|
||||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||||
| FlowVisibilityConfig
|
| FlowVisibilityConfig
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
// 🔧 임시: 버튼 그룹 기능 완전 비활성화
|
||||||
|
// TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요
|
||||||
|
const DISABLE_BUTTON_GROUPS = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!DISABLE_BUTTON_GROUPS &&
|
||||||
|
flowConfig?.enabled &&
|
||||||
|
flowConfig.layoutBehavior === "auto-compact" &&
|
||||||
|
flowConfig.groupId
|
||||||
|
) {
|
||||||
if (!buttonGroups[flowConfig.groupId]) {
|
if (!buttonGroups[flowConfig.groupId]) {
|
||||||
buttonGroups[flowConfig.groupId] = [];
|
buttonGroups[flowConfig.groupId] = [];
|
||||||
}
|
}
|
||||||
buttonGroups[flowConfig.groupId].push(component);
|
buttonGroups[flowConfig.groupId].push(component);
|
||||||
processedButtonIds.add(component.id);
|
processedButtonIds.add(component.id);
|
||||||
}
|
}
|
||||||
|
// else: 모든 버튼을 개별 렌더링
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -267,92 +384,121 @@ export default function ScreenViewPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 일반 컴포넌트들 */}
|
{/* 일반 컴포넌트들 */}
|
||||||
{regularComponents.map((component) => (
|
{regularComponents.map((component) => {
|
||||||
<RealtimePreview
|
// 버튼인 경우 위치 조정 (테이블이 늘어난 만큼 오른쪽으로 이동)
|
||||||
key={component.id}
|
const isButton =
|
||||||
component={component}
|
(component.type === "component" &&
|
||||||
isSelected={false}
|
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||||||
isDesignMode={false}
|
(component.type === "widget" && (component as any).widgetType === "button");
|
||||||
onClick={() => {}}
|
|
||||||
screenId={screenId}
|
|
||||||
tableName={screen?.tableName}
|
|
||||||
userId={user?.userId}
|
|
||||||
userName={userName}
|
|
||||||
companyCode={companyCode}
|
|
||||||
selectedRowsData={selectedRowsData}
|
|
||||||
onSelectedRowsChange={(_, selectedData) => {
|
|
||||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
|
||||||
setSelectedRowsData(selectedData);
|
|
||||||
}}
|
|
||||||
flowSelectedData={flowSelectedData}
|
|
||||||
flowSelectedStepId={flowSelectedStepId}
|
|
||||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
|
||||||
setFlowSelectedData(selectedData);
|
|
||||||
setFlowSelectedStepId(stepId);
|
|
||||||
}}
|
|
||||||
refreshKey={tableRefreshKey}
|
|
||||||
onRefresh={() => {
|
|
||||||
setTableRefreshKey((prev) => prev + 1);
|
|
||||||
setSelectedRowsData([]); // 선택 해제
|
|
||||||
}}
|
|
||||||
flowRefreshKey={flowRefreshKey}
|
|
||||||
onFlowRefresh={() => {
|
|
||||||
setFlowRefreshKey((prev) => prev + 1);
|
|
||||||
setFlowSelectedData([]); // 선택 해제
|
|
||||||
setFlowSelectedStepId(null);
|
|
||||||
}}
|
|
||||||
formData={formData}
|
|
||||||
onFormDataChange={(fieldName, value) => {
|
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 자식 컴포넌트들 */}
|
|
||||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
|
||||||
layout.components
|
|
||||||
.filter((child) => child.parentId === component.id)
|
|
||||||
.map((child) => {
|
|
||||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
|
||||||
const relativeChildComponent = {
|
|
||||||
...child,
|
|
||||||
position: {
|
|
||||||
x: child.position.x - component.position.x,
|
|
||||||
y: child.position.y - component.position.y,
|
|
||||||
z: child.position.z || 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const adjustedComponent =
|
||||||
<RealtimePreview
|
isButton && widthOffset > 0
|
||||||
key={child.id}
|
? {
|
||||||
component={relativeChildComponent}
|
...component,
|
||||||
isSelected={false}
|
position: {
|
||||||
isDesignMode={false}
|
...component.position,
|
||||||
onClick={() => {}}
|
x: component.position.x + widthOffset,
|
||||||
screenId={screenId}
|
},
|
||||||
tableName={screen?.tableName}
|
}
|
||||||
userId={user?.userId}
|
: component;
|
||||||
userName={userName}
|
|
||||||
companyCode={companyCode}
|
// 버튼일 경우 로그 출력
|
||||||
selectedRowsData={selectedRowsData}
|
if (isButton) {
|
||||||
onSelectedRowsChange={(_, selectedData) => {
|
console.log("🔘 버튼 위치 조정:", {
|
||||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
label: component.label,
|
||||||
setSelectedRowsData(selectedData);
|
originalX: component.position.x,
|
||||||
}}
|
adjustedX: component.position.x + widthOffset,
|
||||||
refreshKey={tableRefreshKey}
|
widthOffset,
|
||||||
onRefresh={() => {
|
});
|
||||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
}
|
||||||
setTableRefreshKey((prev) => prev + 1);
|
|
||||||
setSelectedRowsData([]); // 선택 해제
|
return (
|
||||||
}}
|
<RealtimePreview
|
||||||
formData={formData}
|
key={component.id}
|
||||||
onFormDataChange={(fieldName, value) => {
|
component={adjustedComponent}
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
isSelected={false}
|
||||||
}}
|
isDesignMode={false}
|
||||||
/>
|
onClick={() => {}}
|
||||||
);
|
screenId={screenId}
|
||||||
})}
|
tableName={screen?.tableName}
|
||||||
</RealtimePreview>
|
userId={user?.userId}
|
||||||
))}
|
userName={userName}
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedRowsData={selectedRowsData}
|
||||||
|
onSelectedRowsChange={(_, selectedData) => {
|
||||||
|
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||||
|
setSelectedRowsData(selectedData);
|
||||||
|
}}
|
||||||
|
flowSelectedData={flowSelectedData}
|
||||||
|
flowSelectedStepId={flowSelectedStepId}
|
||||||
|
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||||
|
setFlowSelectedData(selectedData);
|
||||||
|
setFlowSelectedStepId(stepId);
|
||||||
|
}}
|
||||||
|
refreshKey={tableRefreshKey}
|
||||||
|
onRefresh={() => {
|
||||||
|
setTableRefreshKey((prev) => prev + 1);
|
||||||
|
setSelectedRowsData([]); // 선택 해제
|
||||||
|
}}
|
||||||
|
flowRefreshKey={flowRefreshKey}
|
||||||
|
onFlowRefresh={() => {
|
||||||
|
setFlowRefreshKey((prev) => prev + 1);
|
||||||
|
setFlowSelectedData([]); // 선택 해제
|
||||||
|
setFlowSelectedStepId(null);
|
||||||
|
}}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 자식 컴포넌트들 */}
|
||||||
|
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||||
|
layout.components
|
||||||
|
.filter((child) => child.parentId === component.id)
|
||||||
|
.map((child) => {
|
||||||
|
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||||
|
const relativeChildComponent = {
|
||||||
|
...child,
|
||||||
|
position: {
|
||||||
|
x: child.position.x - component.position.x,
|
||||||
|
y: child.position.y - component.position.y,
|
||||||
|
z: child.position.z || 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RealtimePreview
|
||||||
|
key={child.id}
|
||||||
|
component={relativeChildComponent}
|
||||||
|
isSelected={false}
|
||||||
|
isDesignMode={false}
|
||||||
|
onClick={() => {}}
|
||||||
|
screenId={screenId}
|
||||||
|
tableName={screen?.tableName}
|
||||||
|
userId={user?.userId}
|
||||||
|
userName={userName}
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedRowsData={selectedRowsData}
|
||||||
|
onSelectedRowsChange={(_, selectedData) => {
|
||||||
|
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||||
|
setSelectedRowsData(selectedData);
|
||||||
|
}}
|
||||||
|
refreshKey={tableRefreshKey}
|
||||||
|
onRefresh={() => {
|
||||||
|
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||||
|
setTableRefreshKey((prev) => prev + 1);
|
||||||
|
setSelectedRowsData([]); // 선택 해제
|
||||||
|
}}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RealtimePreview>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* 🆕 플로우 버튼 그룹들 */}
|
{/* 🆕 플로우 버튼 그룹들 */}
|
||||||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||||||
|
|
@ -362,15 +508,37 @@ export default function ScreenViewPage() {
|
||||||
const groupConfig = (firstButton as any).webTypeConfig
|
const groupConfig = (firstButton as any).webTypeConfig
|
||||||
?.flowVisibilityConfig as FlowVisibilityConfig;
|
?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||||
|
|
||||||
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
// 🔍 버튼 그룹 설정 확인
|
||||||
const groupPosition = buttons.reduce(
|
console.log("🔍 버튼 그룹 설정:", {
|
||||||
(min, button) => ({
|
groupId,
|
||||||
x: Math.min(min.x, button.position.x),
|
buttonCount: buttons.length,
|
||||||
y: Math.min(min.y, button.position.y),
|
buttons: buttons.map((b) => ({
|
||||||
z: min.z,
|
id: b.id,
|
||||||
}),
|
label: b.label,
|
||||||
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
|
x: b.position.x,
|
||||||
);
|
y: b.position.y,
|
||||||
|
})),
|
||||||
|
groupConfig: {
|
||||||
|
layoutBehavior: groupConfig.layoutBehavior,
|
||||||
|
groupDirection: groupConfig.groupDirection,
|
||||||
|
groupAlign: groupConfig.groupAlign,
|
||||||
|
groupGap: groupConfig.groupGap,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되,
|
||||||
|
// 각 버튼의 상대 위치는 원래 위치를 유지
|
||||||
|
const firstButtonPosition = {
|
||||||
|
x: buttons[0].position.x,
|
||||||
|
y: buttons[0].position.y,
|
||||||
|
z: buttons[0].position.z || 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 버튼 그룹 위치에도 widthOffset 적용
|
||||||
|
const adjustedGroupPosition = {
|
||||||
|
...firstButtonPosition,
|
||||||
|
x: firstButtonPosition.x + widthOffset,
|
||||||
|
};
|
||||||
|
|
||||||
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||||
const direction = groupConfig.groupDirection || "horizontal";
|
const direction = groupConfig.groupDirection || "horizontal";
|
||||||
|
|
@ -400,9 +568,9 @@ export default function ScreenViewPage() {
|
||||||
key={`flow-button-group-${groupId}`}
|
key={`flow-button-group-${groupId}`}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: `${groupPosition.x}px`,
|
left: `${adjustedGroupPosition.x}px`,
|
||||||
top: `${groupPosition.y}px`,
|
top: `${adjustedGroupPosition.y}px`,
|
||||||
zIndex: groupPosition.z,
|
zIndex: adjustedGroupPosition.z,
|
||||||
width: `${groupWidth}px`,
|
width: `${groupWidth}px`,
|
||||||
height: `${groupHeight}px`,
|
height: `${groupHeight}px`,
|
||||||
}}
|
}}
|
||||||
|
|
@ -412,9 +580,14 @@ export default function ScreenViewPage() {
|
||||||
groupConfig={groupConfig}
|
groupConfig={groupConfig}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
renderButton={(button) => {
|
renderButton={(button) => {
|
||||||
|
// 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치
|
||||||
const relativeButton = {
|
const relativeButton = {
|
||||||
...button,
|
...button,
|
||||||
position: { x: 0, y: 0, z: button.position.z || 1 },
|
position: {
|
||||||
|
x: button.position.x - firstButtonPosition.x,
|
||||||
|
y: button.position.y - firstButtonPosition.y,
|
||||||
|
z: button.position.z || 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
|
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
|
||||||
|
|
||||||
interface CompanyFormModalProps {
|
interface CompanyFormModalProps {
|
||||||
modalState: CompanyModalState;
|
modalState: CompanyModalState;
|
||||||
|
|
@ -29,6 +30,7 @@ export function CompanyFormModal({
|
||||||
onClearError,
|
onClearError,
|
||||||
}: CompanyFormModalProps) {
|
}: CompanyFormModalProps) {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [businessNumberError, setBusinessNumberError] = useState<string>("");
|
||||||
|
|
||||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||||
if (!modalState.isOpen) return null;
|
if (!modalState.isOpen) return null;
|
||||||
|
|
@ -36,15 +38,43 @@ export function CompanyFormModal({
|
||||||
const { mode, formData, selectedCompany } = modalState;
|
const { mode, formData, selectedCompany } = modalState;
|
||||||
const isEditMode = mode === "edit";
|
const isEditMode = mode === "edit";
|
||||||
|
|
||||||
|
// 사업자등록번호 변경 처리
|
||||||
|
const handleBusinessNumberChange = (value: string) => {
|
||||||
|
// 자동 포맷팅
|
||||||
|
const formatted = formatBusinessNumber(value);
|
||||||
|
onFormChange("business_registration_number", formatted);
|
||||||
|
|
||||||
|
// 유효성 검사 (10자리가 다 입력되었을 때만)
|
||||||
|
const cleaned = formatted.replace(/-/g, "");
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
const validation = validateBusinessNumber(formatted);
|
||||||
|
setBusinessNumberError(validation.isValid ? "" : validation.message);
|
||||||
|
} else if (cleaned.length < 10 && businessNumberError) {
|
||||||
|
// 10자리 미만이면 에러 초기화
|
||||||
|
setBusinessNumberError("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 저장 처리
|
// 저장 처리
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// 입력값 검증
|
// 입력값 검증 (필수 필드)
|
||||||
if (!formData.company_name.trim()) {
|
if (!formData.company_name.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!formData.business_registration_number.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사업자등록번호 최종 검증
|
||||||
|
const validation = validateBusinessNumber(formData.business_registration_number);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
setBusinessNumberError(validation.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
onClearError();
|
onClearError();
|
||||||
|
setBusinessNumberError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await onSave();
|
const success = await onSave();
|
||||||
|
|
@ -81,7 +111,7 @@ export function CompanyFormModal({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
{/* 회사명 입력 */}
|
{/* 회사명 입력 (필수) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="company_name">
|
<Label htmlFor="company_name">
|
||||||
회사명 <span className="text-destructive">*</span>
|
회사명 <span className="text-destructive">*</span>
|
||||||
|
|
@ -97,10 +127,94 @@ export function CompanyFormModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 사업자등록번호 입력 (필수) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="business_registration_number">
|
||||||
|
사업자등록번호 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="business_registration_number"
|
||||||
|
value={formData.business_registration_number || ""}
|
||||||
|
onChange={(e) => handleBusinessNumberChange(e.target.value)}
|
||||||
|
placeholder="000-00-00000"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
maxLength={12}
|
||||||
|
className={businessNumberError ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{businessNumberError ? (
|
||||||
|
<p className="text-xs text-destructive">{businessNumberError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">10자리 숫자 (자동 하이픈 추가)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대표자명 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="representative_name">대표자명</Label>
|
||||||
|
<Input
|
||||||
|
id="representative_name"
|
||||||
|
value={formData.representative_name || ""}
|
||||||
|
onChange={(e) => onFormChange("representative_name", e.target.value)}
|
||||||
|
placeholder="대표자명을 입력하세요"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대표 연락처 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="representative_phone">대표 연락처</Label>
|
||||||
|
<Input
|
||||||
|
id="representative_phone"
|
||||||
|
value={formData.representative_phone || ""}
|
||||||
|
onChange={(e) => onFormChange("representative_phone", e.target.value)}
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이메일 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">이메일</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
value={formData.email || ""}
|
||||||
|
onChange={(e) => onFormChange("email", e.target.value)}
|
||||||
|
placeholder="company@example.com"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 웹사이트 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="website">웹사이트</Label>
|
||||||
|
<Input
|
||||||
|
id="website"
|
||||||
|
value={formData.website || ""}
|
||||||
|
onChange={(e) => onFormChange("website", e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 주소 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="address">회사 주소</Label>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
value={formData.address || ""}
|
||||||
|
onChange={(e) => onFormChange("address", e.target.value)}
|
||||||
|
placeholder="서울특별시 강남구..."
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-destructive/10 rounded-md p-3">
|
<div className="rounded-md bg-destructive/10 p-3">
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -129,7 +243,13 @@ export function CompanyFormModal({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading || isSaving || !formData.company_name.trim()}
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
isSaving ||
|
||||||
|
!formData.company_name.trim() ||
|
||||||
|
!formData.business_registration_number.trim() ||
|
||||||
|
!!businessNumberError
|
||||||
|
}
|
||||||
className="min-w-[80px]"
|
className="min-w-[80px]"
|
||||||
>
|
>
|
||||||
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Edit, Trash2, HardDrive, FileText } from "lucide-react";
|
import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react";
|
||||||
import { Company } from "@/types/company";
|
import { Company } from "@/types/company";
|
||||||
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface CompanyTableProps {
|
interface CompanyTableProps {
|
||||||
companies: Company[];
|
companies: Company[];
|
||||||
|
|
@ -17,11 +18,18 @@ interface CompanyTableProps {
|
||||||
* 모바일/태블릿: 카드 뷰
|
* 모바일/태블릿: 카드 뷰
|
||||||
*/
|
*/
|
||||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 부서 관리 페이지로 이동
|
||||||
|
const handleManageDepartments = (company: Company) => {
|
||||||
|
router.push(`/admin/company/${company.company_code}/departments`);
|
||||||
|
};
|
||||||
|
|
||||||
// 디스크 사용량 포맷팅 함수
|
// 디스크 사용량 포맷팅 함수
|
||||||
const formatDiskUsage = (company: Company) => {
|
const formatDiskUsage = (company: Company) => {
|
||||||
if (!company.diskUsage) {
|
if (!company.diskUsage) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
<HardDrive className="h-3 w-3" />
|
<HardDrive className="h-3 w-3" />
|
||||||
<span className="text-xs">정보 없음</span>
|
<span className="text-xs">정보 없음</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -33,11 +41,11 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FileText className="h-3 w-3 text-primary" />
|
<FileText className="text-primary h-3 w-3" />
|
||||||
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<HardDrive className="h-3 w-3 text-primary" />
|
<HardDrive className="text-primary h-3 w-3" />
|
||||||
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
|
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,7 +57,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
<div className="hidden bg-card shadow-sm lg:block">
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -66,21 +74,21 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||||
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -92,18 +100,18 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
|
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||||
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="flex justify-between">
|
<div key={i} className="flex justify-between">
|
||||||
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||||
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,9 +125,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
// 데이터가 없을 때
|
// 데이터가 없을 때
|
||||||
if (companies.length === 0) {
|
if (companies.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 flex-col items-center justify-center bg-card shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">등록된 회사가 없습니다.</p>
|
<p className="text-muted-foreground text-sm">등록된 회사가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -129,28 +137,40 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
<div className="hidden bg-card lg:block">
|
<div className="bg-card hidden lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{COMPANY_TABLE_COLUMNS.map((column) => (
|
{COMPANY_TABLE_COLUMNS.map((column) => (
|
||||||
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
||||||
{column.label}
|
{column.label}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">디스크 사용량</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">디스크 사용량</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<TableRow key={company.regdate + company.company_code} className="bg-background transition-colors hover:bg-muted/50">
|
<TableRow
|
||||||
|
key={company.regdate + company.company_code}
|
||||||
|
className="bg-background hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{company.company_code}</TableCell>
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{company.company_code}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm font-medium">{company.company_name}</TableCell>
|
<TableCell className="h-16 px-6 py-3 text-sm font-medium">{company.company_name}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">{company.writer}</TableCell>
|
<TableCell className="h-16 px-6 py-3 text-sm">{company.writer}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3">
|
<TableCell className="h-16 px-6 py-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleManageDepartments(company)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="부서관리"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -164,7 +184,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onDelete(company)}
|
onClick={() => onDelete(company)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
aria-label="삭제"
|
aria-label="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|
@ -182,13 +202,13 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<div
|
<div
|
||||||
key={company.regdate + company.company_code}
|
key={company.regdate + company.company_code}
|
||||||
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
|
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-base font-semibold">{company.company_name}</h3>
|
<h3 className="text-base font-semibold">{company.company_name}</h3>
|
||||||
<p className="mt-1 font-mono text-sm text-muted-foreground">{company.company_code}</p>
|
<p className="text-muted-foreground mt-1 font-mono text-sm">{company.company_code}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -209,9 +229,13 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onEdit(company)}
|
onClick={() => handleManageDepartments(company)}
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
>
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
부서
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onEdit(company)} className="h-9 flex-1 gap-2 text-sm">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
수정
|
수정
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -219,7 +243,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onDelete(company)}
|
onClick={() => onDelete(company)}
|
||||||
className="h-9 flex-1 gap-2 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
|
|
|
||||||
|
|
@ -903,11 +903,6 @@ export function CanvasElement({
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
<ChartTestWidget element={element} />
|
<ChartTestWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "list-v2" ? (
|
|
||||||
// 리스트 위젯 (다중 데이터 소스) - 승격 완료
|
|
||||||
<div className="widget-interactive-area h-full w-full">
|
|
||||||
<ListTestWidget element={element} />
|
|
||||||
</div>
|
|
||||||
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? (
|
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? (
|
||||||
// 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
|
// 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
|
|
@ -1014,8 +1009,8 @@ export function CanvasElement({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "list" ? (
|
) : element.type === "widget" && (element.subtype === "list" || element.subtype === "list-v2") ? (
|
||||||
// 리스트 위젯 렌더링 (구버전)
|
// 리스트 위젯 렌더링 (v1 & v2)
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<ListWidget element={element} />
|
<ListWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,10 @@ import React, { useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardCanvas } from "./DashboardCanvas";
|
import { DashboardCanvas } from "./DashboardCanvas";
|
||||||
import { DashboardTopMenu } from "./DashboardTopMenu";
|
import { DashboardTopMenu } from "./DashboardTopMenu";
|
||||||
import { ElementConfigSidebar } from "./ElementConfigSidebar";
|
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
|
||||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||||
import {
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
|
||||||
GRID_CONFIG,
|
|
||||||
snapToGrid,
|
|
||||||
snapSizeToGrid,
|
|
||||||
calculateCellSize,
|
|
||||||
calculateGridConfig,
|
|
||||||
calculateBoxSize,
|
|
||||||
} from "./gridUtils";
|
|
||||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
|
|
@ -147,12 +140,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
// 대시보드 ID가 props로 전달되면 로드
|
// 대시보드 ID가 props로 전달되면 로드
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (initialDashboardId) {
|
if (initialDashboardId) {
|
||||||
console.log("📝 기존 대시보드 편집 모드");
|
|
||||||
loadDashboard(initialDashboardId);
|
loadDashboard(initialDashboardId);
|
||||||
} else {
|
|
||||||
console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution);
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [initialDashboardId]);
|
}, [initialDashboardId]);
|
||||||
|
|
||||||
// 대시보드 데이터 로드
|
// 대시보드 데이터 로드
|
||||||
|
|
@ -162,35 +151,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const dashboard = await dashboardApi.getDashboard(id);
|
const dashboard = await dashboardApi.getDashboard(id);
|
||||||
|
|
||||||
console.log("📊 대시보드 로드:", {
|
|
||||||
id: dashboard.id,
|
|
||||||
title: dashboard.title,
|
|
||||||
settings: dashboard.settings,
|
|
||||||
settingsType: typeof dashboard.settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 대시보드 정보 설정
|
// 대시보드 정보 설정
|
||||||
setDashboardId(dashboard.id);
|
setDashboardId(dashboard.id);
|
||||||
setDashboardTitle(dashboard.title);
|
setDashboardTitle(dashboard.title);
|
||||||
|
|
||||||
// 저장된 설정 복원
|
// 저장된 설정 복원
|
||||||
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
|
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
|
||||||
console.log("🎨 설정 복원:", {
|
|
||||||
settings,
|
|
||||||
resolution: settings?.resolution,
|
|
||||||
backgroundColor: settings?.backgroundColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 배경색 설정
|
// 배경색 설정
|
||||||
if (settings?.backgroundColor) {
|
if (settings?.backgroundColor) {
|
||||||
setCanvasBackgroundColor(settings.backgroundColor);
|
setCanvasBackgroundColor(settings.backgroundColor);
|
||||||
console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
|
// 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
|
||||||
const loadedResolution = settings?.resolution || "fhd";
|
const loadedResolution = settings?.resolution || "fhd";
|
||||||
setResolution(loadedResolution);
|
setResolution(loadedResolution);
|
||||||
console.log("✅ Resolution 설정됨:", loadedResolution);
|
|
||||||
|
|
||||||
// 요소들 설정
|
// 요소들 설정
|
||||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||||
|
|
@ -199,7 +174,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
...el,
|
...el,
|
||||||
dataSources: el.chartConfig?.dataSources || el.dataSources,
|
dataSources: el.chartConfig?.dataSources || el.dataSources,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setElements(elementsWithDataSources);
|
setElements(elementsWithDataSources);
|
||||||
|
|
||||||
// elementCounter를 가장 큰 ID 번호로 설정
|
// elementCounter를 가장 큰 ID 번호로 설정
|
||||||
|
|
@ -228,7 +203,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
||||||
// 좌표 유효성 검사
|
// 좌표 유효성 검사
|
||||||
if (isNaN(x) || isNaN(y)) {
|
if (isNaN(x) || isNaN(y)) {
|
||||||
// console.error("Invalid coordinates:", { x, y });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,14 +227,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
|
|
||||||
// 크기 유효성 검사
|
// 크기 유효성 검사
|
||||||
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
||||||
// console.error("Invalid size calculated:", {
|
|
||||||
// canvasConfig,
|
|
||||||
// cellSize,
|
|
||||||
// cellWithGap,
|
|
||||||
// defaultCells,
|
|
||||||
// defaultWidth,
|
|
||||||
// defaultHeight,
|
|
||||||
// });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,7 +260,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
|
|
||||||
// 좌표 유효성 확인
|
// 좌표 유효성 확인
|
||||||
if (isNaN(centerX) || isNaN(centerY)) {
|
if (isNaN(centerX) || isNaN(centerY)) {
|
||||||
// console.error("Invalid canvas config:", canvasConfig);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,7 +270,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
|
|
||||||
// 요소 업데이트
|
// 요소 업데이트
|
||||||
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
||||||
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
|
setElements((prev) =>
|
||||||
|
prev.map((el) => {
|
||||||
|
if (el.id === id) {
|
||||||
|
return { ...el, ...updates };
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 요소 삭제
|
// 요소 삭제
|
||||||
|
|
@ -382,16 +354,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
setClearConfirmOpen(false);
|
setClearConfirmOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 리스트/야드 위젯 설정 저장 (Partial 업데이트)
|
|
||||||
const saveWidgetConfig = useCallback(
|
|
||||||
(updates: Partial<DashboardElement>) => {
|
|
||||||
if (sidebarElement) {
|
|
||||||
updateElement(sidebarElement.id, updates);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sidebarElement, updateElement],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사이드바 닫기
|
// 사이드바 닫기
|
||||||
const handleCloseSidebar = useCallback(() => {
|
const handleCloseSidebar = useCallback(() => {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
|
|
@ -404,14 +366,17 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
(updatedElement: DashboardElement) => {
|
(updatedElement: DashboardElement) => {
|
||||||
// 현재 요소의 최신 상태를 가져와서 position과 size는 유지
|
// 현재 요소의 최신 상태를 가져와서 position과 size는 유지
|
||||||
const currentElement = elements.find((el) => el.id === updatedElement.id);
|
const currentElement = elements.find((el) => el.id === updatedElement.id);
|
||||||
|
|
||||||
if (currentElement) {
|
if (currentElement) {
|
||||||
// position과 size는 현재 상태 유지, 나머지만 업데이트
|
// id, position, size 제거 후 나머지만 업데이트
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { id, position, size, ...updates } = updatedElement;
|
||||||
const finalElement = {
|
const finalElement = {
|
||||||
...updatedElement,
|
...currentElement,
|
||||||
position: currentElement.position,
|
...updates,
|
||||||
size: currentElement.size,
|
|
||||||
};
|
};
|
||||||
updateElement(finalElement.id, finalElement);
|
|
||||||
|
updateElement(id, updates);
|
||||||
// 사이드바도 최신 상태로 업데이트
|
// 사이드바도 최신 상태로 업데이트
|
||||||
setSidebarElement(finalElement);
|
setSidebarElement(finalElement);
|
||||||
}
|
}
|
||||||
|
|
@ -443,14 +408,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
|
||||||
const elementsData = elements.map((el) => {
|
const elementsData = elements.map((el) => {
|
||||||
// 야드 위젯인 경우 설정 로그 출력
|
|
||||||
// if (el.subtype === "yard-management-3d") {
|
|
||||||
// console.log("💾 야드 위젯 저장:", {
|
|
||||||
// id: el.id,
|
|
||||||
// yardConfig: el.yardConfig,
|
|
||||||
// hasLayoutId: !!el.yardConfig?.layoutId,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
return {
|
return {
|
||||||
id: el.id,
|
id: el.id,
|
||||||
type: el.type,
|
type: el.type,
|
||||||
|
|
@ -494,12 +451,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("💾 대시보드 업데이트 요청:", {
|
|
||||||
dashboardId,
|
|
||||||
updateData,
|
|
||||||
elementsCount: elementsData.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
|
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
|
||||||
} else {
|
} else {
|
||||||
// 새 대시보드 생성
|
// 새 대시보드 생성
|
||||||
|
|
@ -560,18 +511,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
// 성공 모달 표시
|
// 성공 모달 표시
|
||||||
setSuccessModalOpen(true);
|
setSuccessModalOpen(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 대시보드 저장 실패:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||||
|
|
||||||
// 상세한 에러 정보 로깅
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error("Error details:", {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
name: error.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
|
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -582,11 +522,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
// 로딩 중이면 로딩 화면 표시
|
// 로딩 중이면 로딩 화면 표시
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-muted">
|
<div className="bg-muted flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||||
<div className="text-lg font-medium text-foreground">대시보드 로딩 중...</div>
|
<div className="text-foreground text-lg font-medium">대시보드 로딩 중...</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">잠시만 기다려주세요</div>
|
<div className="text-muted-foreground mt-1 text-sm">잠시만 기다려주세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -594,7 +534,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
<div className="flex h-full flex-col bg-muted">
|
<div className="bg-muted flex h-full flex-col">
|
||||||
{/* 상단 메뉴바 */}
|
{/* 상단 메뉴바 */}
|
||||||
<DashboardTopMenu
|
<DashboardTopMenu
|
||||||
onSaveLayout={saveLayout}
|
onSaveLayout={saveLayout}
|
||||||
|
|
@ -610,7 +550,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
|
|
||||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||||
<div className="dashboard-canvas-container flex flex-1 items-start justify-center bg-muted p-8">
|
<div className="dashboard-canvas-container bg-muted flex flex-1 items-start justify-center p-8">
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -651,8 +591,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */}
|
{/* 요소 설정 사이드바 (통합) */}
|
||||||
<ElementConfigSidebar
|
<WidgetConfigSidebar
|
||||||
element={sidebarElement}
|
element={sidebarElement}
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
onClose={handleCloseSidebar}
|
onClose={handleCloseSidebar}
|
||||||
|
|
@ -679,8 +619,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
|
<div className="bg-success/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<CheckCircle2 className="h-6 w-6 text-success" />
|
<CheckCircle2 className="text-success h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||||
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
||||||
|
|
@ -761,13 +701,13 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
||||||
return "달력 위젯";
|
return "달력 위젯";
|
||||||
case "driver-management":
|
case "driver-management":
|
||||||
return "기사 관리 위젯";
|
return "기사 관리 위젯";
|
||||||
case "list":
|
case "list-v2":
|
||||||
return "리스트 위젯";
|
return "리스트 위젯";
|
||||||
case "map-summary":
|
case "map-summary-v2":
|
||||||
return "커스텀 지도 카드";
|
return "커스텀 지도 카드";
|
||||||
case "status-summary":
|
case "status-summary":
|
||||||
return "커스텀 상태 카드";
|
return "커스텀 상태 카드";
|
||||||
case "risk-alert":
|
case "risk-alert-v2":
|
||||||
return "리스크 알림 위젯";
|
return "리스크 알림 위젯";
|
||||||
case "todo":
|
case "todo":
|
||||||
return "할 일 위젯";
|
return "할 일 위젯";
|
||||||
|
|
@ -821,7 +761,7 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
||||||
return "calendar";
|
return "calendar";
|
||||||
case "driver-management":
|
case "driver-management":
|
||||||
return "driver-management";
|
return "driver-management";
|
||||||
case "list":
|
case "list-v2":
|
||||||
return "list-widget";
|
return "list-widget";
|
||||||
case "yard-management-3d":
|
case "yard-management-3d":
|
||||||
return "yard-3d";
|
return "yard-3d";
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,9 @@ export function DashboardTopMenu({
|
||||||
dataUrl: string,
|
dataUrl: string,
|
||||||
format: "png" | "pdf",
|
format: "png" | "pdf",
|
||||||
canvasWidth: number,
|
canvasWidth: number,
|
||||||
canvasHeight: number
|
canvasHeight: number,
|
||||||
) => {
|
) => {
|
||||||
if (format === "png") {
|
if (format === "png") {
|
||||||
console.log("💾 PNG 다운로드 시작...");
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
|
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
|
|
@ -89,11 +88,9 @@ export function DashboardTopMenu({
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
console.log("✅ PNG 다운로드 완료:", filename);
|
|
||||||
} else {
|
} else {
|
||||||
console.log("📄 PDF 생성 중...");
|
|
||||||
const jsPDF = (await import("jspdf")).default;
|
const jsPDF = (await import("jspdf")).default;
|
||||||
|
|
||||||
// dataUrl에서 이미지 크기 계산
|
// dataUrl에서 이미지 크기 계산
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
|
|
@ -101,17 +98,12 @@ export function DashboardTopMenu({
|
||||||
img.onload = resolve;
|
img.onload = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height });
|
|
||||||
console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight });
|
|
||||||
|
|
||||||
// PDF 크기 계산 (A4 기준)
|
// PDF 크기 계산 (A4 기준)
|
||||||
const imgWidth = 210; // A4 width in mm
|
const imgWidth = 210; // A4 width in mm
|
||||||
const actualHeight = canvasHeight;
|
const actualHeight = canvasHeight;
|
||||||
const actualWidth = canvasWidth;
|
const actualWidth = canvasWidth;
|
||||||
const imgHeight = (actualHeight * imgWidth) / actualWidth;
|
const imgHeight = (actualHeight * imgWidth) / actualWidth;
|
||||||
|
|
||||||
console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight });
|
|
||||||
|
|
||||||
const pdf = new jsPDF({
|
const pdf = new jsPDF({
|
||||||
orientation: imgHeight > imgWidth ? "portrait" : "landscape",
|
orientation: imgHeight > imgWidth ? "portrait" : "landscape",
|
||||||
unit: "mm",
|
unit: "mm",
|
||||||
|
|
@ -121,53 +113,44 @@ export function DashboardTopMenu({
|
||||||
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
||||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||||
pdf.save(filename);
|
pdf.save(filename);
|
||||||
console.log("✅ PDF 다운로드 완료:", filename);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (format: "png" | "pdf") => {
|
const handleDownload = async (format: "png" | "pdf") => {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 다운로드 시작:", format);
|
|
||||||
|
|
||||||
// 실제 위젯들이 있는 캔버스 찾기
|
// 실제 위젯들이 있는 캔버스 찾기
|
||||||
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
|
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
|
||||||
console.log("🔍 캔버스 찾기:", canvas);
|
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📸 html-to-image 로딩 중...");
|
|
||||||
// html-to-image 동적 import
|
// html-to-image 동적 import
|
||||||
const { toPng, toJpeg } = await import("html-to-image");
|
// @ts-expect-error - 동적 import
|
||||||
|
const { toPng } = await import("html-to-image");
|
||||||
|
|
||||||
console.log("📸 캔버스 캡처 중...");
|
|
||||||
|
|
||||||
// 3D/WebGL 렌더링 완료 대기
|
// 3D/WebGL 렌더링 완료 대기
|
||||||
console.log("⏳ 3D 렌더링 완료 대기 중...");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
||||||
console.log("🎨 WebGL 캔버스 처리 중...");
|
|
||||||
const webglCanvases = canvas.querySelectorAll("canvas");
|
const webglCanvases = canvas.querySelectorAll("canvas");
|
||||||
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
|
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
|
||||||
|
|
||||||
webglCanvases.forEach((webglCanvas) => {
|
webglCanvases.forEach((webglCanvas) => {
|
||||||
try {
|
try {
|
||||||
const rect = webglCanvas.getBoundingClientRect();
|
const rect = webglCanvas.getBoundingClientRect();
|
||||||
const dataUrl = webglCanvas.toDataURL("image/png");
|
const dataUrl = webglCanvas.toDataURL("image/png");
|
||||||
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
|
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
|
||||||
console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height });
|
} catch {
|
||||||
} catch (error) {
|
// WebGL 캔버스 캡처 실패 시 무시
|
||||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 캔버스의 실제 크기와 위치 가져오기
|
// 캔버스의 실제 크기와 위치 가져오기
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const canvasWidth = canvas.scrollWidth;
|
const canvasWidth = canvas.scrollWidth;
|
||||||
|
|
||||||
// 실제 콘텐츠의 최하단 위치 계산
|
// 실제 콘텐츠의 최하단 위치 계산
|
||||||
const children = canvas.querySelectorAll(".canvas-element");
|
const children = canvas.querySelectorAll(".canvas-element");
|
||||||
let maxBottom = 0;
|
let maxBottom = 0;
|
||||||
|
|
@ -178,17 +161,9 @@ export function DashboardTopMenu({
|
||||||
maxBottom = relativeBottom;
|
maxBottom = relativeBottom;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
||||||
const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight;
|
const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight;
|
||||||
|
|
||||||
console.log("📐 캔버스 정보:", {
|
|
||||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
||||||
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
|
||||||
calculated: { width: canvasWidth, height: canvasHeight },
|
|
||||||
maxBottom: maxBottom,
|
|
||||||
webglCount: webglImages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
||||||
const getDefaultBackgroundColor = () => {
|
const getDefaultBackgroundColor = () => {
|
||||||
|
|
@ -204,8 +179,8 @@ export function DashboardTopMenu({
|
||||||
pixelRatio: 2, // 고해상도
|
pixelRatio: 2, // 고해상도
|
||||||
cacheBust: true,
|
cacheBust: true,
|
||||||
skipFonts: false,
|
skipFonts: false,
|
||||||
preferredFontFormat: 'woff2',
|
preferredFontFormat: "woff2",
|
||||||
filter: (node) => {
|
filter: (node: Node) => {
|
||||||
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
||||||
if (node instanceof HTMLCanvasElement) {
|
if (node instanceof HTMLCanvasElement) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -213,26 +188,25 @@ export function DashboardTopMenu({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebGL 캔버스를 이미지 위에 합성
|
// WebGL 캔버스를 이미지 위에 합성
|
||||||
if (webglImages.length > 0) {
|
if (webglImages.length > 0) {
|
||||||
console.log("🖼️ WebGL 이미지 합성 중...");
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
img.onload = resolve;
|
img.onload = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 새 캔버스에 합성
|
// 새 캔버스에 합성
|
||||||
const compositeCanvas = document.createElement("canvas");
|
const compositeCanvas = document.createElement("canvas");
|
||||||
compositeCanvas.width = img.width;
|
compositeCanvas.width = img.width;
|
||||||
compositeCanvas.height = img.height;
|
compositeCanvas.height = img.height;
|
||||||
const ctx = compositeCanvas.getContext("2d");
|
const ctx = compositeCanvas.getContext("2d");
|
||||||
|
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// 기본 이미지 그리기
|
// 기본 이미지 그리기
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
// WebGL 이미지들을 위치에 맞게 그리기
|
// WebGL 이미지들을 위치에 맞게 그리기
|
||||||
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
|
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
|
||||||
const webglImg = new Image();
|
const webglImg = new Image();
|
||||||
|
|
@ -240,50 +214,45 @@ export function DashboardTopMenu({
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
webglImg.onload = resolve;
|
webglImg.onload = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 상대 위치 계산 (pixelRatio 2 고려)
|
// 상대 위치 계산 (pixelRatio 2 고려)
|
||||||
const relativeX = (webglRect.left - rect.left) * 2;
|
const relativeX = (webglRect.left - rect.left) * 2;
|
||||||
const relativeY = (webglRect.top - rect.top) * 2;
|
const relativeY = (webglRect.top - rect.top) * 2;
|
||||||
const width = webglRect.width * 2;
|
const width = webglRect.width * 2;
|
||||||
const height = webglRect.height * 2;
|
const height = webglRect.height * 2;
|
||||||
|
|
||||||
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
|
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
|
||||||
console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 합성된 이미지를 dataUrl로 변환
|
// 합성된 이미지를 dataUrl로 변환
|
||||||
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
||||||
console.log("✅ 최종 합성 완료");
|
|
||||||
|
|
||||||
// 기존 dataUrl을 합성된 것으로 교체
|
// 기존 dataUrl을 합성된 것으로 교체
|
||||||
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 캡처 완료 (WebGL 없음)");
|
|
||||||
|
|
||||||
// WebGL이 없는 경우 기본 다운로드
|
// WebGL이 없는 경우 기본 다운로드
|
||||||
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 다운로드 실패:", error);
|
|
||||||
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-auto min-h-16 flex-col gap-3 border-b bg-background px-4 py-3 shadow-sm sm:h-16 sm:flex-row sm:items-center sm:justify-between sm:gap-0 sm:px-6 sm:py-0">
|
<div className="bg-background flex h-16 items-center justify-between border-b px-4 py-3 shadow-sm">
|
||||||
{/* 좌측: 대시보드 제목 */}
|
{/* 좌측: 대시보드 제목 */}
|
||||||
<div className="flex flex-1 items-center gap-2 sm:gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
{dashboardTitle && (
|
{dashboardTitle && (
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
<span className="text-base font-semibold text-foreground sm:text-lg">{dashboardTitle}</span>
|
<span className="text-foreground text-base font-semibold sm:text-lg">{dashboardTitle}</span>
|
||||||
<span className="w-fit rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">편집 중</span>
|
<span className="bg-primary/10 text-primary w-fit rounded px-2 py-0.5 text-xs font-medium">편집 중</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 해상도 선택 & 요소 추가 */}
|
{/* 중앙: 해상도 선택 & 요소 추가 */}
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* 해상도 선택 */}
|
{/* 해상도 선택 */}
|
||||||
{onResolutionChange && (
|
{onResolutionChange && (
|
||||||
<ResolutionSelector
|
<ResolutionSelector
|
||||||
|
|
@ -293,7 +262,7 @@ export function DashboardTopMenu({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="bg-border h-6 w-px" />
|
||||||
|
|
||||||
{/* 배경색 선택 */}
|
{/* 배경색 선택 */}
|
||||||
{onBackgroundColorChange && (
|
{onBackgroundColorChange && (
|
||||||
|
|
@ -301,7 +270,7 @@ export function DashboardTopMenu({
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<Palette className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
<div className="h-4 w-4 rounded border border-border" style={{ backgroundColor }} />
|
<div className="border-border h-4 w-4 rounded border" style={{ backgroundColor }} />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="z-[99999] w-64">
|
<PopoverContent className="z-[99999] w-64">
|
||||||
|
|
@ -355,7 +324,7 @@ export function DashboardTopMenu({
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="h-6 w-px bg-border hidden sm:block" />
|
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||||
|
|
||||||
{/* 차트 선택 */}
|
{/* 차트 선택 */}
|
||||||
<Select value={chartValue} onValueChange={handleChartSelect}>
|
<Select value={chartValue} onValueChange={handleChartSelect}>
|
||||||
|
|
@ -422,8 +391,13 @@ export function DashboardTopMenu({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 액션 버튼 */}
|
{/* 우측: 액션 버튼 */}
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
|
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap">
|
||||||
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-destructive hover:text-destructive">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearCanvas}
|
||||||
|
className="text-destructive hover:text-destructive gap-2"
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,563 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
|
||||||
import { QueryEditor } from "./QueryEditor";
|
|
||||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
|
||||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
|
||||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
|
||||||
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
|
|
||||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
|
||||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
|
||||||
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
|
|
||||||
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
|
|
||||||
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface ElementConfigSidebarProps {
|
|
||||||
element: DashboardElement | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요소 설정 사이드바 컴포넌트
|
|
||||||
* - 왼쪽에서 슬라이드 인/아웃
|
|
||||||
* - 캔버스 위에 오버레이
|
|
||||||
* - "적용" 버튼으로 명시적 저장
|
|
||||||
*/
|
|
||||||
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
|
|
||||||
const [dataSource, setDataSource] = useState<ChartDataSource>({
|
|
||||||
type: "database",
|
|
||||||
connectionType: "current",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
const [dataSources, setDataSources] = useState<ChartDataSource[]>([]);
|
|
||||||
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
||||||
const [customTitle, setCustomTitle] = useState<string>("");
|
|
||||||
const [showHeader, setShowHeader] = useState<boolean>(true);
|
|
||||||
|
|
||||||
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
|
|
||||||
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
|
|
||||||
new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사이드바가 열릴 때 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && element) {
|
|
||||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
|
||||||
|
|
||||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
|
||||||
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
|
|
||||||
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
|
|
||||||
setDataSources(initialDataSources);
|
|
||||||
|
|
||||||
setChartConfig(element.chartConfig || {});
|
|
||||||
setQueryResult(null);
|
|
||||||
setTestResults(new Map()); // 테스트 결과도 초기화
|
|
||||||
setCustomTitle(element.customTitle || "");
|
|
||||||
setShowHeader(element.showHeader !== false);
|
|
||||||
} else if (!isOpen) {
|
|
||||||
// 사이드바가 닫힐 때 모든 상태 초기화
|
|
||||||
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
|
||||||
setDataSources([]);
|
|
||||||
setChartConfig({});
|
|
||||||
setQueryResult(null);
|
|
||||||
setTestResults(new Map());
|
|
||||||
setCustomTitle("");
|
|
||||||
setShowHeader(true);
|
|
||||||
}
|
|
||||||
}, [isOpen, element]);
|
|
||||||
|
|
||||||
// Esc 키로 사이드바 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleEsc);
|
|
||||||
return () => window.removeEventListener("keydown", handleEsc);
|
|
||||||
}, [isOpen, onClose]);
|
|
||||||
|
|
||||||
// 데이터 소스 타입 변경
|
|
||||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
|
||||||
if (type === "database") {
|
|
||||||
setDataSource({
|
|
||||||
type: "database",
|
|
||||||
connectionType: "current",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDataSource({
|
|
||||||
type: "api",
|
|
||||||
method: "GET",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setQueryResult(null);
|
|
||||||
setChartConfig({});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 데이터 소스 업데이트
|
|
||||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
|
||||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 차트 설정 변경 처리
|
|
||||||
const handleChartConfigChange = useCallback(
|
|
||||||
(newConfig: ChartConfig) => {
|
|
||||||
setChartConfig(newConfig);
|
|
||||||
|
|
||||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-summary-v2 위젯용)
|
|
||||||
if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) {
|
|
||||||
onApply({
|
|
||||||
...element,
|
|
||||||
chartConfig: newConfig,
|
|
||||||
dataSource: dataSource,
|
|
||||||
customTitle: customTitle,
|
|
||||||
showHeader: showHeader,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[element, dataSource, customTitle, showHeader, onApply],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 쿼리 테스트 결과 처리
|
|
||||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
|
||||||
setQueryResult(result);
|
|
||||||
setChartConfig({});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 적용 처리
|
|
||||||
const handleApply = useCallback(() => {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
// 다중 데이터 소스 위젯 체크
|
|
||||||
const isMultiDS =
|
|
||||||
element.subtype === "map-summary-v2" ||
|
|
||||||
element.subtype === "chart" ||
|
|
||||||
element.subtype === "list-v2" ||
|
|
||||||
element.subtype === "custom-metric-v2" ||
|
|
||||||
element.subtype === "risk-alert-v2";
|
|
||||||
|
|
||||||
const updatedElement: DashboardElement = {
|
|
||||||
...element,
|
|
||||||
// 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장
|
|
||||||
chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig,
|
|
||||||
dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성
|
|
||||||
dataSource: isMultiDS ? undefined : dataSource,
|
|
||||||
customTitle: customTitle.trim() || undefined,
|
|
||||||
showHeader,
|
|
||||||
};
|
|
||||||
|
|
||||||
onApply(updatedElement);
|
|
||||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
|
||||||
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
|
|
||||||
|
|
||||||
// 요소가 없으면 렌더링하지 않음
|
|
||||||
if (!element) return null;
|
|
||||||
|
|
||||||
// 리스트 위젯은 별도 사이드바로 처리
|
|
||||||
if (element.subtype === "list-v2") {
|
|
||||||
return (
|
|
||||||
<ListWidgetConfigSidebar
|
|
||||||
element={element}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
onApply={(updatedElement) => {
|
|
||||||
onApply(updatedElement);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 야드 위젯은 사이드바로 처리
|
|
||||||
if (element.subtype === "yard-management-3d") {
|
|
||||||
return (
|
|
||||||
<YardWidgetConfigSidebar
|
|
||||||
element={element}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onApply={(updates) => {
|
|
||||||
onApply({ ...element, ...updates });
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 커스텀 카드 위젯은 사이드바로 처리
|
|
||||||
if (element.subtype === "custom-metric-v2") {
|
|
||||||
return (
|
|
||||||
<CustomMetricConfigSidebar
|
|
||||||
element={element}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
onApply={(updates) => {
|
|
||||||
onApply({ ...element, ...updates });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
|
||||||
const isSimpleWidget =
|
|
||||||
element.subtype === "todo" ||
|
|
||||||
element.subtype === "booking-alert" ||
|
|
||||||
element.subtype === "maintenance" ||
|
|
||||||
element.subtype === "document" ||
|
|
||||||
element.subtype === "vehicle-status" ||
|
|
||||||
element.subtype === "vehicle-list" ||
|
|
||||||
element.subtype === "status-summary" ||
|
|
||||||
element.subtype === "delivery-status" ||
|
|
||||||
element.subtype === "delivery-status-summary" ||
|
|
||||||
element.subtype === "delivery-today-stats" ||
|
|
||||||
element.subtype === "cargo-list" ||
|
|
||||||
element.subtype === "customer-issues" ||
|
|
||||||
element.subtype === "driver-management" ||
|
|
||||||
element.subtype === "work-history" ||
|
|
||||||
element.subtype === "transport-stats";
|
|
||||||
|
|
||||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
|
||||||
const isSelfContainedWidget =
|
|
||||||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
|
||||||
|
|
||||||
// 지도 위젯 (위도/경도 매핑 필요)
|
|
||||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary-v2";
|
|
||||||
|
|
||||||
// 헤더 전용 위젯
|
|
||||||
const isHeaderOnlyWidget =
|
|
||||||
element.type === "widget" &&
|
|
||||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
|
||||||
|
|
||||||
// 다중 데이터 소스 위젯
|
|
||||||
const isMultiDataSourceWidget =
|
|
||||||
(element.subtype as string) === "map-summary-v2" ||
|
|
||||||
(element.subtype as string) === "chart" ||
|
|
||||||
(element.subtype as string) === "list-v2" ||
|
|
||||||
(element.subtype as string) === "custom-metric-v2" ||
|
|
||||||
(element.subtype as string) === "risk-alert-v2";
|
|
||||||
|
|
||||||
// 저장 가능 여부 확인
|
|
||||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
|
||||||
const isApiSource = dataSource.type === "api";
|
|
||||||
|
|
||||||
const hasYAxis =
|
|
||||||
chartConfig.yAxis &&
|
|
||||||
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
|
||||||
|
|
||||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
|
||||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
|
||||||
|
|
||||||
const canApply =
|
|
||||||
isTitleChanged ||
|
|
||||||
isHeaderChanged ||
|
|
||||||
(isMultiDataSourceWidget
|
|
||||||
? true // 다중 데이터 소스 위젯은 항상 적용 가능
|
|
||||||
: isSimpleWidget
|
|
||||||
? queryResult && queryResult.rows.length > 0
|
|
||||||
: isMapWidget
|
|
||||||
? element.subtype === "map-summary-v2"
|
|
||||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 지도 위젯: 타일맵 URL 또는 API 데이터
|
|
||||||
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
|
||||||
: queryResult &&
|
|
||||||
queryResult.rows.length > 0 &&
|
|
||||||
chartConfig.xAxis &&
|
|
||||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="bg-background flex items-center justify-between px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">⚙</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-foreground text-xs font-semibold">{element.title}</span>
|
|
||||||
</div>
|
|
||||||
<Button onClick={onClose} variant="ghost" size="icon" className="h-6 w-6">
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문: 스크롤 가능 영역 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
{/* 기본 설정 카드 */}
|
|
||||||
<div className="bg-background mb-3 rounded-lg p-3 shadow-sm">
|
|
||||||
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase">기본 설정</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 커스텀 제목 입력 */}
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
value={customTitle}
|
|
||||||
onChange={(e) => setCustomTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
placeholder="위젯 제목"
|
|
||||||
className="bg-muted focus:bg-background h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 표시 옵션 */}
|
|
||||||
<div className="border-border bg-muted flex items-center gap-2 rounded border px-2 py-1.5">
|
|
||||||
<Checkbox
|
|
||||||
id="showHeader"
|
|
||||||
checked={showHeader}
|
|
||||||
onCheckedChange={(checked) => setShowHeader(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="showHeader" className="cursor-pointer text-xs font-normal">
|
|
||||||
헤더 표시
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 다중 데이터 소스 위젯 */}
|
|
||||||
{isMultiDataSourceWidget && (
|
|
||||||
<>
|
|
||||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
|
||||||
<MultiDataSourceConfig
|
|
||||||
dataSources={dataSources}
|
|
||||||
onChange={setDataSources}
|
|
||||||
onTestResult={(result, dataSourceId) => {
|
|
||||||
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
|
|
||||||
setQueryResult({
|
|
||||||
...result,
|
|
||||||
totalRows: result.rows.length,
|
|
||||||
executionTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 각 데이터 소스의 테스트 결과 저장
|
|
||||||
setTestResults((prev) => {
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.set(dataSourceId, result);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 지도 위젯: 타일맵 URL 설정 */}
|
|
||||||
{element.subtype === "map-summary-v2" && (
|
|
||||||
<div className="bg-background rounded-lg shadow-sm">
|
|
||||||
<details className="group">
|
|
||||||
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
|
||||||
타일맵 설정 (선택사항)
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-0.5 text-[10px]">기본 VWorld 타일맵 사용 중</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 transition-transform group-open:rotate-180"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</summary>
|
|
||||||
<div className="border-t p-3">
|
|
||||||
<MapTestConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={undefined}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 차트 위젯: 차트 설정 */}
|
|
||||||
{element.subtype === "chart" && (
|
|
||||||
<div className="bg-background rounded-lg shadow-sm">
|
|
||||||
<details className="group" open>
|
|
||||||
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
|
||||||
차트 설정
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-0.5 text-[10px]">
|
|
||||||
{testResults.size > 0
|
|
||||||
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
|
|
||||||
: "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 transition-transform group-open:rotate-180"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</summary>
|
|
||||||
<div className="border-t p-3">
|
|
||||||
<MultiChartConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
dataSources={dataSources}
|
|
||||||
testResults={testResults}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
|
||||||
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
|
|
||||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
|
||||||
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase">
|
|
||||||
데이터 소스
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultValue={dataSource.type}
|
|
||||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList className="bg-muted grid h-7 w-full grid-cols-2 p-0.5">
|
|
||||||
<TabsTrigger
|
|
||||||
value="database"
|
|
||||||
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
데이터베이스
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="api"
|
|
||||||
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
REST API
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="database" className="mt-2 space-y-2">
|
|
||||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={dataSource}
|
|
||||||
onDataSourceChange={handleDataSourceUpdate}
|
|
||||||
onQueryTest={handleQueryTest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 차트/지도 설정 */}
|
|
||||||
{!isSimpleWidget &&
|
|
||||||
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{isMapWidget ? (
|
|
||||||
element.subtype === "map-summary-v2" ? (
|
|
||||||
<MapTestConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult || undefined}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
queryResult &&
|
|
||||||
queryResult.rows.length > 0 && (
|
|
||||||
<VehicleMapConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
queryResult &&
|
|
||||||
queryResult.rows.length > 0 && (
|
|
||||||
<ChartConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
chartType={element.subtype}
|
|
||||||
dataSourceType={dataSource.type}
|
|
||||||
query={dataSource.query}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="api" className="mt-2 space-y-2">
|
|
||||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
||||||
|
|
||||||
{/* 차트/지도 설정 */}
|
|
||||||
{!isSimpleWidget &&
|
|
||||||
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{isMapWidget ? (
|
|
||||||
element.subtype === "map-summary-v2" ? (
|
|
||||||
<MapTestConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult || undefined}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
queryResult &&
|
|
||||||
queryResult.rows.length > 0 && (
|
|
||||||
<VehicleMapConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
queryResult &&
|
|
||||||
queryResult.rows.length > 0 && (
|
|
||||||
<ChartConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
chartType={element.subtype}
|
|
||||||
dataSourceType={dataSource.type}
|
|
||||||
query={dataSource.query}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* 데이터 로드 상태 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="bg-success/10 mt-2 flex items-center gap-1.5 rounded px-2 py-1">
|
|
||||||
<div className="bg-success h-1.5 w-1.5 rounded-full" />
|
|
||||||
<span className="text-success text-[10px] font-medium">{queryResult.rows.length}개 데이터 로드됨</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터: 적용 버튼 */}
|
|
||||||
<div className="bg-background flex gap-2 p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
||||||
<Button onClick={onClose} variant="outline" className="flex-1 text-xs">
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleApply} disabled={isHeaderOnlyWidget ? false : !canApply} className="flex-1 text-xs">
|
|
||||||
적용
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,543 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
DashboardElement,
|
||||||
|
ChartDataSource,
|
||||||
|
ElementSubtype,
|
||||||
|
QueryResult,
|
||||||
|
ListWidgetConfig,
|
||||||
|
ChartConfig,
|
||||||
|
CustomMetricConfig,
|
||||||
|
} from "./types";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||||
|
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||||
|
import { QueryEditor } from "./QueryEditor";
|
||||||
|
import { ListWidgetSection } from "./widget-sections/ListWidgetSection";
|
||||||
|
import { ChartConfigSection } from "./widget-sections/ChartConfigSection";
|
||||||
|
import { CustomMetricSection } from "./widget-sections/CustomMetricSection";
|
||||||
|
import { MapConfigSection } from "./widget-sections/MapConfigSection";
|
||||||
|
import { RiskAlertSection } from "./widget-sections/RiskAlertSection";
|
||||||
|
import MultiDataSourceConfig from "@/components/admin/dashboard/data-sources/MultiDataSourceConfig";
|
||||||
|
|
||||||
|
interface WidgetConfigSidebarProps {
|
||||||
|
element: DashboardElement | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (element: DashboardElement) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위젯 분류 헬퍼 함수
|
||||||
|
const needsDataSource = (subtype: ElementSubtype): boolean => {
|
||||||
|
// 차트 타입들
|
||||||
|
const chartTypes = ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"];
|
||||||
|
|
||||||
|
const dataWidgets = [
|
||||||
|
"list-v2",
|
||||||
|
"custom-metric-v2",
|
||||||
|
"chart",
|
||||||
|
"map-summary-v2",
|
||||||
|
"risk-alert-v2",
|
||||||
|
"yard-management-3d",
|
||||||
|
"todo",
|
||||||
|
"document",
|
||||||
|
"work-history",
|
||||||
|
"transport-stats",
|
||||||
|
"booking-alert",
|
||||||
|
"maintenance",
|
||||||
|
"vehicle-status",
|
||||||
|
"vehicle-list",
|
||||||
|
"status-summary",
|
||||||
|
"delivery-status",
|
||||||
|
"delivery-status-summary",
|
||||||
|
"delivery-today-stats",
|
||||||
|
"cargo-list",
|
||||||
|
"customer-issues",
|
||||||
|
"driver-management",
|
||||||
|
];
|
||||||
|
|
||||||
|
return chartTypes.includes(subtype) || dataWidgets.includes(subtype);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWidgetIcon = (subtype: ElementSubtype): string => {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
"list-v2": "📋",
|
||||||
|
"custom-metric-v2": "📊",
|
||||||
|
chart: "📈",
|
||||||
|
"map-summary-v2": "🗺️",
|
||||||
|
"risk-alert-v2": "⚠️",
|
||||||
|
"yard-management-3d": "🏗️",
|
||||||
|
weather: "🌤️",
|
||||||
|
exchange: "💱",
|
||||||
|
calculator: "🧮",
|
||||||
|
clock: "🕐",
|
||||||
|
calendar: "📅",
|
||||||
|
todo: "✅",
|
||||||
|
document: "📄",
|
||||||
|
};
|
||||||
|
return iconMap[subtype] || "🔧";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWidgetTitle = (subtype: ElementSubtype): string => {
|
||||||
|
const titleMap: Record<string, string> = {
|
||||||
|
"list-v2": "리스트 위젯",
|
||||||
|
"custom-metric-v2": "통계 카드",
|
||||||
|
chart: "차트",
|
||||||
|
"map-summary-v2": "지도",
|
||||||
|
"risk-alert-v2": "리스크 알림",
|
||||||
|
"yard-management-3d": "야드 관리 3D",
|
||||||
|
weather: "날씨 위젯",
|
||||||
|
exchange: "환율 위젯",
|
||||||
|
calculator: "계산기",
|
||||||
|
clock: "시계",
|
||||||
|
calendar: "달력",
|
||||||
|
todo: "할 일",
|
||||||
|
document: "문서",
|
||||||
|
};
|
||||||
|
return titleMap[subtype] || "위젯";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통합 위젯 설정 사이드바
|
||||||
|
* - 모든 위젯 타입에 대한 일관된 설정 UI 제공
|
||||||
|
* - 일반 탭: 제목, 헤더 표시 설정
|
||||||
|
* - 데이터 탭: 데이터 소스 및 위젯별 커스텀 설정
|
||||||
|
*/
|
||||||
|
export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: WidgetConfigSidebarProps) {
|
||||||
|
// 일반 설정 state
|
||||||
|
const [customTitle, setCustomTitle] = useState<string>("");
|
||||||
|
const [showHeader, setShowHeader] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// 데이터 소스 state
|
||||||
|
const [dataSource, setDataSource] = useState<ChartDataSource>({
|
||||||
|
type: "database",
|
||||||
|
connectionType: "current",
|
||||||
|
refreshInterval: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다중 데이터 소스 상태 추가
|
||||||
|
const [dataSources, setDataSources] = useState<ChartDataSource[]>(element?.dataSources || []);
|
||||||
|
|
||||||
|
// 쿼리 결과
|
||||||
|
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||||
|
|
||||||
|
// 리스트 위젯 설정
|
||||||
|
const [listConfig, setListConfig] = useState<ListWidgetConfig>({
|
||||||
|
viewMode: "table",
|
||||||
|
columns: [],
|
||||||
|
pageSize: 10,
|
||||||
|
enablePagination: true,
|
||||||
|
showHeader: true,
|
||||||
|
stripedRows: true,
|
||||||
|
compactMode: false,
|
||||||
|
cardColumns: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 차트 설정
|
||||||
|
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
||||||
|
|
||||||
|
// 커스텀 메트릭 설정
|
||||||
|
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({});
|
||||||
|
|
||||||
|
// 사이드바 열릴 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && element) {
|
||||||
|
setCustomTitle(element.customTitle || "");
|
||||||
|
setShowHeader(element.showHeader !== false);
|
||||||
|
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||||
|
setDataSources(element.dataSources || []);
|
||||||
|
setQueryResult(null);
|
||||||
|
|
||||||
|
// 리스트 위젯 설정 초기화
|
||||||
|
if (element.subtype === "list-v2" && element.listConfig) {
|
||||||
|
setListConfig(element.listConfig);
|
||||||
|
} else {
|
||||||
|
setListConfig({
|
||||||
|
viewMode: "table",
|
||||||
|
columns: [],
|
||||||
|
pageSize: 10,
|
||||||
|
enablePagination: true,
|
||||||
|
showHeader: true,
|
||||||
|
stripedRows: true,
|
||||||
|
compactMode: false,
|
||||||
|
cardColumns: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차트 설정 초기화
|
||||||
|
setChartConfig(element.chartConfig || {});
|
||||||
|
|
||||||
|
// 커스텀 메트릭 설정 초기화
|
||||||
|
setCustomMetricConfig(element.customMetricConfig || {});
|
||||||
|
} else if (!isOpen) {
|
||||||
|
// 사이드바 닫힐 때 초기화
|
||||||
|
setCustomTitle("");
|
||||||
|
setShowHeader(true);
|
||||||
|
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
||||||
|
setDataSources([]);
|
||||||
|
setQueryResult(null);
|
||||||
|
setListConfig({
|
||||||
|
viewMode: "table",
|
||||||
|
columns: [],
|
||||||
|
pageSize: 10,
|
||||||
|
enablePagination: true,
|
||||||
|
showHeader: true,
|
||||||
|
stripedRows: true,
|
||||||
|
compactMode: false,
|
||||||
|
cardColumns: 3,
|
||||||
|
});
|
||||||
|
setChartConfig({});
|
||||||
|
setCustomMetricConfig({});
|
||||||
|
}
|
||||||
|
}, [isOpen, element]);
|
||||||
|
|
||||||
|
// Esc 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleEsc);
|
||||||
|
return () => window.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// 데이터 소스 타입 변경
|
||||||
|
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||||
|
if (type === "database") {
|
||||||
|
setDataSource({
|
||||||
|
type: "database",
|
||||||
|
connectionType: "current",
|
||||||
|
refreshInterval: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDataSource({
|
||||||
|
type: "api",
|
||||||
|
method: "GET",
|
||||||
|
refreshInterval: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 소스 업데이트
|
||||||
|
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||||
|
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 다중 데이터 소스 변경 핸들러
|
||||||
|
const handleDataSourcesChange = useCallback((updatedSources: ChartDataSource[]) => {
|
||||||
|
setDataSources(updatedSources);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 쿼리 테스트 결과 처리
|
||||||
|
const handleQueryTest = useCallback(
|
||||||
|
(result: QueryResult) => {
|
||||||
|
setQueryResult(result);
|
||||||
|
|
||||||
|
// 리스트 위젯: 쿼리 결과로 컬럼 자동 생성
|
||||||
|
if (element?.subtype === "list-v2" && result.columns && result.columns.length > 0) {
|
||||||
|
const newColumns = result.columns.map((col: string, idx: number) => ({
|
||||||
|
id: `col_${Date.now()}_${idx}`,
|
||||||
|
field: col,
|
||||||
|
label: col,
|
||||||
|
visible: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
align: "left" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setListConfig((prev) => ({ ...prev, columns: newColumns }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[element],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리스트 설정 변경
|
||||||
|
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
|
||||||
|
setListConfig((prev) => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 차트 설정 변경
|
||||||
|
const handleChartConfigChange = useCallback((config: ChartConfig) => {
|
||||||
|
setChartConfig(config);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 커스텀 메트릭 설정 변경
|
||||||
|
const handleCustomMetricConfigChange = useCallback((updates: Partial<CustomMetricConfig>) => {
|
||||||
|
setCustomMetricConfig((prev) => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 적용
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
// 다중 데이터 소스를 사용하는 위젯 체크
|
||||||
|
const isMultiDataSourceWidget =
|
||||||
|
element.subtype === "map-summary-v2" ||
|
||||||
|
element.subtype === "chart" ||
|
||||||
|
element.subtype === "list-v2" ||
|
||||||
|
element.subtype === "custom-metric-v2" ||
|
||||||
|
element.subtype === "risk-alert-v2";
|
||||||
|
|
||||||
|
const updatedElement: DashboardElement = {
|
||||||
|
...element,
|
||||||
|
customTitle: customTitle.trim() || undefined,
|
||||||
|
showHeader,
|
||||||
|
// 데이터 소스 처리
|
||||||
|
...(needsDataSource(element.subtype)
|
||||||
|
? {
|
||||||
|
dataSource,
|
||||||
|
// 다중 데이터 소스 위젯은 dataSources도 포함
|
||||||
|
...(isMultiDataSourceWidget
|
||||||
|
? {
|
||||||
|
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// 리스트 위젯 설정
|
||||||
|
...(element.subtype === "list-v2"
|
||||||
|
? {
|
||||||
|
listConfig,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// 차트 설정 (차트 타입이거나 차트 기능이 있는 위젯)
|
||||||
|
...(element.type === "chart" ||
|
||||||
|
element.subtype === "chart" ||
|
||||||
|
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
|
||||||
|
? {
|
||||||
|
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함
|
||||||
|
chartConfig: isMultiDataSourceWidget
|
||||||
|
? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] }
|
||||||
|
: chartConfig,
|
||||||
|
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함
|
||||||
|
...(isMultiDataSourceWidget
|
||||||
|
? {
|
||||||
|
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// 커스텀 메트릭 설정
|
||||||
|
...(element.subtype === "custom-metric-v2"
|
||||||
|
? {
|
||||||
|
customMetricConfig,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", {
|
||||||
|
subtype: element.subtype,
|
||||||
|
customMetricConfig,
|
||||||
|
updatedElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
onApply(updatedElement);
|
||||||
|
onClose();
|
||||||
|
}, [
|
||||||
|
element,
|
||||||
|
customTitle,
|
||||||
|
showHeader,
|
||||||
|
dataSource,
|
||||||
|
dataSources,
|
||||||
|
listConfig,
|
||||||
|
chartConfig,
|
||||||
|
customMetricConfig,
|
||||||
|
onApply,
|
||||||
|
onClose,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
const hasDataTab = needsDataSource(element.subtype);
|
||||||
|
const widgetIcon = getWidgetIcon(element.subtype);
|
||||||
|
const widgetTitle = getWidgetTitle(element.subtype);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col transition-transform duration-300 ease-in-out",
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-background flex items-center justify-between px-3 py-2 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||||
|
<span className="text-primary text-xs font-bold">{widgetIcon}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-foreground text-xs font-semibold">{widgetTitle} 설정</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="text-muted-foreground h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 영역 */}
|
||||||
|
<Tabs defaultValue="general" className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TabsList className="bg-background mx-3 mt-3 grid h-9 w-auto grid-cols-2">
|
||||||
|
<TabsTrigger value="general" className="text-xs">
|
||||||
|
일반
|
||||||
|
</TabsTrigger>
|
||||||
|
{hasDataTab && (
|
||||||
|
<TabsTrigger value="data" className="text-xs">
|
||||||
|
데이터
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 일반 탭 */}
|
||||||
|
<TabsContent value="general" className="mt-0 flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 위젯 제목 */}
|
||||||
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
|
<Label htmlFor="widget-title" className="mb-2 block text-xs font-semibold">
|
||||||
|
위젯 제목
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={customTitle}
|
||||||
|
onChange={(e) => setCustomTitle(e.target.value)}
|
||||||
|
placeholder={`기본 제목: ${element.title}`}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-xs">비워두면 기본 제목이 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 표시 */}
|
||||||
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="show-header" className="text-xs font-semibold">
|
||||||
|
헤더 표시
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">위젯 상단 헤더를 표시합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="show-header" checked={showHeader} onCheckedChange={setShowHeader} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 데이터 탭 */}
|
||||||
|
{hasDataTab && (
|
||||||
|
<TabsContent value="data" className="mt-0 flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */}
|
||||||
|
{!["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
|
||||||
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">데이터 소스</Label>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={dataSource.type}
|
||||||
|
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="bg-muted grid h-8 w-full grid-cols-2 p-0.5">
|
||||||
|
<TabsTrigger
|
||||||
|
value="database"
|
||||||
|
className="data-[state=active]:bg-background h-7 rounded text-xs data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
데이터베이스
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="api"
|
||||||
|
className="data-[state=active]:bg-background h-7 rounded text-xs data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
REST API
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="database" className="mt-2 space-y-2">
|
||||||
|
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||||
|
<QueryEditor
|
||||||
|
dataSource={dataSource}
|
||||||
|
onDataSourceChange={handleDataSourceUpdate}
|
||||||
|
onQueryTest={handleQueryTest}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="api" className="mt-2 space-y-2">
|
||||||
|
<ApiConfig
|
||||||
|
dataSource={dataSource}
|
||||||
|
onChange={handleDataSourceUpdate}
|
||||||
|
onTestResult={handleQueryTest}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 다중 데이터 소스 설정 */}
|
||||||
|
{["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
|
||||||
|
<MultiDataSourceConfig dataSources={dataSources} onChange={handleDataSourcesChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 위젯별 커스텀 섹션 */}
|
||||||
|
{element.subtype === "list-v2" && (
|
||||||
|
<ListWidgetSection
|
||||||
|
queryResult={queryResult}
|
||||||
|
config={listConfig}
|
||||||
|
onConfigChange={handleListConfigChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 차트 설정 */}
|
||||||
|
{(element.type === "chart" ||
|
||||||
|
element.subtype === "chart" ||
|
||||||
|
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(
|
||||||
|
element.subtype,
|
||||||
|
)) && (
|
||||||
|
<ChartConfigSection
|
||||||
|
queryResult={queryResult}
|
||||||
|
dataSource={dataSource}
|
||||||
|
config={chartConfig}
|
||||||
|
chartType={element.subtype}
|
||||||
|
onConfigChange={handleChartConfigChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 메트릭 설정 */}
|
||||||
|
{element.subtype === "custom-metric-v2" && (
|
||||||
|
<CustomMetricSection
|
||||||
|
queryResult={queryResult}
|
||||||
|
config={customMetricConfig}
|
||||||
|
onConfigChange={handleCustomMetricConfigChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 지도 설정 */}
|
||||||
|
{element.subtype === "map-summary-v2" && <MapConfigSection queryResult={queryResult} />}
|
||||||
|
|
||||||
|
{/* 리스크 알림 설정 */}
|
||||||
|
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="bg-background flex gap-2 border-t p-3">
|
||||||
|
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApply} className="h-9 flex-1 text-sm">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 현재 DB vs 외부 DB 선택 */}
|
{/* 현재 DB vs 외부 DB 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-xs font-medium text-foreground">데이터베이스 선택</Label>
|
<Label className="text-foreground mb-2 block text-xs font-medium">데이터베이스 선택</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -88,12 +88,12 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
{dataSource.connectionType === "external" && (
|
{dataSource.connectionType === "external" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium text-foreground">외부 커넥션</Label>
|
<Label className="text-foreground text-xs font-medium">외부 커넥션</Label>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/external-connections");
|
router.push("/admin/external-connections");
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 text-[11px] text-primary transition-colors hover:text-primary"
|
className="text-primary hover:text-primary flex items-center gap-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
커넥션 관리
|
커넥션 관리
|
||||||
|
|
@ -102,17 +102,17 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-3">
|
<div className="flex items-center justify-center py-3">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-blue-600" />
|
<div className="border-border h-4 w-4 animate-spin rounded-full border-2 border-t-blue-600" />
|
||||||
<span className="ml-2 text-xs text-foreground">로딩 중...</span>
|
<span className="text-foreground ml-2 text-xs">로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded bg-destructive/10 px-2 py-1.5">
|
<div className="bg-destructive/10 rounded px-2 py-1.5">
|
||||||
<div className="text-xs text-destructive">{error}</div>
|
<div className="text-destructive text-xs">{error}</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadExternalConnections}
|
onClick={loadExternalConnections}
|
||||||
className="mt-1 text-[11px] text-destructive underline hover:no-underline"
|
className="text-destructive mt-1 text-[11px] underline hover:no-underline"
|
||||||
>
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -120,13 +120,13 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && connections.length === 0 && (
|
{!loading && !error && connections.length === 0 && (
|
||||||
<div className="rounded bg-warning/10 px-2 py-2 text-center">
|
<div className="bg-warning/10 rounded px-2 py-2 text-center">
|
||||||
<div className="mb-1 text-xs text-warning">등록된 커넥션이 없습니다</div>
|
<div className="text-warning mb-1 text-xs">등록된 커넥션이 없습니다</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/external-connections");
|
router.push("/admin/external-connections");
|
||||||
}}
|
}}
|
||||||
className="text-[11px] text-warning underline hover:no-underline"
|
className="text-warning text-[11px] underline hover:no-underline"
|
||||||
>
|
>
|
||||||
커넥션 등록하기
|
커넥션 등록하기
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -149,7 +149,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium">{conn.connection_name}</span>
|
<span className="font-medium">{conn.connection_name}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">({conn.db_type.toUpperCase()})</span>
|
<span className="text-muted-foreground text-[10px]">({conn.db_type.toUpperCase()})</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -157,7 +157,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{selectedConnection && (
|
{selectedConnection && (
|
||||||
<div className="space-y-0.5 rounded bg-muted px-2 py-1.5 text-[11px] text-foreground">
|
<div className="bg-muted text-foreground space-y-0.5 rounded px-2 py-1.5 text-[11px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">커넥션:</span> {selectedConnection.connection_name}
|
<span className="font-medium">커넥션:</span> {selectedConnection.connection_name}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -380,15 +380,18 @@ export interface YardManagementConfig {
|
||||||
|
|
||||||
// 사용자 커스텀 카드 설정
|
// 사용자 커스텀 카드 설정
|
||||||
export interface CustomMetricConfig {
|
export interface CustomMetricConfig {
|
||||||
groupByMode?: boolean; // 그룹별 카드 생성 모드 (기본: false)
|
// 단일 통계 카드 설정
|
||||||
groupByDataSource?: ChartDataSource; // 그룹별 카드 전용 데이터 소스 (선택사항)
|
valueColumn?: string; // 계산할 컬럼명
|
||||||
metrics: Array<{
|
aggregation?: "sum" | "avg" | "count" | "min" | "max"; // 계산 방식
|
||||||
id: string; // 고유 ID
|
title?: string; // 카드 제목
|
||||||
field: string; // 집계할 컬럼명
|
unit?: string; // 표시 단위 (원, 건, % 등)
|
||||||
label: string; // 표시할 라벨
|
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
|
||||||
aggregation: "count" | "sum" | "avg" | "min" | "max"; // 집계 함수
|
decimals?: number; // 소수점 자릿수 (기본: 0)
|
||||||
unit: string; // 단위 (%, 건, 일, km, 톤 등)
|
|
||||||
color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray";
|
// 필터 조건
|
||||||
decimals: number; // 소수점 자릿수
|
filters?: Array<{
|
||||||
|
column: string; // 필터 컬럼명
|
||||||
|
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "contains" | "not_contains"; // 조건 연산자
|
||||||
|
value: string; // 비교값
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ChartConfig, QueryResult, ChartDataSource } from "../types";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ChartConfigPanel } from "../ChartConfigPanel";
|
||||||
|
|
||||||
|
interface ChartConfigSectionProps {
|
||||||
|
queryResult: QueryResult | null;
|
||||||
|
dataSource: ChartDataSource;
|
||||||
|
config: ChartConfig;
|
||||||
|
chartType?: string;
|
||||||
|
onConfigChange: (config: ChartConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차트 설정 섹션
|
||||||
|
* - 차트 타입, 축 매핑, 스타일 설정
|
||||||
|
*/
|
||||||
|
export function ChartConfigSection({
|
||||||
|
queryResult,
|
||||||
|
dataSource,
|
||||||
|
config,
|
||||||
|
chartType,
|
||||||
|
onConfigChange,
|
||||||
|
}: ChartConfigSectionProps) {
|
||||||
|
// 쿼리 결과가 없으면 표시하지 않음
|
||||||
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">차트 설정</Label>
|
||||||
|
<ChartConfigPanel
|
||||||
|
config={config}
|
||||||
|
queryResult={queryResult}
|
||||||
|
onConfigChange={onConfigChange}
|
||||||
|
chartType={chartType}
|
||||||
|
dataSourceType={dataSource.type}
|
||||||
|
query={dataSource.query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { CustomMetricConfig, QueryResult } from "../types";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { AlertCircle, Plus, X } from "lucide-react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface CustomMetricSectionProps {
|
||||||
|
queryResult: QueryResult | null;
|
||||||
|
config: CustomMetricConfig;
|
||||||
|
onConfigChange: (updates: Partial<CustomMetricConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 카드 설정 섹션
|
||||||
|
* - 쿼리 결과를 받아서 어떻게 통계를 낼지 설정
|
||||||
|
* - 컬럼 선택, 계산 방식(합계/평균/개수 등), 표시 방식
|
||||||
|
* - 필터 조건 추가 가능
|
||||||
|
*/
|
||||||
|
export function CustomMetricSection({ queryResult, config, onConfigChange }: CustomMetricSectionProps) {
|
||||||
|
console.log("⚙️ [CustomMetricSection] 렌더링:", { config, queryResult });
|
||||||
|
|
||||||
|
// 초기값 설정 (aggregation이 없으면 기본값 "sum" 설정)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (queryResult && queryResult.columns && queryResult.columns.length > 0 && !config.aggregation) {
|
||||||
|
console.log("🔧 기본 aggregation 설정: sum");
|
||||||
|
onConfigChange({ aggregation: "sum" });
|
||||||
|
}
|
||||||
|
}, [queryResult, config.aggregation, onConfigChange]);
|
||||||
|
|
||||||
|
// 쿼리 결과가 없으면 안내 메시지
|
||||||
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">통계 카드 설정</Label>
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
먼저 데이터 소스 탭에서 쿼리를 실행하고 결과를 확인해주세요.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 추가
|
||||||
|
const addFilter = () => {
|
||||||
|
const newFilters = [
|
||||||
|
...(config.filters || []),
|
||||||
|
{ column: queryResult.columns[0] || "", operator: "=" as const, value: "" },
|
||||||
|
];
|
||||||
|
onConfigChange({ filters: newFilters });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 제거
|
||||||
|
const removeFilter = (index: number) => {
|
||||||
|
const newFilters = [...(config.filters || [])];
|
||||||
|
newFilters.splice(index, 1);
|
||||||
|
onConfigChange({ filters: newFilters });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 업데이트
|
||||||
|
const updateFilter = (index: number, field: string, value: string) => {
|
||||||
|
const newFilters = [...(config.filters || [])];
|
||||||
|
newFilters[index] = { ...newFilters[index], [field]: value };
|
||||||
|
onConfigChange({ filters: newFilters });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 통계 설정
|
||||||
|
return (
|
||||||
|
<div className="bg-background space-y-4 rounded-lg p-3 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">통계 카드 설정</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">쿼리 결과를 바탕으로 통계를 계산하고 표시합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1. 필터 조건 (선택사항) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">필터 조건 (선택사항)</Label>
|
||||||
|
<Button onClick={addFilter} variant="outline" size="sm" className="h-7 gap-1 text-xs">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.filters && config.filters.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.filters.map((filter, index) => (
|
||||||
|
<div key={index} className="bg-muted/50 flex items-center gap-2 rounded-md border p-2">
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{queryResult.columns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 연산자 선택 */}
|
||||||
|
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
|
||||||
|
<SelectTrigger className="h-8 w-[100px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="=" className="text-xs">
|
||||||
|
같음 (=)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="!=" className="text-xs">
|
||||||
|
다름 (≠)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value=">" className="text-xs">
|
||||||
|
큼 (>)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="<" className="text-xs">
|
||||||
|
작음 (<)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value=">=" className="text-xs">
|
||||||
|
크거나 같음 (≥)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="<=" className="text-xs">
|
||||||
|
작거나 같음 (≤)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="contains" className="text-xs">
|
||||||
|
포함
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="not_contains" className="text-xs">
|
||||||
|
미포함
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 값 입력 */}
|
||||||
|
<Input
|
||||||
|
value={filter.value}
|
||||||
|
onChange={(e) => updateFilter(index, "value", e.target.value)}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-8 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">필터 없음 (전체 데이터 사용)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 계산할 컬럼 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">계산 컬럼</Label>
|
||||||
|
<Select value={config.valueColumn || ""} onValueChange={(value) => onConfigChange({ valueColumn: value })}>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{queryResult.columns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 계산 방식 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">계산 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.aggregation || "sum"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
console.log("📐 계산 방식 변경:", value);
|
||||||
|
onConfigChange({ aggregation: value as "sum" | "avg" | "count" | "min" | "max" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="계산 방식" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sum" className="text-xs">
|
||||||
|
합계 (SUM)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="avg" className="text-xs">
|
||||||
|
평균 (AVG)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="count" className="text-xs">
|
||||||
|
개수 (COUNT)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="min" className="text-xs">
|
||||||
|
최소값 (MIN)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="max" className="text-xs">
|
||||||
|
최대값 (MAX)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. 카드 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">카드 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.title || ""}
|
||||||
|
onChange={(e) => onConfigChange({ title: e.target.value })}
|
||||||
|
placeholder="예: 총 매출액"
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. 표시 단위 (선택사항) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 단위 (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.unit || ""}
|
||||||
|
onChange={(e) => onConfigChange({ unit: e.target.value })}
|
||||||
|
placeholder="예: 원, 건, %"
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 */}
|
||||||
|
{config.valueColumn && config.aggregation && (
|
||||||
|
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
|
||||||
|
<p className="text-muted-foreground text-xs font-semibold">설정 미리보기</p>
|
||||||
|
|
||||||
|
{/* 필터 조건 표시 */}
|
||||||
|
{config.filters && config.filters.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium">필터:</p>
|
||||||
|
{config.filters.map((filter, idx) => (
|
||||||
|
<p key={idx} className="text-muted-foreground text-xs">
|
||||||
|
· {filter.column} {filter.operator} "{filter.value}"
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 계산 표시 */}
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{config.title || "통계 제목"}: {config.aggregation?.toUpperCase()}({config.valueColumn})
|
||||||
|
{config.unit ? ` ${config.unit}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ListWidgetConfig, QueryResult } from "../types";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
||||||
|
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
|
||||||
|
|
||||||
|
interface ListWidgetSectionProps {
|
||||||
|
queryResult: QueryResult | null;
|
||||||
|
config: ListWidgetConfig;
|
||||||
|
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스트 위젯 설정 섹션
|
||||||
|
* - 컬럼 설정
|
||||||
|
* - 테이블 옵션
|
||||||
|
*/
|
||||||
|
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||||
|
{queryResult && queryResult.columns.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">컬럼 설정</Label>
|
||||||
|
<UnifiedColumnEditor queryResult={queryResult} config={config} onConfigChange={onConfigChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 옵션 */}
|
||||||
|
{config.columns.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">테이블 옵션</Label>
|
||||||
|
<ListTableOptions config={config} onChange={onConfigChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { QueryResult } from "../types";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface MapConfigSectionProps {
|
||||||
|
queryResult: QueryResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지도 위젯 설정 섹션
|
||||||
|
* - 위도/경도 매핑
|
||||||
|
*
|
||||||
|
* TODO: 상세 설정 UI 추가 필요
|
||||||
|
*/
|
||||||
|
export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
|
||||||
|
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||||
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
지도 상세 설정 UI는 추후 추가 예정입니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { QueryResult } from "../types";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface RiskAlertSectionProps {
|
||||||
|
queryResult: QueryResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스크 알림 위젯 설정 섹션
|
||||||
|
* - 알림 설정
|
||||||
|
*
|
||||||
|
* TODO: 상세 설정 UI 추가 필요
|
||||||
|
*/
|
||||||
|
export function RiskAlertSection({ queryResult }: RiskAlertSectionProps) {
|
||||||
|
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||||
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">리스크 알림 설정</Label>
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<Label className="mb-2 block text-xs font-semibold">리스크 알림 설정</Label>
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
리스크 알림 상세 설정 UI는 추후 추가 예정입니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +39,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
// 데이터 로드
|
// 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
|
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -168,8 +170,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<div className="text-sm text-foreground">데이터 로딩 중...</div>
|
<div className="text-foreground text-sm">데이터 로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -181,8 +183,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-2xl">⚠️</div>
|
<div className="mb-2 text-2xl">⚠️</div>
|
||||||
<div className="text-sm font-medium text-destructive">오류 발생</div>
|
<div className="text-destructive text-sm font-medium">오류 발생</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{error}</div>
|
<div className="text-muted-foreground mt-1 text-xs">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -194,8 +196,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-4xl">📋</div>
|
<div className="mb-2 text-4xl">📋</div>
|
||||||
<div className="text-sm font-medium text-foreground">리스트를 설정하세요</div>
|
<div className="text-foreground text-sm font-medium">리스트를 설정하세요</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
<div className="text-muted-foreground mt-1 text-xs">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -222,7 +224,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
<div className="flex h-full w-full flex-col p-4">
|
<div className="flex h-full w-full flex-col p-4">
|
||||||
{/* 제목 - 항상 표시 */}
|
{/* 제목 - 항상 표시 */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
<h3 className="text-foreground text-sm font-semibold">{element.customTitle || element.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 뷰 */}
|
{/* 테이블 뷰 */}
|
||||||
|
|
@ -251,7 +253,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={displayColumns.filter((col) => col.visible).length}
|
colSpan={displayColumns.filter((col) => col.visible).length}
|
||||||
className="text-center text-muted-foreground"
|
className="text-muted-foreground text-center"
|
||||||
>
|
>
|
||||||
데이터가 없습니다
|
데이터가 없습니다
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -281,7 +283,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
{config.viewMode === "card" && (
|
{config.viewMode === "card" && (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{paginatedRows.length === 0 ? (
|
{paginatedRows.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">데이터가 없습니다</div>
|
<div className="text-muted-foreground flex h-full items-center justify-center">데이터가 없습니다</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
|
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
|
||||||
|
|
@ -296,9 +298,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
<div key={col.id}>
|
<div key={col.id}>
|
||||||
<div className="text-xs font-medium text-muted-foreground">{col.label || col.name}</div>
|
<div className="text-muted-foreground text-xs font-medium">{col.label || col.name}</div>
|
||||||
<div
|
<div
|
||||||
className={`font-medium text-foreground ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
className={`text-foreground font-medium ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||||
>
|
>
|
||||||
{String(row[col.dataKey || col.field] ?? "")}
|
{String(row[col.dataKey || col.field] ?? "")}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
|
||||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
|
||||||
import { QueryEditor } from "../QueryEditor";
|
|
||||||
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
|
|
||||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
|
||||||
|
|
||||||
interface ListWidgetConfigSidebarProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리스트 위젯 설정 사이드바
|
|
||||||
*/
|
|
||||||
export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) {
|
|
||||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
|
||||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
|
||||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
|
||||||
);
|
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
||||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
|
||||||
element.listConfig || {
|
|
||||||
viewMode: "table",
|
|
||||||
columns: [],
|
|
||||||
pageSize: 10,
|
|
||||||
enablePagination: true,
|
|
||||||
showHeader: true,
|
|
||||||
stripedRows: true,
|
|
||||||
compactMode: false,
|
|
||||||
cardColumns: 3,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사이드바 열릴 때 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setTitle(element.title || "📋 리스트");
|
|
||||||
if (element.dataSource) {
|
|
||||||
setDataSource(element.dataSource);
|
|
||||||
}
|
|
||||||
if (element.listConfig) {
|
|
||||||
setListConfig(element.listConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, element]);
|
|
||||||
|
|
||||||
// Esc 키로 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleEsc);
|
|
||||||
return () => window.removeEventListener("keydown", handleEsc);
|
|
||||||
}, [isOpen, onClose]);
|
|
||||||
|
|
||||||
// 데이터 소스 타입 변경
|
|
||||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
|
||||||
if (type === "database") {
|
|
||||||
setDataSource({
|
|
||||||
type: "database",
|
|
||||||
connectionType: "current",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDataSource({
|
|
||||||
type: "api",
|
|
||||||
method: "GET",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setQueryResult(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 데이터 소스 업데이트
|
|
||||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
|
||||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 쿼리 실행 결과 처리
|
|
||||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
|
||||||
setQueryResult(result);
|
|
||||||
|
|
||||||
// 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기)
|
|
||||||
const newColumns = result.columns.map((col, idx) => ({
|
|
||||||
id: `col_${Date.now()}_${idx}`,
|
|
||||||
field: col,
|
|
||||||
label: col,
|
|
||||||
visible: true,
|
|
||||||
align: "left" as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setListConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
columns: newColumns,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컬럼 설정 변경
|
|
||||||
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
|
|
||||||
setListConfig((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 적용
|
|
||||||
const handleApply = useCallback(() => {
|
|
||||||
const updatedElement: DashboardElement = {
|
|
||||||
...element,
|
|
||||||
title,
|
|
||||||
dataSource,
|
|
||||||
listConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
onApply(updatedElement);
|
|
||||||
}, [element, title, dataSource, listConfig, onApply]);
|
|
||||||
|
|
||||||
// 저장 가능 여부
|
|
||||||
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">📋</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-foreground">리스트 위젯 설정</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문: 스크롤 가능 영역 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
{/* 기본 설정 */}
|
|
||||||
<div className="mb-3 rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">기본 설정</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
placeholder="리스트 이름"
|
|
||||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background focus:ring-1 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 데이터 소스 */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">데이터 소스</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultValue={dataSource.type}
|
|
||||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-muted p-0.5">
|
|
||||||
<TabsTrigger
|
|
||||||
value="database"
|
|
||||||
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
데이터베이스
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="api"
|
|
||||||
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
REST API
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="database" className="mt-2 space-y-2">
|
|
||||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={dataSource}
|
|
||||||
onDataSourceChange={handleDataSourceUpdate}
|
|
||||||
onQueryTest={handleQueryTest}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="api" className="mt-2 space-y-2">
|
|
||||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* 데이터 로드 상태 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-success/10 px-2 py-1">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-success" />
|
|
||||||
<span className="text-[10px] font-medium text-success">{queryResult.rows.length}개 데이터 로드됨</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">컬럼 설정</div>
|
|
||||||
<UnifiedColumnEditor
|
|
||||||
queryResult={queryResult}
|
|
||||||
config={listConfig}
|
|
||||||
onConfigChange={handleListConfigChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
|
|
||||||
{listConfig.columns.length > 0 && (
|
|
||||||
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">테이블 옵션</div>
|
|
||||||
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터: 적용 버튼 */}
|
|
||||||
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
disabled={!canApply}
|
|
||||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-primary-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
적용
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import { DashboardElement } from "../types";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface YardWidgetConfigSidebarProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (updates: Partial<DashboardElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
|
|
||||||
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
|
|
||||||
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setCustomTitle(element.customTitle || "");
|
|
||||||
setShowHeader(element.showHeader !== false);
|
|
||||||
}
|
|
||||||
}, [isOpen, element]);
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
onApply({
|
|
||||||
customTitle,
|
|
||||||
showHeader,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">🏗️</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-foreground">야드 관리 위젯 설정</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컨텐츠 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 위젯 제목 */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">위젯 제목</div>
|
|
||||||
<Input
|
|
||||||
value={customTitle}
|
|
||||||
onChange={(e) => setCustomTitle(e.target.value)}
|
|
||||||
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">기본 제목: 야드 관리 3D</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 표시 */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">헤더 표시</div>
|
|
||||||
<RadioGroup
|
|
||||||
value={showHeader ? "show" : "hide"}
|
|
||||||
onValueChange={(value) => setShowHeader(value === "show")}
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
표시
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
숨김
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-primary-foreground transition-colors"
|
|
||||||
>
|
|
||||||
적용
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,516 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react";
|
|
||||||
import { DatabaseConfig } from "../../data-sources/DatabaseConfig";
|
|
||||||
import { ChartDataSource } from "../../types";
|
|
||||||
import { ApiConfig } from "../../data-sources/ApiConfig";
|
|
||||||
import { QueryEditor } from "../../QueryEditor";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface CustomMetricConfigSidebarProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (updates: Partial<DashboardElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CustomMetricConfigSidebar({
|
|
||||||
element,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}: CustomMetricConfigSidebarProps) {
|
|
||||||
const [metrics, setMetrics] = useState<CustomMetricConfig["metrics"]>(element.customMetricConfig?.metrics || []);
|
|
||||||
const [expandedMetric, setExpandedMetric] = useState<string | null>(null);
|
|
||||||
const [queryColumns, setQueryColumns] = useState<string[]>([]);
|
|
||||||
const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database");
|
|
||||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
|
||||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
|
||||||
);
|
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
||||||
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || element.title || "");
|
|
||||||
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
|
|
||||||
const [groupByMode, setGroupByMode] = useState<boolean>(element.customMetricConfig?.groupByMode || false);
|
|
||||||
const [groupByDataSource, setGroupByDataSource] = useState<ChartDataSource | undefined>(
|
|
||||||
element.customMetricConfig?.groupByDataSource,
|
|
||||||
);
|
|
||||||
const [groupByQueryColumns, setGroupByQueryColumns] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// 쿼리 실행 결과 처리 (일반 지표용)
|
|
||||||
const handleQueryTest = (result: any) => {
|
|
||||||
// QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } }
|
|
||||||
if (result.success && result.data?.columns) {
|
|
||||||
setQueryColumns(result.data.columns);
|
|
||||||
}
|
|
||||||
// ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] }
|
|
||||||
else if (result.columns && Array.isArray(result.columns)) {
|
|
||||||
setQueryColumns(result.columns);
|
|
||||||
}
|
|
||||||
// 오류 처리
|
|
||||||
else {
|
|
||||||
setQueryColumns([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 쿼리 실행 결과 처리 (그룹별 카드용)
|
|
||||||
const handleGroupByQueryTest = (result: any) => {
|
|
||||||
if (result.success && result.data?.columns) {
|
|
||||||
setGroupByQueryColumns(result.data.columns);
|
|
||||||
} else if (result.columns && Array.isArray(result.columns)) {
|
|
||||||
setGroupByQueryColumns(result.columns);
|
|
||||||
} else {
|
|
||||||
setGroupByQueryColumns([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 메트릭 추가
|
|
||||||
const addMetric = () => {
|
|
||||||
const newMetric = {
|
|
||||||
id: uuidv4(),
|
|
||||||
field: "",
|
|
||||||
label: "새 지표",
|
|
||||||
aggregation: "count" as const,
|
|
||||||
unit: "",
|
|
||||||
color: "gray" as const,
|
|
||||||
decimals: 1,
|
|
||||||
};
|
|
||||||
setMetrics([...metrics, newMetric]);
|
|
||||||
setExpandedMetric(newMetric.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 메트릭 삭제
|
|
||||||
const deleteMetric = (id: string) => {
|
|
||||||
setMetrics(metrics.filter((m) => m.id !== id));
|
|
||||||
if (expandedMetric === id) {
|
|
||||||
setExpandedMetric(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 메트릭 업데이트
|
|
||||||
const updateMetric = (id: string, field: string, value: any) => {
|
|
||||||
setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 메트릭 순서 변경
|
|
||||||
// 드래그 앤 드롭 핸들러
|
|
||||||
const handleDragStart = (index: number) => {
|
|
||||||
setDraggedIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragOverIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (draggedIndex === null || draggedIndex === dropIndex) {
|
|
||||||
setDraggedIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMetrics = [...metrics];
|
|
||||||
const [draggedItem] = newMetrics.splice(draggedIndex, 1);
|
|
||||||
newMetrics.splice(dropIndex, 0, draggedItem);
|
|
||||||
|
|
||||||
setMetrics(newMetrics);
|
|
||||||
setDraggedIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setDraggedIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 데이터 소스 업데이트
|
|
||||||
const handleDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
|
|
||||||
const newDataSource = { ...dataSource, ...updates };
|
|
||||||
setDataSource(newDataSource);
|
|
||||||
onApply({ dataSource: newDataSource });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 데이터 소스 타입 변경
|
|
||||||
const handleDataSourceTypeChange = (type: "database" | "api") => {
|
|
||||||
setDataSourceType(type);
|
|
||||||
const newDataSource: ChartDataSource =
|
|
||||||
type === "database"
|
|
||||||
? { type: "database", connectionType: "current", refreshInterval: 0 }
|
|
||||||
: { type: "api", method: "GET", refreshInterval: 0 };
|
|
||||||
|
|
||||||
setDataSource(newDataSource);
|
|
||||||
onApply({ dataSource: newDataSource });
|
|
||||||
setQueryColumns([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 그룹별 데이터 소스 업데이트
|
|
||||||
const handleGroupByDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
|
|
||||||
const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource;
|
|
||||||
setGroupByDataSource(newDataSource);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 저장
|
|
||||||
const handleSave = () => {
|
|
||||||
onApply({
|
|
||||||
customTitle: customTitle,
|
|
||||||
showHeader: showHeader,
|
|
||||||
customMetricConfig: {
|
|
||||||
groupByMode,
|
|
||||||
groupByDataSource: groupByMode ? groupByDataSource : undefined,
|
|
||||||
metrics,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">📊</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-foreground">커스텀 카드 설정</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문: 스크롤 가능 영역 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 헤더 설정 */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">헤더 설정</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 제목 입력 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">제목</label>
|
|
||||||
<Input
|
|
||||||
value={customTitle}
|
|
||||||
onChange={(e) => setCustomTitle(e.target.value)}
|
|
||||||
placeholder="위젯 제목을 입력하세요"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 표시 여부 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-[9px] font-medium text-muted-foreground">헤더 표시</label>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHeader(!showHeader)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
||||||
showHeader ? "bg-primary" : "bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
|
||||||
showHeader ? "translate-x-5" : "translate-x-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 데이터 소스 타입 선택 */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">데이터 소스 타입</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDataSourceTypeChange("database")}
|
|
||||||
className={`flex h-16 items-center justify-center rounded border transition-all ${
|
|
||||||
dataSourceType === "database"
|
|
||||||
? "border-primary bg-primary/5 text-primary"
|
|
||||||
: "border-border bg-muted text-foreground hover:border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium">데이터베이스</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDataSourceTypeChange("api")}
|
|
||||||
className={`flex h-16 items-center justify-center rounded border transition-all ${
|
|
||||||
dataSourceType === "api"
|
|
||||||
? "border-primary bg-primary/5 text-primary"
|
|
||||||
: "border-border bg-muted text-foreground hover:border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium">REST API</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 데이터 소스 설정 */}
|
|
||||||
{dataSourceType === "database" ? (
|
|
||||||
<>
|
|
||||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={dataSource}
|
|
||||||
onDataSourceChange={handleDataSourceUpdate}
|
|
||||||
onQueryTest={handleQueryTest}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 일반 지표 설정 (항상 표시) */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">일반 지표</div>
|
|
||||||
{queryColumns.length > 0 && (
|
|
||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{queryColumns.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">먼저 쿼리를 실행하세요</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{metrics.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">추가된 지표가 없습니다</p>
|
|
||||||
) : (
|
|
||||||
metrics.map((metric, index) => (
|
|
||||||
<div
|
|
||||||
key={metric.id}
|
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
|
||||||
onDrop={(e) => handleDrop(e, index)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-md border bg-background p-2 transition-all",
|
|
||||||
draggedIndex === index && "opacity-50",
|
|
||||||
dragOverIndex === index && draggedIndex !== index && "border-primary border-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex w-full items-center gap-2">
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
onDragStart={() => handleDragStart(index)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
className="cursor-grab active:cursor-grabbing"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr,auto,auto] items-center gap-2">
|
|
||||||
<span className="truncate text-xs font-medium text-foreground">
|
|
||||||
{metric.label || "새 지표"}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{metric.aggregation.toUpperCase()}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedMetric(expandedMetric === metric.id ? null : metric.id)}
|
|
||||||
className="flex items-center justify-center rounded p-0.5 hover:bg-muted"
|
|
||||||
>
|
|
||||||
{expandedMetric === metric.id ? (
|
|
||||||
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 설정 영역 */}
|
|
||||||
{expandedMetric === metric.id && (
|
|
||||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
|
||||||
{/* 2열 그리드 레이아웃 */}
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
{/* 컬럼 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">컬럼</label>
|
|
||||||
<Select
|
|
||||||
value={metric.field}
|
|
||||||
onValueChange={(value) => updateMetric(metric.id, "field", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 w-full text-[10px]">
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{queryColumns.map((col) => (
|
|
||||||
<SelectItem key={col} value={col}>
|
|
||||||
{col}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 집계 함수 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">집계</label>
|
|
||||||
<Select
|
|
||||||
value={metric.aggregation}
|
|
||||||
onValueChange={(value: any) => updateMetric(metric.id, "aggregation", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 w-full text-[10px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="count">COUNT</SelectItem>
|
|
||||||
<SelectItem value="sum">SUM</SelectItem>
|
|
||||||
<SelectItem value="avg">AVG</SelectItem>
|
|
||||||
<SelectItem value="min">MIN</SelectItem>
|
|
||||||
<SelectItem value="max">MAX</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 단위 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">단위</label>
|
|
||||||
<Input
|
|
||||||
value={metric.unit}
|
|
||||||
onChange={(e) => updateMetric(metric.id, "unit", e.target.value)}
|
|
||||||
className="h-6 w-full text-[10px]"
|
|
||||||
placeholder="건, %, km"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 소수점 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">소수점</label>
|
|
||||||
<Select
|
|
||||||
value={String(metric.decimals)}
|
|
||||||
onValueChange={(value) => updateMetric(metric.id, "decimals", parseInt(value))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 w-full text-[10px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[0, 1, 2].map((num) => (
|
|
||||||
<SelectItem key={num} value={String(num)}>
|
|
||||||
{num}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 표시 이름 (전체 너비) */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">표시 이름</label>
|
|
||||||
<Input
|
|
||||||
value={metric.label}
|
|
||||||
onChange={(e) => updateMetric(metric.id, "label", e.target.value)}
|
|
||||||
className="h-6 w-full text-[10px]"
|
|
||||||
placeholder="라벨"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
<div className="border-t border-border pt-1.5">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-6 w-full gap-1 text-[10px]"
|
|
||||||
onClick={() => deleteMetric(metric.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 그룹별 카드 생성 모드 (항상 표시) */}
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">표시 모드</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-foreground">그룹별 카드 생성</label>
|
|
||||||
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
|
||||||
쿼리 결과의 각 행을 개별 카드로 표시
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setGroupByMode(!groupByMode);
|
|
||||||
if (!groupByMode && !groupByDataSource) {
|
|
||||||
// 그룹별 모드 활성화 시 기본 데이터 소스 초기화
|
|
||||||
setGroupByDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
||||||
groupByMode ? "bg-primary" : "bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
|
||||||
groupByMode ? "translate-x-5" : "translate-x-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{groupByMode && (
|
|
||||||
<div className="rounded-md bg-primary/10 p-2 text-[9px] text-primary">
|
|
||||||
<p className="font-medium">💡 사용 방법</p>
|
|
||||||
<ul className="mt-1 space-y-0.5 pl-3 text-[8px]">
|
|
||||||
<li>• 첫 번째 컬럼: 카드 제목</li>
|
|
||||||
<li>• 두 번째 컬럼: 카드 값</li>
|
|
||||||
<li>• 예: SELECT status, COUNT(*) FROM drivers GROUP BY status</li>
|
|
||||||
<li>• <strong>아래 별도 쿼리로 설정</strong> (일반 지표와 독립적)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */}
|
|
||||||
{groupByMode && groupByDataSource && (
|
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">
|
|
||||||
그룹별 카드 쿼리
|
|
||||||
</div>
|
|
||||||
<DatabaseConfig dataSource={groupByDataSource} onChange={handleGroupByDataSourceUpdate} />
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={groupByDataSource}
|
|
||||||
onDataSourceChange={handleGroupByDataSourceUpdate}
|
|
||||||
onQueryTest={handleGroupByQueryTest}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div className="flex gap-2 border-t bg-background p-3 shadow-sm">
|
|
||||||
<Button variant="outline" className="h-8 flex-1 text-xs" onClick={onClose}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button className="focus:ring-primary/20 h-8 flex-1 text-xs" onClick={handleSave}>
|
|
||||||
적용
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { DepartmentStructure } from "./DepartmentStructure";
|
||||||
|
import { DepartmentMembers } from "./DepartmentMembers";
|
||||||
|
import type { Department } from "@/types/department";
|
||||||
|
import { getCompanyList } from "@/lib/api/company";
|
||||||
|
|
||||||
|
interface DepartmentManagementProps {
|
||||||
|
companyCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 관리 메인 컴포넌트
|
||||||
|
* 좌측: 부서 구조, 우측: 부서 인원
|
||||||
|
*/
|
||||||
|
export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("structure");
|
||||||
|
const [companyName, setCompanyName] = useState<string>("");
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
|
// 부서원 변경 시 부서 구조 새로고침
|
||||||
|
const handleMemberChange = () => {
|
||||||
|
setRefreshTrigger((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 회사 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCompanyInfo = async () => {
|
||||||
|
const response = await getCompanyList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const company = response.data.find((c) => c.company_code === companyCode);
|
||||||
|
if (company) {
|
||||||
|
setCompanyName(company.company_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadCompanyInfo();
|
||||||
|
}, [companyCode]);
|
||||||
|
|
||||||
|
const handleBackToList = () => {
|
||||||
|
router.push("/admin/company");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
|
||||||
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
회사 목록
|
||||||
|
</Button>
|
||||||
|
<div className="bg-border h-6 w-px" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">부서 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 탭 네비게이션 (모바일용) */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="structure">부서 구조</TabsTrigger>
|
||||||
|
<TabsTrigger value="members">부서 인원</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="structure" className="mt-4">
|
||||||
|
<DepartmentStructure
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onSelectDepartment={setSelectedDepartment}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="members" className="mt-4">
|
||||||
|
<DepartmentMembers
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onMemberChange={handleMemberChange}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 좌우 레이아웃 (데스크톱) */}
|
||||||
|
<div className="hidden h-full gap-6 lg:flex">
|
||||||
|
{/* 좌측: 부서 구조 (20%) */}
|
||||||
|
<div className="w-[20%] border-r pr-6">
|
||||||
|
<DepartmentStructure
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onSelectDepartment={setSelectedDepartment}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 부서 인원 (80%) */}
|
||||||
|
<div className="w-[80%] pl-0">
|
||||||
|
<DepartmentMembers
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onMemberChange={handleMemberChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,452 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Plus, X, Star } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { Department, DepartmentMember } from "@/types/department";
|
||||||
|
import * as departmentAPI from "@/lib/api/department";
|
||||||
|
|
||||||
|
interface DepartmentMembersProps {
|
||||||
|
companyCode: string;
|
||||||
|
selectedDepartment: Department | null;
|
||||||
|
onMemberChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 인원 관리 컴포넌트
|
||||||
|
*/
|
||||||
|
export function DepartmentMembers({
|
||||||
|
companyCode,
|
||||||
|
selectedDepartment,
|
||||||
|
onMemberChange,
|
||||||
|
}: DepartmentMembersProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [members, setMembers] = useState<DepartmentMember[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false); // 검색 버튼을 눌렀는지 여부
|
||||||
|
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 부서원 삭제 확인 모달
|
||||||
|
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||||
|
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
// 부서원 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDepartment) {
|
||||||
|
loadMembers();
|
||||||
|
}
|
||||||
|
}, [selectedDepartment]);
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
if (!selectedDepartment) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.getDepartmentMembers(selectedDepartment.dept_code);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setMembers(response.data);
|
||||||
|
} else {
|
||||||
|
console.error("부서원 목록 로드 실패:", response.error);
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부서원 목록 로드 실패:", error);
|
||||||
|
setMembers([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 검색
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setHasSearched(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setHasSearched(true); // 검색 버튼을 눌렀음을 표시
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.searchUsers(companyCode, searchQuery);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setSearchResults(response.data);
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("사용자 검색 실패:", error);
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서원 추가
|
||||||
|
const handleAddMember = async (userId: string) => {
|
||||||
|
if (!selectedDepartment) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.addDepartmentMember(
|
||||||
|
selectedDepartment.dept_code,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setIsAddModalOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
setHasSearched(false); // 검색 상태 초기화
|
||||||
|
loadMembers();
|
||||||
|
onMemberChange?.(); // 부서 구조 새로고침
|
||||||
|
|
||||||
|
// 성공 Toast 표시
|
||||||
|
toast({
|
||||||
|
title: "부서원 추가 완료",
|
||||||
|
description: "부서원이 추가되었습니다.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if ((response as any).isDuplicate) {
|
||||||
|
setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다.");
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "부서원 추가 실패",
|
||||||
|
description: response.error || "부서원 추가에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부서원 추가 실패:", error);
|
||||||
|
toast({
|
||||||
|
title: "부서원 추가 실패",
|
||||||
|
description: "부서원 추가 중 오류가 발생했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서원 제거 확인 요청
|
||||||
|
const handleRemoveMemberRequest = (userId: string, userName: string) => {
|
||||||
|
setMemberToRemove({ userId, name: userName });
|
||||||
|
setRemoveConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서원 제거 실행
|
||||||
|
const handleRemoveMemberConfirm = async () => {
|
||||||
|
if (!selectedDepartment || !memberToRemove) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.removeDepartmentMember(
|
||||||
|
selectedDepartment.dept_code,
|
||||||
|
memberToRemove.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setRemoveConfirmOpen(false);
|
||||||
|
setMemberToRemove(null);
|
||||||
|
loadMembers();
|
||||||
|
onMemberChange?.(); // 부서 구조 새로고침
|
||||||
|
|
||||||
|
// 성공 Toast 표시
|
||||||
|
toast({
|
||||||
|
title: "부서원 제거 완료",
|
||||||
|
description: `${memberToRemove.name} 님이 부서에서 제외되었습니다.`,
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "부서원 제거 실패",
|
||||||
|
description: response.error || "부서원 제거에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부서원 제거 실패:", error);
|
||||||
|
toast({
|
||||||
|
title: "부서원 제거 실패",
|
||||||
|
description: "부서원 제거 중 오류가 발생했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 주 부서 설정
|
||||||
|
const handleSetPrimaryDepartment = async (userId: string) => {
|
||||||
|
if (!selectedDepartment) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.setPrimaryDepartment(
|
||||||
|
selectedDepartment.dept_code,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
loadMembers();
|
||||||
|
|
||||||
|
// 성공 Toast 표시
|
||||||
|
toast({
|
||||||
|
title: "주 부서 설정 완료",
|
||||||
|
description: "주 부서가 변경되었습니다.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "주 부서 설정 실패",
|
||||||
|
description: response.error || "주 부서 설정에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("주 부서 설정 실패:", error);
|
||||||
|
toast({
|
||||||
|
title: "주 부서 설정 실패",
|
||||||
|
description: "주 부서 설정 중 오류가 발생했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedDepartment) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card p-8 shadow-sm">
|
||||||
|
<p className="text-sm text-muted-foreground">좌측에서 부서를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{selectedDepartment.dept_name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">부서원 {members.length}명</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => setIsAddModalOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
부서원 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서원 목록 */}
|
||||||
|
<div className="space-y-2 rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">로딩 중...</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
부서원이 없습니다. 부서원을 추가해주세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.user_id}
|
||||||
|
className="flex items-center justify-between rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{member.user_name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">({member.user_id})</span>
|
||||||
|
{member.is_primary && (
|
||||||
|
<Badge variant="default" className="h-5 gap-1 text-xs">
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
주 부서
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-4 text-xs text-muted-foreground">
|
||||||
|
{member.position_name && <span>직책: {member.position_name}</span>}
|
||||||
|
{member.email && <span>이메일: {member.email}</span>}
|
||||||
|
{member.phone && <span>전화: {member.phone}</span>}
|
||||||
|
{member.cell_phone && <span>휴대폰: {member.cell_phone}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!member.is_primary && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-xs"
|
||||||
|
onClick={() => handleSetPrimaryDepartment(member.user_id)}
|
||||||
|
>
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
주 부서 설정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => handleRemoveMemberRequest(member.user_id, member.user_name)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서원 추가 모달 */}
|
||||||
|
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">부서원 추가</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="search" className="text-xs sm:text-sm">
|
||||||
|
사용자 검색 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="이름 또는 ID로 검색"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!searchQuery.trim() || isSearching}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
겸직이 가능합니다. 한 사용자를 여러 부서에 추가할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 결과 */}
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">검색 중...</div>
|
||||||
|
) : hasSearched && searchResults.length > 0 ? (
|
||||||
|
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||||
|
{searchResults.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.user_id}
|
||||||
|
className="flex cursor-pointer items-center justify-between rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
||||||
|
onClick={() => handleAddMember(user.user_id)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{user.user_name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">({user.user_id})</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-3 text-xs text-muted-foreground">
|
||||||
|
{user.position_name && <span>{user.position_name}</span>}
|
||||||
|
{user.email && <span>{user.email}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : hasSearched && searchResults.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAddModalOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
setHasSearched(false); // 검색 상태 초기화
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 중복 알림 모달 */}
|
||||||
|
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(null)}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">중복 알림</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm">{duplicateMessage}</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDuplicateMessage(null)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 부서원 제거 확인 모달 */}
|
||||||
|
<Dialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">부서원 제거 확인</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-semibold">{memberToRemove?.name}</span> 님을 이 부서에서 제외하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
다른 부서에는 영향을 주지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setRemoveConfirmOpen(false);
|
||||||
|
setMemberToRemove(null);
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleRemoveMemberConfirm}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
제거
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,383 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { Department } from "@/types/department";
|
||||||
|
import * as departmentAPI from "@/lib/api/department";
|
||||||
|
|
||||||
|
interface DepartmentStructureProps {
|
||||||
|
companyCode: string;
|
||||||
|
selectedDepartment: Department | null;
|
||||||
|
onSelectDepartment: (department: Department | null) => void;
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 구조 컴포넌트 (트리 형태)
|
||||||
|
*/
|
||||||
|
export function DepartmentStructure({
|
||||||
|
companyCode,
|
||||||
|
selectedDepartment,
|
||||||
|
onSelectDepartment,
|
||||||
|
refreshTrigger,
|
||||||
|
}: DepartmentStructureProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 부서 추가 모달
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
|
||||||
|
const [newDeptName, setNewDeptName] = useState("");
|
||||||
|
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 부서 삭제 확인 모달
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
|
||||||
|
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 부서 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadDepartments();
|
||||||
|
}, [companyCode, refreshTrigger]);
|
||||||
|
|
||||||
|
const loadDepartments = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.getDepartments(companyCode);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setDepartments(response.data);
|
||||||
|
} else {
|
||||||
|
console.error("부서 목록 로드 실패:", response.error);
|
||||||
|
setDepartments([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부서 목록 로드 실패:", error);
|
||||||
|
setDepartments([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 트리 구조 생성
|
||||||
|
const buildTree = (parentCode: string | null): Department[] => {
|
||||||
|
return departments
|
||||||
|
.filter((dept) => dept.parent_dept_code === parentCode)
|
||||||
|
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 추가 핸들러
|
||||||
|
const handleAddDepartment = (parentDeptCode: string | null = null) => {
|
||||||
|
setParentDeptForAdd(parentDeptCode);
|
||||||
|
setNewDeptName("");
|
||||||
|
setIsAddModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 저장
|
||||||
|
const handleSaveDepartment = async () => {
|
||||||
|
if (!newDeptName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.createDepartment(companyCode, {
|
||||||
|
dept_name: newDeptName,
|
||||||
|
parent_dept_code: parentDeptForAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setIsAddModalOpen(false);
|
||||||
|
setNewDeptName("");
|
||||||
|
setParentDeptForAdd(null);
|
||||||
|
loadDepartments();
|
||||||
|
|
||||||
|
// 성공 Toast 표시
|
||||||
|
toast({
|
||||||
|
title: "부서 생성 완료",
|
||||||
|
description: `"${newDeptName}" 부서가 생성되었습니다.`,
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if ((response as any).isDuplicate) {
|
||||||
|
setDuplicateMessage(response.error || "이미 존재하는 부서명입니다.");
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "부서 생성 실패",
|
||||||
|
description: response.error || "부서 추가에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부서 추가 실패:", error);
|
||||||
|
toast({
|
||||||
|
title: "부서 생성 실패",
|
||||||
|
description: "부서 추가 중 오류가 발생했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 삭제 확인 요청
|
||||||
|
const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => {
|
||||||
|
setDeptToDelete({ code: deptCode, name: deptName });
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 삭제 실행
|
||||||
|
const handleDeleteDepartmentConfirm = async () => {
|
||||||
|
if (!deptToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await departmentAPI.deleteDepartment(deptToDelete.code);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// 삭제된 부서가 선택되어 있었다면 선택 해제
|
||||||
|
if (selectedDepartment?.dept_code === deptToDelete.code) {
|
||||||
|
onSelectDepartment(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeptToDelete(null);
|
||||||
|
loadDepartments();
|
||||||
|
|
||||||
|
// 성공 메시지 Toast로 표시 (부서원 수 포함)
|
||||||
|
toast({
|
||||||
|
title: "부서 삭제 완료",
|
||||||
|
description: response.message || "부서가 삭제되었습니다.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 삭제 확인 모달을 닫고 에러 모달을 표시
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeptToDelete(null);
|
||||||
|
setDeleteErrorMessage(response.error || "부서 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부서 삭제 실패:", error);
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeptToDelete(null);
|
||||||
|
setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 확장/축소 토글
|
||||||
|
const toggleExpand = (deptCode: string) => {
|
||||||
|
const newExpanded = new Set(expandedDepts);
|
||||||
|
if (newExpanded.has(deptCode)) {
|
||||||
|
newExpanded.delete(deptCode);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(deptCode);
|
||||||
|
}
|
||||||
|
setExpandedDepts(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 트리 렌더링 (재귀)
|
||||||
|
const renderDepartmentTree = (parentCode: string | null, level: number = 0) => {
|
||||||
|
const children = buildTree(parentCode);
|
||||||
|
|
||||||
|
return children.map((dept) => {
|
||||||
|
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
|
||||||
|
const isExpanded = expandedDepts.has(dept.dept_code);
|
||||||
|
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dept.dept_code}>
|
||||||
|
{/* 부서 항목 */}
|
||||||
|
<div
|
||||||
|
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
|
||||||
|
isSelected ? "bg-primary/10 text-primary" : ""
|
||||||
|
}`}
|
||||||
|
style={{ marginLeft: `${level * 16}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
|
||||||
|
{/* 확장/축소 아이콘 */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(dept.dept_code);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 부서명 */}
|
||||||
|
<span className="font-medium">{dept.dept_name}</span>
|
||||||
|
|
||||||
|
{/* 인원수 */}
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
<span>{dept.memberCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAddDepartment(dept.dept_code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하위 부서 (재귀) */}
|
||||||
|
{hasChildren && isExpanded && renderDepartmentTree(dept.dept_code, level + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">부서 구조</h3>
|
||||||
|
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
최상위 부서 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 트리 */}
|
||||||
|
<div className="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
||||||
|
) : departments.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
부서가 없습니다. 최상위 부서를 추가해주세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderDepartmentTree(null)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 추가 모달 */}
|
||||||
|
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dept_name">
|
||||||
|
부서명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dept_name"
|
||||||
|
value={newDeptName}
|
||||||
|
onChange={(e) => setNewDeptName(e.target.value)}
|
||||||
|
placeholder="부서명을 입력하세요"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveDepartment} disabled={!newDeptName.trim()}>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 중복 알림 모달 */}
|
||||||
|
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(null)}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">중복 알림</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm">{duplicateMessage}</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDuplicateMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 부서 삭제 확인 모달 */}
|
||||||
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">부서 삭제 확인</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-semibold">{deptToDelete?.name}</span> 부서를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">이 작업은 되돌릴 수 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeptToDelete(null);
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteDepartmentConfirm}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 부서 삭제 에러 모달 */}
|
||||||
|
<Dialog open={!!deleteErrorMessage} onOpenChange={() => setDeleteErrorMessage(null)}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">삭제 불가</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm">{deleteErrorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDeleteErrorMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
||||||
|
import Webcam from "react-webcam";
|
||||||
|
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||||
|
|
||||||
|
export interface BarcodeScanModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
targetField?: string;
|
||||||
|
barcodeFormat?: "all" | "1d" | "2d";
|
||||||
|
autoSubmit?: boolean;
|
||||||
|
onScanSuccess: (barcode: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
targetField,
|
||||||
|
barcodeFormat = "all",
|
||||||
|
autoSubmit = false,
|
||||||
|
onScanSuccess,
|
||||||
|
}) => {
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [scannedCode, setScannedCode] = useState<string>("");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||||
|
const webcamRef = useRef<Webcam>(null);
|
||||||
|
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||||
|
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 바코드 리더 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||||
|
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopScanning();
|
||||||
|
if (codeReaderRef.current) {
|
||||||
|
codeReaderRef.current.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 카메라 권한 요청
|
||||||
|
const requestCameraPermission = async () => {
|
||||||
|
console.log("🎥 카메라 권한 요청 시작...");
|
||||||
|
|
||||||
|
// navigator.mediaDevices 지원 확인
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
console.error("❌ navigator.mediaDevices를 사용할 수 없습니다.");
|
||||||
|
console.log("현재 프로토콜:", window.location.protocol);
|
||||||
|
console.log("현재 호스트:", window.location.host);
|
||||||
|
setHasPermission(false);
|
||||||
|
setError(
|
||||||
|
"이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " +
|
||||||
|
"현재 프로토콜: " + window.location.protocol
|
||||||
|
);
|
||||||
|
toast.error("카메라 접근이 불가능합니다. 콘솔을 확인해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🔄 getUserMedia 호출 중...");
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
console.log("✅ 카메라 권한 허용됨!");
|
||||||
|
setHasPermission(true);
|
||||||
|
stream.getTracks().forEach((track) => track.stop()); // 권한 확인 후 스트림 종료
|
||||||
|
toast.success("카메라 권한이 허용되었습니다.");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ 카메라 권한 오류:", err);
|
||||||
|
console.error("에러 이름:", err.name);
|
||||||
|
console.error("에러 메시지:", err.message);
|
||||||
|
setHasPermission(false);
|
||||||
|
|
||||||
|
// 에러 타입에 따라 다른 메시지 표시
|
||||||
|
if (err.name === "NotAllowedError") {
|
||||||
|
setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요.");
|
||||||
|
toast.error("카메라 권한이 거부되었습니다.");
|
||||||
|
} else if (err.name === "NotFoundError") {
|
||||||
|
setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요.");
|
||||||
|
toast.error("카메라를 찾을 수 없습니다.");
|
||||||
|
} else if (err.name === "NotReadableError") {
|
||||||
|
setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다.");
|
||||||
|
toast.error("카메라가 사용 중입니다.");
|
||||||
|
} else if (err.name === "NotSupportedError") {
|
||||||
|
setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다.");
|
||||||
|
toast.error("HTTPS 환경이 필요합니다.");
|
||||||
|
} else {
|
||||||
|
setError(`카메라 접근 오류: ${err.name} - ${err.message}`);
|
||||||
|
toast.error("카메라 접근 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스캔 시작
|
||||||
|
const startScanning = () => {
|
||||||
|
setIsScanning(true);
|
||||||
|
setError("");
|
||||||
|
setScannedCode("");
|
||||||
|
|
||||||
|
// 주기적으로 스캔 시도 (500ms마다)
|
||||||
|
scanIntervalRef.current = setInterval(() => {
|
||||||
|
scanBarcode();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스캔 중지
|
||||||
|
const stopScanning = () => {
|
||||||
|
setIsScanning(false);
|
||||||
|
if (scanIntervalRef.current) {
|
||||||
|
clearInterval(scanIntervalRef.current);
|
||||||
|
scanIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 바코드 스캔
|
||||||
|
const scanBarcode = async () => {
|
||||||
|
if (!webcamRef.current || !codeReaderRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageSrc = webcamRef.current.getScreenshot();
|
||||||
|
if (!imageSrc) return;
|
||||||
|
|
||||||
|
// 이미지를 HTMLImageElement로 변환
|
||||||
|
const img = new Image();
|
||||||
|
img.src = imageSrc;
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 바코드 디코딩
|
||||||
|
const result = await codeReaderRef.current.decodeFromImageElement(img);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const barcode = result.getText();
|
||||||
|
console.log("✅ 바코드 스캔 성공:", barcode);
|
||||||
|
|
||||||
|
setScannedCode(barcode);
|
||||||
|
stopScanning();
|
||||||
|
toast.success(`바코드 스캔 완료: ${barcode}`);
|
||||||
|
|
||||||
|
// 자동 제출 옵션이 켜져있으면 바로 콜백 실행
|
||||||
|
if (autoSubmit) {
|
||||||
|
onScanSuccess(barcode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// NotFoundException은 정상적인 상황 (바코드가 아직 인식되지 않음)
|
||||||
|
if (!(err instanceof NotFoundException)) {
|
||||||
|
console.error("바코드 스캔 오류:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수동 확인 버튼
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (scannedCode) {
|
||||||
|
onScanSuccess(scannedCode);
|
||||||
|
} else {
|
||||||
|
toast.error("스캔된 바코드가 없습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
카메라로 바코드를 스캔하세요.
|
||||||
|
{targetField && ` (대상 필드: ${targetField})`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 카메라 권한 요청 대기 중 */}
|
||||||
|
{hasPermission === null && (
|
||||||
|
<div className="rounded-md border border-primary bg-primary/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Camera className="mt-0.5 h-5 w-5 flex-shrink-0 text-primary" />
|
||||||
|
<div className="flex-1 space-y-3 text-xs sm:text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">카메라 권한이 필요합니다</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
바코드를 스캔하려면 카메라 접근 권한을 허용해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-background/50 p-3">
|
||||||
|
<p className="mb-2 font-medium text-foreground">💡 권한 요청 안내:</p>
|
||||||
|
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
|
||||||
|
<li>아래 버튼을 클릭하면 브라우저에서 권한 요청 팝업이 표시됩니다</li>
|
||||||
|
<li>팝업에서 <strong>"허용"</strong> 버튼을 클릭해주세요</li>
|
||||||
|
<li>권한은 언제든지 브라우저 설정에서 변경할 수 있습니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={requestCameraPermission}
|
||||||
|
className="h-9 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Camera className="mr-2 h-4 w-4" />
|
||||||
|
카메라 권한 요청
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카메라 권한 거부됨 */}
|
||||||
|
{hasPermission === false && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
||||||
|
<div className="flex-1 space-y-3 text-xs sm:text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-destructive">카메라 접근 권한이 필요합니다</p>
|
||||||
|
<p className="mt-1 text-destructive/80">{error}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-background/50 p-3">
|
||||||
|
<p className="mb-2 font-medium text-foreground">📱 권한 허용 방법:</p>
|
||||||
|
<ol className="ml-4 list-decimal space-y-1 text-muted-foreground">
|
||||||
|
<li>브라우저 주소창 왼쪽의 <strong>🔒 자물쇠 아이콘</strong>을 클릭하세요</li>
|
||||||
|
<li><strong>"카메라"</strong> 항목을 찾아 <strong>"허용"</strong>으로 변경하세요</li>
|
||||||
|
<li>페이지를 새로고침하거나 다시 스캔을 시도하세요</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={requestCameraPermission}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 웹캠 뷰 */}
|
||||||
|
{hasPermission && (
|
||||||
|
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||||
|
<Webcam
|
||||||
|
ref={webcamRef}
|
||||||
|
audio={false}
|
||||||
|
screenshotFormat="image/jpeg"
|
||||||
|
videoConstraints={{
|
||||||
|
facingMode: "environment", // 후면 카메라 우선
|
||||||
|
}}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 스캔 가이드 오버레이 */}
|
||||||
|
{isScanning && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
|
||||||
|
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
||||||
|
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||||
|
스캔 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 스캔 완료 오버레이 */}
|
||||||
|
{scannedCode && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||||
|
<div className="text-center">
|
||||||
|
<CheckCircle2 className="mx-auto h-16 w-16 text-success" />
|
||||||
|
<p className="mt-2 text-sm font-medium">스캔 완료!</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-bold text-primary">{scannedCode}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 바코드 포맷 정보 */}
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
<p className="font-medium">지원 포맷</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"}
|
||||||
|
{barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"}
|
||||||
|
{barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
|
||||||
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!isScanning && !scannedCode && hasPermission && (
|
||||||
|
<Button
|
||||||
|
onClick={startScanning}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<Camera className="mr-2 h-4 w-4" />
|
||||||
|
스캔 시작
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScanning && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={stopScanning}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<CameraOff className="mr-2 h-4 w-4" />
|
||||||
|
스캔 중지
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scannedCode && !autoSubmit && (
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
confirmText?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 삭제 확인 모달 (공통 컴포넌트)
|
||||||
|
* - 표준 디자인: shadcn AlertDialog 기반
|
||||||
|
* - 반응형: 모바일/데스크톱 최적화
|
||||||
|
* - 로딩 상태 지원
|
||||||
|
*/
|
||||||
|
export function DeleteConfirmModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onConfirm,
|
||||||
|
confirmText = "삭제",
|
||||||
|
isLoading = false,
|
||||||
|
}: DeleteConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base sm:text-lg">{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-xs sm:text-sm">{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<AlertDialogCancel disabled={isLoading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,39 @@ interface CustomMetricWidgetProps {
|
||||||
element?: DashboardElement;
|
element?: DashboardElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 필터 적용 함수
|
||||||
|
const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => {
|
||||||
|
if (!filters || filters.length === 0) return rows;
|
||||||
|
|
||||||
|
return rows.filter((row) => {
|
||||||
|
return filters.every((filter) => {
|
||||||
|
const cellValue = String(row[filter.column] || "");
|
||||||
|
const filterValue = filter.value;
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case "=":
|
||||||
|
return cellValue === filterValue;
|
||||||
|
case "!=":
|
||||||
|
return cellValue !== filterValue;
|
||||||
|
case ">":
|
||||||
|
return parseFloat(cellValue) > parseFloat(filterValue);
|
||||||
|
case "<":
|
||||||
|
return parseFloat(cellValue) < parseFloat(filterValue);
|
||||||
|
case ">=":
|
||||||
|
return parseFloat(cellValue) >= parseFloat(filterValue);
|
||||||
|
case "<=":
|
||||||
|
return parseFloat(cellValue) <= parseFloat(filterValue);
|
||||||
|
case "contains":
|
||||||
|
return cellValue.includes(filterValue);
|
||||||
|
case "not_contains":
|
||||||
|
return !cellValue.includes(filterValue);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 집계 함수 실행
|
// 집계 함수 실행
|
||||||
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
|
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
|
||||||
if (rows.length === 0) return 0;
|
if (rows.length === 0) return 0;
|
||||||
|
|
@ -33,22 +66,12 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 색상 스타일 매핑
|
|
||||||
const colorMap = {
|
|
||||||
indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
|
||||||
green: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
|
||||||
blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
|
||||||
purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
|
||||||
orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
|
||||||
gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
|
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
|
||||||
const [metrics, setMetrics] = useState<any[]>([]);
|
const [value, setValue] = useState<number>(0);
|
||||||
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
|
||||||
|
const config = element?.customMetricConfig;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
|
@ -64,14 +87,111 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 그룹별 카드 데이터 로드
|
const dataSourceType = element?.dataSource?.type;
|
||||||
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
|
|
||||||
await loadGroupByData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일반 지표 데이터 로드
|
// Database 타입
|
||||||
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
|
if (dataSourceType === "database") {
|
||||||
await loadMetricsData();
|
if (!element?.dataSource?.query) {
|
||||||
|
setValue(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: element.dataSource.query,
|
||||||
|
connectionType: element.dataSource.connectionType || "current",
|
||||||
|
connectionId: (element.dataSource as any).connectionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data?.rows) {
|
||||||
|
let rows = result.data.rows;
|
||||||
|
|
||||||
|
// 필터 적용
|
||||||
|
if (config?.filters && config.filters.length > 0) {
|
||||||
|
rows = applyFilters(rows, config.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 집계 계산
|
||||||
|
if (config?.valueColumn && config?.aggregation) {
|
||||||
|
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||||
|
setValue(calculatedValue);
|
||||||
|
} else {
|
||||||
|
setValue(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "데이터 로드 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// API 타입
|
||||||
|
else if (dataSourceType === "api") {
|
||||||
|
if (!element?.dataSource?.endpoint) {
|
||||||
|
setValue(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: (element.dataSource as any).method || "GET",
|
||||||
|
url: element.dataSource.endpoint,
|
||||||
|
headers: (element.dataSource as any).headers || {},
|
||||||
|
body: (element.dataSource as any).body,
|
||||||
|
authType: (element.dataSource as any).authType,
|
||||||
|
authConfig: (element.dataSource as any).authConfig,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("API 호출 실패");
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
let rows: any[] = [];
|
||||||
|
|
||||||
|
// API 응답 데이터 구조 확인 및 처리
|
||||||
|
if (Array.isArray(result.data)) {
|
||||||
|
rows = result.data;
|
||||||
|
} else if (result.data.results && Array.isArray(result.data.results)) {
|
||||||
|
rows = result.data.results;
|
||||||
|
} else if (result.data.items && Array.isArray(result.data.items)) {
|
||||||
|
rows = result.data.items;
|
||||||
|
} else if (result.data.data && Array.isArray(result.data.data)) {
|
||||||
|
rows = result.data.data;
|
||||||
|
} else {
|
||||||
|
rows = [result.data];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 적용
|
||||||
|
if (config?.filters && config.filters.length > 0) {
|
||||||
|
rows = applyFilters(rows, config.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 집계 계산
|
||||||
|
if (config?.valueColumn && config?.aggregation) {
|
||||||
|
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||||
|
setValue(calculatedValue);
|
||||||
|
} else {
|
||||||
|
setValue(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("API 응답 형식 오류");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("데이터 로드 실패:", err);
|
console.error("데이터 로드 실패:", err);
|
||||||
|
|
@ -81,221 +201,6 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 그룹별 카드 데이터 로드
|
|
||||||
const loadGroupByData = async () => {
|
|
||||||
const groupByDS = element?.customMetricConfig?.groupByDataSource;
|
|
||||||
if (!groupByDS) return;
|
|
||||||
|
|
||||||
const dataSourceType = groupByDS.type;
|
|
||||||
|
|
||||||
// Database 타입
|
|
||||||
if (dataSourceType === "database") {
|
|
||||||
if (!groupByDS.query) return;
|
|
||||||
|
|
||||||
const token = localStorage.getItem("authToken");
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: groupByDS.query,
|
|
||||||
connectionType: groupByDS.connectionType || "current",
|
|
||||||
connectionId: (groupByDS as any).connectionId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.data?.rows) {
|
|
||||||
const rows = result.data.rows;
|
|
||||||
if (rows.length > 0) {
|
|
||||||
const columns = result.data.columns || Object.keys(rows[0]);
|
|
||||||
const labelColumn = columns[0];
|
|
||||||
const valueColumn = columns[1];
|
|
||||||
|
|
||||||
const cards = rows.map((row: any) => ({
|
|
||||||
label: String(row[labelColumn] || ""),
|
|
||||||
value: parseFloat(row[valueColumn]) || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setGroupedCards(cards);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// API 타입
|
|
||||||
else if (dataSourceType === "api") {
|
|
||||||
if (!groupByDS.endpoint) return;
|
|
||||||
|
|
||||||
const token = localStorage.getItem("authToken");
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
method: (groupByDS as any).method || "GET",
|
|
||||||
url: groupByDS.endpoint,
|
|
||||||
headers: (groupByDS as any).headers || {},
|
|
||||||
body: (groupByDS as any).body,
|
|
||||||
authType: (groupByDS as any).authType,
|
|
||||||
authConfig: (groupByDS as any).authConfig,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
let rows: any[] = [];
|
|
||||||
if (Array.isArray(result.data)) {
|
|
||||||
rows = result.data;
|
|
||||||
} else if (result.data.results && Array.isArray(result.data.results)) {
|
|
||||||
rows = result.data.results;
|
|
||||||
} else if (result.data.items && Array.isArray(result.data.items)) {
|
|
||||||
rows = result.data.items;
|
|
||||||
} else if (result.data.data && Array.isArray(result.data.data)) {
|
|
||||||
rows = result.data.data;
|
|
||||||
} else {
|
|
||||||
rows = [result.data];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
const columns = Object.keys(rows[0]);
|
|
||||||
const labelColumn = columns[0];
|
|
||||||
const valueColumn = columns[1];
|
|
||||||
|
|
||||||
const cards = rows.map((row: any) => ({
|
|
||||||
label: String(row[labelColumn] || ""),
|
|
||||||
value: parseFloat(row[valueColumn]) || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setGroupedCards(cards);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 일반 지표 데이터 로드
|
|
||||||
const loadMetricsData = async () => {
|
|
||||||
const dataSourceType = element?.dataSource?.type;
|
|
||||||
|
|
||||||
// Database 타입
|
|
||||||
if (dataSourceType === "database") {
|
|
||||||
if (!element?.dataSource?.query) {
|
|
||||||
setMetrics([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("authToken");
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: element.dataSource.query,
|
|
||||||
connectionType: element.dataSource.connectionType || "current",
|
|
||||||
connectionId: (element.dataSource as any).connectionId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.data?.rows) {
|
|
||||||
const rows = result.data.rows;
|
|
||||||
|
|
||||||
const calculatedMetrics =
|
|
||||||
element.customMetricConfig?.metrics.map((metric) => {
|
|
||||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
|
||||||
return {
|
|
||||||
...metric,
|
|
||||||
calculatedValue: value,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message || "데이터 로드 실패");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// API 타입
|
|
||||||
else if (dataSourceType === "api") {
|
|
||||||
if (!element?.dataSource?.endpoint) {
|
|
||||||
setMetrics([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("authToken");
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
method: (element.dataSource as any).method || "GET",
|
|
||||||
url: element.dataSource.endpoint,
|
|
||||||
headers: (element.dataSource as any).headers || {},
|
|
||||||
body: (element.dataSource as any).body,
|
|
||||||
authType: (element.dataSource as any).authType,
|
|
||||||
authConfig: (element.dataSource as any).authConfig,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("API 호출 실패");
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
// API 응답 데이터 구조 확인 및 처리
|
|
||||||
let rows: any[] = [];
|
|
||||||
|
|
||||||
// result.data가 배열인 경우
|
|
||||||
if (Array.isArray(result.data)) {
|
|
||||||
rows = result.data;
|
|
||||||
}
|
|
||||||
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
|
|
||||||
else if (result.data.results && Array.isArray(result.data.results)) {
|
|
||||||
rows = result.data.results;
|
|
||||||
}
|
|
||||||
// result.data.items가 배열인 경우
|
|
||||||
else if (result.data.items && Array.isArray(result.data.items)) {
|
|
||||||
rows = result.data.items;
|
|
||||||
}
|
|
||||||
// result.data.data가 배열인 경우
|
|
||||||
else if (result.data.data && Array.isArray(result.data.data)) {
|
|
||||||
rows = result.data.data;
|
|
||||||
}
|
|
||||||
// 그 외의 경우 단일 객체를 배열로 래핑
|
|
||||||
else {
|
|
||||||
rows = [result.data];
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculatedMetrics =
|
|
||||||
element.customMetricConfig?.metrics.map((metric) => {
|
|
||||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
|
||||||
return {
|
|
||||||
...metric,
|
|
||||||
calculatedValue: value,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
|
||||||
} else {
|
|
||||||
throw new Error("API 응답 형식 오류");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-background">
|
<div className="flex h-full items-center justify-center bg-background">
|
||||||
|
|
@ -323,103 +228,64 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 소스 체크
|
// 설정 체크
|
||||||
const hasMetricsDataSource =
|
const hasDataSource =
|
||||||
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
|
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
|
||||||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
|
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
|
||||||
|
|
||||||
const hasGroupByDataSource =
|
const hasConfig = config?.valueColumn && config?.aggregation;
|
||||||
isGroupByMode &&
|
|
||||||
element?.customMetricConfig?.groupByDataSource &&
|
|
||||||
((element.customMetricConfig.groupByDataSource.type === "database" &&
|
|
||||||
element.customMetricConfig.groupByDataSource.query) ||
|
|
||||||
(element.customMetricConfig.groupByDataSource.type === "api" &&
|
|
||||||
element.customMetricConfig.groupByDataSource.endpoint));
|
|
||||||
|
|
||||||
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
|
// 설정이 없으면 안내 화면
|
||||||
|
if (!hasDataSource || !hasConfig) {
|
||||||
// 둘 다 없으면 빈 화면 표시
|
|
||||||
const shouldShowEmpty =
|
|
||||||
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
|
|
||||||
|
|
||||||
if (shouldShowEmpty) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||||
<div className="max-w-xs space-y-2 text-center">
|
<div className="max-w-xs space-y-2 text-center">
|
||||||
<h3 className="text-sm font-bold text-foreground">사용자 커스텀 카드</h3>
|
<h3 className="text-sm font-bold text-foreground">통계 카드</h3>
|
||||||
<div className="space-y-1.5 text-xs text-foreground">
|
<div className="space-y-1.5 text-xs text-foreground">
|
||||||
<p className="font-medium">📊 맞춤형 지표 위젯</p>
|
<p className="font-medium">📊 단일 통계 위젯</p>
|
||||||
<ul className="space-y-0.5 text-left">
|
<ul className="space-y-0.5 text-left">
|
||||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
||||||
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
<li>• 필터 조건으로 데이터를 필터링합니다</li>
|
||||||
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
<li>• 선택한 컬럼에 집계 함수를 적용합니다</li>
|
||||||
<li>• 사용자 정의 단위 설정 가능</li>
|
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
||||||
<li>
|
|
||||||
• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||||
<p className="font-medium">⚙️ 설정 방법</p>
|
<p className="font-medium">⚙️ 설정 방법</p>
|
||||||
<p className="mb-1">
|
<p>1. 데이터 탭에서 쿼리 실행</p>
|
||||||
{isGroupByMode
|
<p>2. 필터 조건 추가 (선택사항)</p>
|
||||||
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
<p>3. 계산 컬럼 및 방식 선택</p>
|
||||||
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
<p>4. 제목 및 단위 입력</p>
|
||||||
</p>
|
|
||||||
{isGroupByMode && <p className="text-[9px]">💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로)
|
// 소수점 자릿수 (기본: 0)
|
||||||
// 실제 측정된 1칸 높이: 119px
|
const decimals = config?.decimals ?? 0;
|
||||||
const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진)
|
const formattedValue = value.toFixed(decimals);
|
||||||
|
|
||||||
|
// 통계 카드 렌더링
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-full w-full items-center justify-center bg-background p-4">
|
||||||
className={`flex h-full w-full overflow-hidden bg-background p-0.5 ${
|
<div className="flex flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
|
||||||
isHorizontalLayout ? "flex-row gap-0.5" : "flex-col gap-0.5"
|
{/* 제목 */}
|
||||||
}`}
|
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||||
>
|
|
||||||
{/* 그룹별 카드 (활성화 시) */}
|
|
||||||
{isGroupByMode &&
|
|
||||||
groupedCards.map((card, index) => {
|
|
||||||
// 색상 순환 (6가지 색상)
|
|
||||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
|
||||||
const colorKey = colorKeys[index % colorKeys.length];
|
|
||||||
const colors = colorMap[colorKey];
|
|
||||||
|
|
||||||
return (
|
{/* 값 */}
|
||||||
<div
|
<div className="flex items-baseline gap-1">
|
||||||
key={`group-${index}`}
|
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||||
>
|
</div>
|
||||||
<div className="text-[8px] leading-tight text-foreground">{card.label}</div>
|
|
||||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 일반 지표 카드 (항상 표시) */}
|
{/* 필터 표시 (디버깅용, 작게) */}
|
||||||
{metrics.map((metric) => {
|
{config?.filters && config.filters.length > 0 && (
|
||||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
<div className="text-muted-foreground mt-2 text-xs">
|
||||||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
필터: {config.filters.length}개 적용됨
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={metric.id}
|
|
||||||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
|
||||||
>
|
|
||||||
<div className="text-[8px] leading-tight text-foreground">{metric.label}</div>
|
|
||||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
|
|
||||||
{formattedValue}
|
|
||||||
<span className="ml-0 text-[8px]">{metric.unit}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,11 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
||||||
<Input
|
<Input
|
||||||
value={flowName}
|
value={flowName}
|
||||||
onChange={(e) => setFlowName(e.target.value)}
|
onChange={(e) => setFlowName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지
|
||||||
|
// FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
className="h-8 w-[200px] text-sm"
|
className="h-8 w-[200px] text-sm"
|
||||||
placeholder="플로우 이름"
|
placeholder="플로우 이름"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
isMobile
|
isMobile
|
||||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
||||||
: "relative top-0 z-auto translate-x-0"
|
: "relative top-0 z-auto translate-x-0"
|
||||||
} flex h-[calc(100vh-3.5rem)] w-[240px] max-w-[240px] min-w-[240px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
} flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
>
|
>
|
||||||
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule";
|
||||||
|
|
||||||
|
interface AutoConfigPanelProps {
|
||||||
|
partType: CodePartType;
|
||||||
|
config?: any;
|
||||||
|
onChange: (config: any) => void;
|
||||||
|
isPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
||||||
|
partType,
|
||||||
|
config = {},
|
||||||
|
onChange,
|
||||||
|
isPreview = false,
|
||||||
|
}) => {
|
||||||
|
// 1. 순번 (자동 증가)
|
||||||
|
if (partType === "sequence") {
|
||||||
|
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.sequenceLength || 3}
|
||||||
|
onChange={(e) =>
|
||||||
|
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">
|
||||||
|
예: 3 → 001, 4 → 0001
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">시작 번호</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={config.startFrom || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...config, startFrom: parseInt(e.target.value) || 1 })
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">날짜 형식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.dateFormat || "YYYYMMDD"}
|
||||||
|
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
|
||||||
|
disabled={isPreview}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FORMAT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
||||||
|
{option.label} ({option.example})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
현재 날짜가 자동으로 입력됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 문자
|
||||||
|
if (partType === "text") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">텍스트 값</Label>
|
||||||
|
<Input
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
interface ManualConfigPanelProps {
|
||||||
|
config?: {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
onChange: (config: any) => void;
|
||||||
|
isPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManualConfigPanel: React.FC<ManualConfigPanelProps> = ({
|
||||||
|
config = {},
|
||||||
|
onChange,
|
||||||
|
isPreview = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">입력값</Label>
|
||||||
|
<Input
|
||||||
|
value={config.value || ""}
|
||||||
|
onChange={(e) => onChange({ ...config, value: e.target.value })}
|
||||||
|
placeholder={config.placeholder || "값을 입력하세요"}
|
||||||
|
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>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">플레이스홀더 (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.placeholder || ""}
|
||||||
|
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||||
|
placeholder="예: 부서코드 입력"
|
||||||
|
disabled={isPreview}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { NumberingRulePart, CodePartType, GenerationMethod, CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||||
|
import { AutoConfigPanel } from "./AutoConfigPanel";
|
||||||
|
import { ManualConfigPanel } from "./ManualConfigPanel";
|
||||||
|
|
||||||
|
interface NumberingRuleCardProps {
|
||||||
|
part: NumberingRulePart;
|
||||||
|
onUpdate: (updates: Partial<NumberingRulePart>) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
isPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
|
part,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
isPreview = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card className="border-border bg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||||
|
규칙 {part.order}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onDelete}
|
||||||
|
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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3 sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={part.partType}
|
||||||
|
onValueChange={(value) => onUpdate({ partType: value as CodePartType })}
|
||||||
|
disabled={isPreview}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CODE_PART_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">생성 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={part.generationMethod}
|
||||||
|
onValueChange={(value) => onUpdate({ generationMethod: value as GenerationMethod })}
|
||||||
|
disabled={isPreview}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto" className="text-xs sm:text-sm">
|
||||||
|
자동 생성
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="manual" className="text-xs sm:text-sm">
|
||||||
|
직접 입력
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{part.generationMethod === "auto" ? (
|
||||||
|
<AutoConfigPanel
|
||||||
|
partType={part.partType}
|
||||||
|
config={part.autoConfig}
|
||||||
|
onChange={(autoConfig) => onUpdate({ autoConfig })}
|
||||||
|
isPreview={isPreview}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ManualConfigPanel
|
||||||
|
config={part.manualConfig}
|
||||||
|
onChange={(manualConfig) => onUpdate({ manualConfig })}
|
||||||
|
isPreview={isPreview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
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";
|
||||||
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||||
|
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||||
|
import {
|
||||||
|
getNumberingRules,
|
||||||
|
createNumberingRule,
|
||||||
|
updateNumberingRule,
|
||||||
|
deleteNumberingRule,
|
||||||
|
} from "@/lib/api/numberingRule";
|
||||||
|
|
||||||
|
interface NumberingRuleDesignerProps {
|
||||||
|
initialConfig?: NumberingRuleConfig;
|
||||||
|
onSave?: (config: NumberingRuleConfig) => void;
|
||||||
|
onChange?: (config: NumberingRuleConfig) => void;
|
||||||
|
maxRules?: number;
|
||||||
|
isPreview?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
|
initialConfig,
|
||||||
|
onSave,
|
||||||
|
onChange,
|
||||||
|
maxRules = 6,
|
||||||
|
isPreview = false,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||||
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
|
||||||
|
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
||||||
|
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||||
|
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadRules = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getNumberingRules();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setSavedRules(response.data);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`로딩 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentRule) {
|
||||||
|
onChange?.(currentRule);
|
||||||
|
}
|
||||||
|
}, [currentRule, onChange]);
|
||||||
|
|
||||||
|
const handleAddPart = useCallback(() => {
|
||||||
|
if (!currentRule) return;
|
||||||
|
|
||||||
|
if (currentRule.parts.length >= maxRules) {
|
||||||
|
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPart: NumberingRulePart = {
|
||||||
|
id: `part-${Date.now()}`,
|
||||||
|
order: currentRule.parts.length + 1,
|
||||||
|
partType: "text",
|
||||||
|
generationMethod: "auto",
|
||||||
|
autoConfig: { textValue: "CODE" },
|
||||||
|
};
|
||||||
|
|
||||||
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return { ...prev, parts: [...prev.parts, newPart] };
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||||
|
}, [currentRule, maxRules]);
|
||||||
|
|
||||||
|
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
|
||||||
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeletePart = useCallback((partId: string) => {
|
||||||
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts
|
||||||
|
.filter((part) => part.id !== partId)
|
||||||
|
.map((part, index) => ({ ...part, order: index + 1 })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("규칙이 삭제되었습니다");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!currentRule) {
|
||||||
|
toast.error("저장할 규칙이 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRule.parts.length === 0) {
|
||||||
|
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (existing) {
|
||||||
|
response = await updateNumberingRule(currentRule.ruleId, currentRule);
|
||||||
|
} else {
|
||||||
|
response = await createNumberingRule(currentRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setSavedRules((prev) => {
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r));
|
||||||
|
} else {
|
||||||
|
return [...prev, response.data!];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentRule(response.data);
|
||||||
|
setSelectedRuleId(response.data.ruleId);
|
||||||
|
|
||||||
|
await onSave?.(response.data);
|
||||||
|
toast.success("채번 규칙이 저장되었습니다");
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`저장 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentRule, savedRules, onSave]);
|
||||||
|
|
||||||
|
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||||
|
setSelectedRuleId(rule.ruleId);
|
||||||
|
setCurrentRule(rule);
|
||||||
|
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteSavedRule = useCallback(async (ruleId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await deleteNumberingRule(ruleId);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||||
|
|
||||||
|
if (selectedRuleId === ruleId) {
|
||||||
|
setSelectedRuleId(null);
|
||||||
|
setCurrentRule(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("규칙이 삭제되었습니다");
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`삭제 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedRuleId]);
|
||||||
|
|
||||||
|
const handleNewRule = useCallback(() => {
|
||||||
|
const newRule: NumberingRuleConfig = {
|
||||||
|
ruleId: `rule-${Date.now()}`,
|
||||||
|
ruleName: "새 채번 규칙",
|
||||||
|
parts: [],
|
||||||
|
separator: "-",
|
||||||
|
resetPeriod: "none",
|
||||||
|
currentSequence: 1,
|
||||||
|
scopeType: "global",
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedRuleId(newRule.ruleId);
|
||||||
|
setCurrentRule(newRule);
|
||||||
|
|
||||||
|
toast.success("새 규칙이 생성되었습니다");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex h-full gap-4 ${className}`}>
|
||||||
|
{/* 좌측: 저장된 규칙 목록 */}
|
||||||
|
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{editingLeftTitle ? (
|
||||||
|
<Input
|
||||||
|
value={leftTitle}
|
||||||
|
onChange={(e) => setLeftTitle(e.target.value)}
|
||||||
|
onBlur={() => setEditingLeftTitle(false)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
|
||||||
|
className="h-8 text-sm font-semibold"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setEditingLeftTitle(true)}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 규칙 생성
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-xs text-muted-foreground">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : savedRules.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
||||||
|
<p className="text-xs text-muted-foreground">저장된 규칙이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
savedRules.map((rule) => (
|
||||||
|
<Card
|
||||||
|
key={rule.ruleId}
|
||||||
|
className={`cursor-pointer border-border transition-colors hover:bg-accent ${
|
||||||
|
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectRule(rule)}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
규칙 {rule.parts.length}개
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteSavedRule(rule.ruleId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 pt-0">
|
||||||
|
<NumberingRulePreview config={rule} compact />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-full w-px bg-border"></div>
|
||||||
|
|
||||||
|
{/* 우측: 편집 영역 */}
|
||||||
|
<div className="flex flex-1 flex-col gap-4">
|
||||||
|
{!currentRule ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-2 text-lg font-medium text-muted-foreground">
|
||||||
|
규칙을 선택해주세요
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
좌측에서 규칙을 선택하거나 새로 생성하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{editingRightTitle ? (
|
||||||
|
<Input
|
||||||
|
value={rightTitle}
|
||||||
|
onChange={(e) => setRightTitle(e.target.value)}
|
||||||
|
onBlur={() => setEditingRightTitle(false)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
|
||||||
|
className="h-8 text-sm font-semibold"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setEditingRightTitle(true)}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">규칙명</Label>
|
||||||
|
<Input
|
||||||
|
value={currentRule.ruleName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="예: 프로젝트 코드"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<NumberingRulePreview config={currentRule} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold">코드 구성</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{currentRule.parts.length}/{maxRules}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentRule.parts.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
||||||
|
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||||
|
규칙을 추가하여 코드를 구성하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
|
{currentRule.parts.map((part) => (
|
||||||
|
<NumberingRuleCard
|
||||||
|
key={part.id}
|
||||||
|
part={part}
|
||||||
|
onUpdate={(updates) => handleUpdatePart(part.id, updates)}
|
||||||
|
onDelete={() => handleDeletePart(part.id)}
|
||||||
|
isPreview={isPreview}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleAddPart}
|
||||||
|
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 flex-1 text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
규칙 추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isPreview || loading}
|
||||||
|
className="h-9 flex-1 text-sm"
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
|
|
||||||
|
interface NumberingRulePreviewProps {
|
||||||
|
config: NumberingRuleConfig;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
|
config,
|
||||||
|
compact = false
|
||||||
|
}) => {
|
||||||
|
const generatedCode = useMemo(() => {
|
||||||
|
if (!config.parts || config.parts.length === 0) {
|
||||||
|
return "규칙을 추가해주세요";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = config.parts
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((part) => {
|
||||||
|
if (part.generationMethod === "manual") {
|
||||||
|
return part.manualConfig?.value || "XXX";
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoConfig = part.autoConfig || {};
|
||||||
|
|
||||||
|
switch (part.partType) {
|
||||||
|
// 1. 순번 (자동 증가)
|
||||||
|
case "sequence": {
|
||||||
|
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();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "YYYY": return String(year);
|
||||||
|
case "YY": return String(year).slice(-2);
|
||||||
|
case "YYYYMM": return `${year}${month}`;
|
||||||
|
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||||
|
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||||
|
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||||
|
default: return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 문자
|
||||||
|
case "text":
|
||||||
|
return autoConfig.textValue || "TEXT";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "XXX";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parts.join(config.separator || "");
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-muted px-2 py-1">
|
||||||
|
<code className="text-xs font-mono text-foreground">{generatedCode}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground sm:text-sm">코드 미리보기</p>
|
||||||
|
<div className="rounded-md bg-muted p-3 sm:p-4">
|
||||||
|
<code className="text-sm font-mono text-foreground sm:text-base">{generatedCode}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -102,13 +102,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenEditModal = (event: CustomEvent) => {
|
const handleOpenEditModal = (event: CustomEvent) => {
|
||||||
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
|
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
|
||||||
console.log("🚀 EditModal 열기 이벤트 수신:", {
|
|
||||||
screenId,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
modalSize,
|
|
||||||
editData,
|
|
||||||
});
|
|
||||||
|
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
|
|
@ -126,7 +119,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseEditModal = () => {
|
const handleCloseEditModal = () => {
|
||||||
console.log("🚪 EditModal 닫기 이벤트 수신");
|
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||||
|
if (modalState.onSave) {
|
||||||
|
try {
|
||||||
|
modalState.onSave();
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error("⚠️ onSave 콜백 에러:", callbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -137,7 +139,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
|
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
|
||||||
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
|
||||||
|
|
||||||
// 화면 데이터 로딩
|
// 화면 데이터 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -211,12 +213,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("💾 수정 저장 시작:", {
|
|
||||||
tableName: screenData.screenInfo.tableName,
|
|
||||||
formData,
|
|
||||||
originalData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 변경된 필드만 추출
|
// 변경된 필드만 추출
|
||||||
const changedData: Record<string, any> = {};
|
const changedData: Record<string, any> = {};
|
||||||
Object.keys(formData).forEach((key) => {
|
Object.keys(formData).forEach((key) => {
|
||||||
|
|
@ -225,26 +221,33 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📝 변경된 필드:", changedData);
|
|
||||||
|
|
||||||
if (Object.keys(changedData).length === 0) {
|
if (Object.keys(changedData).length === 0) {
|
||||||
toast.info("변경된 내용이 없습니다.");
|
toast.info("변경된 내용이 없습니다.");
|
||||||
handleClose();
|
handleClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기본키 확인 (id 또는 첫 번째 키)
|
||||||
|
const recordId = originalData.id || Object.values(originalData)[0];
|
||||||
|
|
||||||
// UPDATE 액션 실행
|
// UPDATE 액션 실행
|
||||||
const response = await dynamicFormApi.updateData(screenData.screenInfo.tableName, {
|
const response = await dynamicFormApi.updateFormDataPartial(
|
||||||
...originalData, // 원본 데이터 (WHERE 조건용)
|
recordId,
|
||||||
...changedData, // 변경된 데이터만
|
originalData,
|
||||||
});
|
changedData,
|
||||||
|
screenData.screenInfo.tableName,
|
||||||
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success("데이터가 수정되었습니다.");
|
toast.success("데이터가 수정되었습니다.");
|
||||||
|
|
||||||
// 부모 컴포넌트의 onSave 콜백 실행
|
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||||
if (modalState.onSave) {
|
if (modalState.onSave) {
|
||||||
modalState.onSave();
|
try {
|
||||||
|
modalState.onSave();
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error("⚠️ onSave 콜백 에러:", callbackError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
@ -335,16 +338,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
allComponents={screenData.components}
|
allComponents={screenData.components}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
setFormData((prev) => ({
|
||||||
console.log("📋 현재 formData:", formData);
|
...prev,
|
||||||
setFormData((prev) => {
|
[fieldName]: value,
|
||||||
const newFormData = {
|
}));
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
};
|
|
||||||
console.log("📝 EditModal 업데이트된 formData:", newFormData);
|
|
||||||
return newFormData;
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
screenInfo={{
|
screenInfo={{
|
||||||
id: modalState.screenId!,
|
id: modalState.screenId!,
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@ import {
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Grid,
|
Grid,
|
||||||
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
||||||
|
|
@ -99,6 +101,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
|
|
@ -106,6 +109,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
const hasInitializedWidthsRef = useRef(false);
|
||||||
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||||
|
const isResizingRef = useRef(false);
|
||||||
|
|
||||||
// SaveModal 상태 (등록/수정 통합)
|
// SaveModal 상태 (등록/수정 통합)
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
|
@ -130,6 +137,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 공통코드 관리 상태
|
// 공통코드 관리 상태
|
||||||
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ value: string; label: string }>>>({});
|
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ value: string; label: string }>>>({});
|
||||||
|
|
||||||
|
// 🆕 검색 필터 관련 상태 (FlowWidget과 동일)
|
||||||
|
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set()); // 검색 필터로 사용할 컬럼
|
||||||
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그
|
||||||
|
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||||
|
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||||
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||||
|
|
||||||
// 공통코드 옵션 가져오기
|
// 공통코드 옵션 가져오기
|
||||||
const loadCodeOptions = useCallback(
|
const loadCodeOptions = useCallback(
|
||||||
async (categoryCode: string) => {
|
async (categoryCode: string) => {
|
||||||
|
|
@ -408,6 +422,35 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 페이지 크기 설정
|
// 페이지 크기 설정
|
||||||
const pageSize = component.pagination?.pageSize || 10;
|
const pageSize = component.pagination?.pageSize || 10;
|
||||||
|
|
||||||
|
// 초기 컬럼 너비 측정 (한 번만)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedWidthsRef.current && visibleColumns.length > 0) {
|
||||||
|
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
let hasAnyWidth = false;
|
||||||
|
|
||||||
|
visibleColumns.forEach((column) => {
|
||||||
|
const thElement = columnRefs.current[column.id];
|
||||||
|
if (thElement) {
|
||||||
|
const measuredWidth = thElement.offsetWidth;
|
||||||
|
if (measuredWidth > 0) {
|
||||||
|
newWidths[column.id] = measuredWidth;
|
||||||
|
hasAnyWidth = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasAnyWidth) {
|
||||||
|
setColumnWidths(newWidths);
|
||||||
|
hasInitializedWidthsRef.current = true;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 데이터 로드 함수
|
// 데이터 로드 함수
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||||
|
|
@ -442,6 +485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
search: searchParams,
|
search: searchParams,
|
||||||
|
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
|
|
@ -533,7 +577,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[component.tableName, pageSize],
|
[component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 사용자 정보 로드
|
// 현재 사용자 정보 로드
|
||||||
|
|
@ -600,6 +644,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
try {
|
try {
|
||||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||||
setTableColumns(columns);
|
setTableColumns(columns);
|
||||||
|
|
||||||
|
// 🆕 전체 컬럼 목록 설정
|
||||||
|
const columnNames = columns.map(col => col.columnName);
|
||||||
|
setAllAvailableColumns(columnNames);
|
||||||
|
|
||||||
|
// 🆕 컬럼명 -> 라벨 매핑 생성
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
columns.forEach(col => {
|
||||||
|
labels[col.columnName] = col.displayName || col.columnName;
|
||||||
|
});
|
||||||
|
setColumnLabels(labels);
|
||||||
|
|
||||||
|
// 🆕 localStorage에서 필터 설정 복원
|
||||||
|
if (user?.userId && component.componentId) {
|
||||||
|
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
|
||||||
|
const savedFilter = localStorage.getItem(storageKey);
|
||||||
|
if (savedFilter) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedFilter);
|
||||||
|
setSearchFilterColumns(new Set(parsed));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("필터 설정 복원 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("테이블 컬럼 정보 로드 실패:", error);
|
// console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -608,7 +677,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
if (component.tableName) {
|
if (component.tableName) {
|
||||||
fetchTableColumns();
|
fetchTableColumns();
|
||||||
}
|
}
|
||||||
}, [component.tableName]);
|
}, [component.tableName, component.componentId, user?.userId]);
|
||||||
|
|
||||||
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
||||||
const searchFilters = useMemo(() => {
|
const searchFilters = useMemo(() => {
|
||||||
|
|
@ -769,7 +838,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setShowSaveModal(true);
|
setShowSaveModal(true);
|
||||||
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
|
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
|
||||||
|
|
||||||
// 데이터 수정 핸들러 (SaveModal 사용)
|
// 데이터 수정 핸들러 (EditModal 사용)
|
||||||
const handleEditData = useCallback(() => {
|
const handleEditData = useCallback(() => {
|
||||||
if (selectedRows.size !== 1) return;
|
if (selectedRows.size !== 1) return;
|
||||||
|
|
||||||
|
|
@ -793,17 +862,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
initialData[col.columnName] = selectedRowData[col.columnName] || "";
|
initialData[col.columnName] = selectedRowData[col.columnName] || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
setEditFormData(initialData);
|
|
||||||
setEditingRowData(selectedRowData);
|
|
||||||
|
|
||||||
// 수정 모달 설정에서 제목과 설명 가져오기
|
// 수정 모달 설정에서 제목과 설명 가져오기
|
||||||
const editModalTitle = component.editModalConfig?.title || "";
|
const editModalTitle = component.editModalConfig?.title || "데이터 수정";
|
||||||
const editModalDescription = component.editModalConfig?.description || "";
|
const editModalDescription = component.editModalConfig?.description || "";
|
||||||
|
|
||||||
console.log("📝 수정 모달 설정:", { editModalTitle, editModalDescription });
|
// 전역 EditModal 열기 이벤트 발생
|
||||||
|
const event = new CustomEvent("openEditModal", {
|
||||||
setShowEditModal(true);
|
detail: {
|
||||||
}, [selectedRows, data, getDisplayColumns, component.editModalConfig]);
|
screenId,
|
||||||
|
title: editModalTitle,
|
||||||
|
description: editModalDescription,
|
||||||
|
modalSize: "lg",
|
||||||
|
editData: initialData,
|
||||||
|
onSave: () => {
|
||||||
|
loadData(); // 테이블 데이터 새로고침
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]);
|
||||||
|
|
||||||
// 수정 폼 데이터 변경 핸들러
|
// 수정 폼 데이터 변경 핸들러
|
||||||
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
||||||
|
|
@ -1011,6 +1088,29 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}
|
}
|
||||||
}, [isAdding]);
|
}, [isAdding]);
|
||||||
|
|
||||||
|
// 🆕 검색 필터 저장 함수
|
||||||
|
const handleSaveSearchFilter = useCallback(() => {
|
||||||
|
if (user?.userId && component.componentId) {
|
||||||
|
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
|
||||||
|
const filterArray = Array.from(searchFilterColumns);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(filterArray));
|
||||||
|
toast.success("검색 필터 설정이 저장되었습니다.");
|
||||||
|
}
|
||||||
|
}, [user?.userId, component.componentId, searchFilterColumns]);
|
||||||
|
|
||||||
|
// 🆕 검색 필터 토글 함수
|
||||||
|
const handleToggleFilterColumn = useCallback((columnName: string) => {
|
||||||
|
setSearchFilterColumns((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(columnName)) {
|
||||||
|
newSet.delete(columnName);
|
||||||
|
} else {
|
||||||
|
newSet.add(columnName);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 데이터 삭제 핸들러
|
// 데이터 삭제 핸들러
|
||||||
const handleDeleteData = useCallback(() => {
|
const handleDeleteData = useCallback(() => {
|
||||||
if (selectedRows.size === 0) {
|
if (selectedRows.size === 0) {
|
||||||
|
|
@ -1767,8 +1867,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
case "decimal":
|
case "decimal":
|
||||||
if (typeof value === "number") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
return value.toLocaleString();
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -1909,27 +2012,97 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
{visibleColumns.length > 0 ? (
|
{visibleColumns.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
||||||
<Table>
|
<Table style={{ tableLayout: 'fixed' }}>
|
||||||
<TableHeader>
|
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||||
{component.enableDelete && (
|
{component.enableDelete && (
|
||||||
<TableHead className="w-12 px-4">
|
<TableHead
|
||||||
|
className="px-4"
|
||||||
|
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.size === data.length && data.length > 0}
|
checked={selectedRows.size === data.length && data.length > 0}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{visibleColumns.map((column: DataTableColumn) => (
|
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
|
||||||
<TableHead
|
const columnWidth = columnWidths[column.id];
|
||||||
key={column.id}
|
|
||||||
className="bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700"
|
return (
|
||||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
<TableHead
|
||||||
>
|
key={column.id}
|
||||||
{column.label}
|
ref={(el) => (columnRefs.current[column.id] = el)}
|
||||||
</TableHead>
|
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
|
||||||
))}
|
style={{
|
||||||
|
width: columnWidth ? `${columnWidth}px` : undefined,
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
{/* 리사이즈 핸들 */}
|
||||||
|
{columnIndex < visibleColumns.length - 1 && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||||
|
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const thElement = columnRefs.current[column.id];
|
||||||
|
if (!thElement) return;
|
||||||
|
|
||||||
|
isResizingRef.current = true;
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = columnWidth || thElement.offsetWidth;
|
||||||
|
|
||||||
|
// 드래그 중 텍스트 선택 방지
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
|
||||||
|
const diff = moveEvent.clientX - startX;
|
||||||
|
const newWidth = Math.max(80, startWidth + diff);
|
||||||
|
|
||||||
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
||||||
|
if (thElement) {
|
||||||
|
thElement.style.width = `${newWidth}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
// 최종 너비를 state에 저장
|
||||||
|
if (thElement) {
|
||||||
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||||
|
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 선택 복원
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
|
// 약간의 지연 후 리사이즈 플래그 해제
|
||||||
|
setTimeout(() => {
|
||||||
|
isResizingRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -1951,18 +2124,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
||||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||||
{component.enableDelete && (
|
{component.enableDelete && (
|
||||||
<TableCell className="w-12 px-4">
|
<TableCell
|
||||||
|
className="px-4"
|
||||||
|
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.has(rowIndex)}
|
checked={selectedRows.has(rowIndex)}
|
||||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{visibleColumns.map((column: DataTableColumn) => (
|
{visibleColumns.map((column: DataTableColumn) => {
|
||||||
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
|
||||||
{formatCellValue(row[column.columnName], column, row)}
|
return (
|
||||||
</TableCell>
|
<TableCell
|
||||||
))}
|
key={column.id}
|
||||||
|
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
style={{ textAlign: isNumeric ? 'right' : 'left' }}
|
||||||
|
>
|
||||||
|
{formatCellValue(row[column.columnName], column, row)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
{/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
import { FileUpload } from "./widgets/FileUpload";
|
import { FileUpload } from "./widgets/FileUpload";
|
||||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||||
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
||||||
import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
||||||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||||
|
|
@ -237,14 +237,46 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 자동입력 필드들의 값을 formData에 초기 설정
|
// 자동입력 필드들의 값을 formData에 초기 설정
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
|
// console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
|
||||||
const initAutoInputFields = () => {
|
const initAutoInputFields = async () => {
|
||||||
// console.log("🔧 initAutoInputFields 실행 시작");
|
// console.log("🔧 initAutoInputFields 실행 시작");
|
||||||
allComponents.forEach(comp => {
|
for (const comp of allComponents) {
|
||||||
if (comp.type === 'widget') {
|
// 🆕 type: "component" 또는 type: "widget" 모두 처리
|
||||||
|
if (comp.type === 'widget' || comp.type === 'component') {
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const fieldName = widget.columnName || widget.id;
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
|
||||||
// 텍스트 타입 위젯의 자동입력 처리
|
// 🆕 autoFill 처리 (테이블 조회 기반 자동 입력)
|
||||||
|
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||||||
|
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||||||
|
const currentValue = formData[fieldName];
|
||||||
|
if (currentValue === undefined || currentValue === '') {
|
||||||
|
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
||||||
|
|
||||||
|
// 사용자 정보에서 필터 값 가져오기
|
||||||
|
const userValue = user?.[userField];
|
||||||
|
|
||||||
|
if (userValue && sourceTable && filterColumn && displayColumn) {
|
||||||
|
try {
|
||||||
|
const result = await tableTypeApi.getTableRecord(
|
||||||
|
sourceTable,
|
||||||
|
filterColumn,
|
||||||
|
userValue,
|
||||||
|
displayColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
updateFormData(fieldName, result.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue; // autoFill이 활성화되면 일반 자동입력은 건너뜀
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 widget 타입 전용 로직은 widget인 경우만
|
||||||
|
if (comp.type !== 'widget') continue;
|
||||||
|
|
||||||
|
// 텍스트 타입 위젯의 자동입력 처리 (기존 로직)
|
||||||
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
|
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
|
||||||
widget.webTypeConfig) {
|
widget.webTypeConfig) {
|
||||||
const config = widget.webTypeConfig as TextTypeConfig;
|
const config = widget.webTypeConfig as TextTypeConfig;
|
||||||
|
|
@ -278,12 +310,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 로드 시 자동입력 필드들 설정
|
// 초기 로드 시 자동입력 필드들 설정
|
||||||
initAutoInputFields();
|
initAutoInputFields();
|
||||||
}, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지)
|
}, [allComponents, generateAutoValue, user]); // formData는 의존성에서 제외 (무한 루프 방지)
|
||||||
|
|
||||||
// 날짜 값 업데이트
|
// 날짜 값 업데이트
|
||||||
const updateDateValue = (fieldName: string, date: Date | undefined) => {
|
const updateDateValue = (fieldName: string, date: Date | undefined) => {
|
||||||
|
|
@ -1221,6 +1253,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const handleSaveAction = async () => {
|
const handleSaveAction = async () => {
|
||||||
// console.log("💾 저장 시작");
|
// console.log("💾 저장 시작");
|
||||||
|
|
||||||
|
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
|
||||||
|
if (!user?.userId) {
|
||||||
|
alert("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 개선된 검증 시스템이 활성화된 경우
|
// 개선된 검증 시스템이 활성화된 경우
|
||||||
if (enhancedValidation) {
|
if (enhancedValidation) {
|
||||||
// console.log("🔍 개선된 검증 시스템 사용");
|
// console.log("🔍 개선된 검증 시스템 사용");
|
||||||
|
|
@ -1357,19 +1395,26 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
allComponents.find(c => c.columnName)?.tableName ||
|
allComponents.find(c => c.columnName)?.tableName ||
|
||||||
"dynamic_form_data"; // 기본값
|
"dynamic_form_data"; // 기본값
|
||||||
|
|
||||||
// 🆕 자동으로 작성자 정보 추가
|
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
||||||
const writerValue = user?.userId || userName || "unknown";
|
const writerValue = user.userId;
|
||||||
|
const companyCodeValue = user.companyCode || "";
|
||||||
|
|
||||||
console.log("👤 현재 사용자 정보:", {
|
console.log("👤 현재 사용자 정보:", {
|
||||||
userId: user?.userId,
|
userId: user.userId,
|
||||||
userName: userName,
|
userName: userName,
|
||||||
writerValue: writerValue,
|
companyCode: user.companyCode, // ✅ 회사 코드
|
||||||
|
formDataWriter: mappedData.writer, // ✅ 폼에서 입력한 writer 값
|
||||||
|
formDataCompanyCode: mappedData.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||||
|
defaultWriterValue: writerValue,
|
||||||
|
companyCodeValue, // ✅ 최종 회사 코드 값
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataWithUserInfo = {
|
const dataWithUserInfo = {
|
||||||
...mappedData,
|
...mappedData,
|
||||||
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
|
writer: mappedData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||||
created_by: writerValue,
|
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||||
updated_by: writerValue,
|
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
||||||
|
company_code: mappedData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveData: DynamicFormData = {
|
const saveData: DynamicFormData = {
|
||||||
|
|
@ -1633,24 +1678,19 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return applyStyles(
|
||||||
<Button
|
<Button
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={config?.variant || "default"}
|
variant={config?.variant || "default"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: "100%" }}
|
|
||||||
style={{
|
style={{
|
||||||
// 컴포넌트 스타일과 설정 스타일 모두 적용
|
|
||||||
...comp.style,
|
|
||||||
// 크기는 className으로 처리하므로 CSS 크기 속성 제거
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
height: "100%",
|
||||||
// 설정값이 있으면 우선 적용, 없으면 컴포넌트 스타일 사용
|
// 설정값이 있으면 우선 적용
|
||||||
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
backgroundColor: config?.backgroundColor,
|
||||||
color: config?.textColor || comp.style?.color,
|
color: config?.textColor,
|
||||||
borderColor: config?.borderColor || comp.style?.borderColor,
|
borderColor: config?.borderColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label || "버튼"}
|
{label || "버튼"}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ interface InteractiveScreenViewerProps {
|
||||||
id: number;
|
id: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
};
|
};
|
||||||
|
onSave?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||||
|
|
@ -47,6 +48,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
screenInfo,
|
screenInfo,
|
||||||
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { userName, user } = useAuth();
|
const { userName, user } = useAuth();
|
||||||
|
|
@ -79,6 +81,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
|
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
|
||||||
const formData = externalFormData || localFormData;
|
const formData = externalFormData || localFormData;
|
||||||
|
|
||||||
|
// formData 업데이트 함수
|
||||||
|
const updateFormData = useCallback(
|
||||||
|
(fieldName: string, value: any) => {
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(fieldName, value);
|
||||||
|
} else {
|
||||||
|
setLocalFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFormDataChange],
|
||||||
|
);
|
||||||
|
|
||||||
// 자동값 생성 함수
|
// 자동값 생성 함수
|
||||||
const generateAutoValue = useCallback(
|
const generateAutoValue = useCallback(
|
||||||
(autoValueType: string): string => {
|
(autoValueType: string): string => {
|
||||||
|
|
@ -103,6 +120,50 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
[userName],
|
[userName],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 autoFill 자동 입력 초기화
|
||||||
|
React.useEffect(() => {
|
||||||
|
const initAutoInputFields = async () => {
|
||||||
|
for (const comp of allComponents) {
|
||||||
|
// type: "component" 또는 type: "widget" 모두 처리
|
||||||
|
if (comp.type === 'widget' || comp.type === 'component') {
|
||||||
|
const widget = comp as any;
|
||||||
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
|
||||||
|
// autoFill 처리 (테이블 조회 기반 자동 입력)
|
||||||
|
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||||||
|
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||||||
|
const currentValue = formData[fieldName];
|
||||||
|
|
||||||
|
if (currentValue === undefined || currentValue === '') {
|
||||||
|
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
||||||
|
|
||||||
|
// 사용자 정보에서 필터 값 가져오기
|
||||||
|
const userValue = user?.[userField];
|
||||||
|
|
||||||
|
if (userValue && sourceTable && filterColumn && displayColumn) {
|
||||||
|
try {
|
||||||
|
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||||
|
const result = await tableTypeApi.getTableRecord(
|
||||||
|
sourceTable,
|
||||||
|
filterColumn,
|
||||||
|
userValue,
|
||||||
|
displayColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
updateFormData(fieldName, result.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAutoInputFields();
|
||||||
|
}, [allComponents, user]);
|
||||||
|
|
||||||
// 팝업 화면 레이아웃 로드
|
// 팝업 화면 레이아웃 로드
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (popupScreen?.screenId) {
|
if (popupScreen?.screenId) {
|
||||||
|
|
@ -140,15 +201,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
};
|
};
|
||||||
|
|
||||||
// 폼 데이터 변경 핸들러
|
// 폼 데이터 변경 핸들러
|
||||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
const handleFormDataChange = (fieldName: string | any, value?: any) => {
|
||||||
// console.log(`🎯 InteractiveScreenViewerDynamic handleFormDataChange 호출: ${fieldName} = "${value}"`);
|
// 일반 필드 변경
|
||||||
// console.log(`📋 onFormDataChange 존재 여부:`, !!onFormDataChange);
|
|
||||||
|
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
// console.log(`📤 InteractiveScreenViewerDynamic -> onFormDataChange 호출: ${fieldName} = "${value}"`);
|
|
||||||
onFormDataChange(fieldName, value);
|
onFormDataChange(fieldName, value);
|
||||||
} else {
|
} else {
|
||||||
// console.log(`💾 InteractiveScreenViewerDynamic 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
|
||||||
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -188,6 +245,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
onFormDataChange={handleFormDataChange}
|
onFormDataChange={handleFormDataChange}
|
||||||
screenId={screenInfo?.id}
|
screenId={screenInfo?.id}
|
||||||
tableName={screenInfo?.tableName}
|
tableName={screenInfo?.tableName}
|
||||||
|
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||||
|
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||||
|
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
||||||
|
|
@ -204,8 +264,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// 화면 닫기 로직 (필요시 구현)
|
// buttonActions.ts가 이미 처리함
|
||||||
console.log("🚪 화면 닫기 요청");
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -299,6 +358,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
// 버튼 액션 핸들러들
|
// 버튼 액션 핸들러들
|
||||||
const handleSaveAction = async () => {
|
const handleSaveAction = async () => {
|
||||||
|
// EditModal에서 전달된 onSave가 있으면 우선 사용 (수정 모달)
|
||||||
|
if (onSave) {
|
||||||
|
try {
|
||||||
|
await onSave();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장 오류:", error);
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 저장 액션 (신규 생성)
|
||||||
if (!screenInfo?.tableName) {
|
if (!screenInfo?.tableName) {
|
||||||
toast.error("테이블명이 설정되지 않았습니다.");
|
toast.error("테이블명이 설정되지 않았습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -412,9 +483,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
disabled={config?.disabled}
|
disabled={config?.disabled}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: config?.backgroundColor,
|
// 컴포넌트 스타일 먼저 적용
|
||||||
color: config?.textColor,
|
|
||||||
...comp.style,
|
...comp.style,
|
||||||
|
// 설정값이 있으면 우선 적용
|
||||||
|
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
||||||
|
color: config?.textColor || comp.style?.color,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label || "버튼"}
|
{label || "버튼"}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
|
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
|
||||||
const [assignmentMessage, setAssignmentMessage] = useState("");
|
const [assignmentMessage, setAssignmentMessage] = useState("");
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const autoRedirectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴)
|
// 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴)
|
||||||
const loadMenus = async () => {
|
const loadMenus = async () => {
|
||||||
|
|
@ -98,7 +99,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달이 열릴 때 메뉴 목록 로드
|
// 모달이 열릴 때 메뉴 목록 로드 및 정리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadMenus();
|
loadMenus();
|
||||||
|
|
@ -107,7 +108,21 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setAssignmentSuccess(false);
|
setAssignmentSuccess(false);
|
||||||
setAssignmentMessage("");
|
setAssignmentMessage("");
|
||||||
|
} else {
|
||||||
|
// 모달이 닫힐 때 타이머 정리
|
||||||
|
if (autoRedirectTimerRef.current) {
|
||||||
|
clearTimeout(autoRedirectTimerRef.current);
|
||||||
|
autoRedirectTimerRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 언마운트 시 타이머 정리
|
||||||
|
return () => {
|
||||||
|
if (autoRedirectTimerRef.current) {
|
||||||
|
clearTimeout(autoRedirectTimerRef.current);
|
||||||
|
autoRedirectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 메뉴 선택 처리
|
// 메뉴 선택 처리
|
||||||
|
|
@ -208,7 +223,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||||
setTimeout(() => {
|
autoRedirectTimerRef.current = setTimeout(() => {
|
||||||
onClose(); // 모달 닫기
|
onClose(); // 모달 닫기
|
||||||
if (onBackToList) {
|
if (onBackToList) {
|
||||||
onBackToList();
|
onBackToList();
|
||||||
|
|
@ -237,7 +252,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||||
setTimeout(() => {
|
autoRedirectTimerRef.current = setTimeout(() => {
|
||||||
onClose(); // 모달 닫기
|
onClose(); // 모달 닫기
|
||||||
if (onBackToList) {
|
if (onBackToList) {
|
||||||
onBackToList();
|
onBackToList();
|
||||||
|
|
@ -374,13 +389,20 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// 타이머 정리
|
||||||
|
if (autoRedirectTimerRef.current) {
|
||||||
|
clearTimeout(autoRedirectTimerRef.current);
|
||||||
|
autoRedirectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 목록으로 이동
|
||||||
if (onBackToList) {
|
if (onBackToList) {
|
||||||
onBackToList();
|
onBackToList();
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 text-white hover:bg-green-700"
|
||||||
>
|
>
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
화면 목록으로 이동
|
화면 목록으로 이동
|
||||||
|
|
@ -536,7 +558,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAssignScreen}
|
onClick={handleAssignScreen}
|
||||||
disabled={!selectedMenu || assigning}
|
disabled={!selectedMenu || assigning}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
{assigning ? (
|
{assigning ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -616,7 +638,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
await performAssignment(true);
|
await performAssignment(true);
|
||||||
}}
|
}}
|
||||||
disabled={assigning}
|
disabled={assigning}
|
||||||
className="bg-orange-600 hover:bg-orange-700"
|
className="bg-orange-600 text-white hover:bg-orange-700"
|
||||||
>
|
>
|
||||||
{assigning ? (
|
{assigning ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -399,13 +399,26 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
willUse100Percent: positionX === 0,
|
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 = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
...style, // 먼저 적용하고
|
...style, // 먼저 적용하고
|
||||||
left: positionX,
|
left: positionX,
|
||||||
top: position?.y || 0,
|
top: position?.y || 0,
|
||||||
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
|
width: getWidth(), // 우선순위에 따른 너비
|
||||||
width: positionX === 0 ? "100%" : (size?.width || 200),
|
|
||||||
height: finalHeight,
|
height: finalHeight,
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
// right 속성 강제 제거
|
// right 속성 강제 제거
|
||||||
|
|
|
||||||
|
|
@ -200,19 +200,58 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||||
// 너비 우선순위: style.width > size.width (픽셀값)
|
// 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값)
|
||||||
const getWidth = () => {
|
const getWidth = () => {
|
||||||
// 1순위: style.width가 있으면 우선 사용
|
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||||
if (componentStyle?.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;
|
return componentStyle.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: size.width (픽셀)
|
// 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
|
||||||
if (component.componentConfig?.type === "table-list") {
|
const isButtonComponent =
|
||||||
return `${Math.max(size?.width || 120, 120)}px`;
|
(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 = () => {
|
const getHeight = () => {
|
||||||
|
|
@ -238,17 +277,51 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
|
width: getWidth(), // getWidth()가 모든 우선순위를 처리
|
||||||
width: position.x === 0 ? "100%" : getWidth(),
|
height: getHeight(),
|
||||||
height: getHeight(), // 모든 컴포넌트 고정 높이로 변경
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
|
|
||||||
...componentStyle,
|
...componentStyle,
|
||||||
// style.width가 있어도 position.x === 0이면 100%로 강제
|
|
||||||
...(position.x === 0 && { width: "100%" }),
|
|
||||||
// right 속성 강제 제거
|
|
||||||
right: undefined,
|
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) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
|
|
@ -270,21 +343,28 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={outerDivRef}
|
||||||
id={`component-${id}`}
|
id={`component-${id}`}
|
||||||
|
data-component-id={id}
|
||||||
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
||||||
style={{ ...baseStyle, ...selectionStyle }}
|
style={{ ...baseStyle, ...selectionStyle }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
draggable
|
draggable={isDesignMode} // 디자인 모드에서만 드래그 가능
|
||||||
onDragStart={handleDragStart}
|
onDragStart={isDesignMode ? handleDragStart : undefined}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={isDesignMode ? handleDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* 동적 컴포넌트 렌더링 */}
|
{/* 동적 컴포넌트 렌더링 */}
|
||||||
<div
|
<div
|
||||||
ref={
|
ref={(node) => {
|
||||||
component.type === "component" && (component as any).componentType === "flow-widget" ? contentRef : undefined
|
// 멀티 ref 처리
|
||||||
}
|
innerDivRef.current = node;
|
||||||
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} w-full max-w-full overflow-visible`}
|
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
|
<DynamicComponentRenderer
|
||||||
component={component}
|
component={component}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!screenData || !screenId) return;
|
if (!screenData || !screenId) return;
|
||||||
|
|
||||||
|
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
|
||||||
|
if (!user?.userId) {
|
||||||
|
toast.error("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
|
|
@ -129,19 +135,26 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
// 저장할 데이터 준비
|
// 저장할 데이터 준비
|
||||||
const dataToSave = initialData ? changedData : formData;
|
const dataToSave = initialData ? changedData : formData;
|
||||||
|
|
||||||
// 🆕 자동으로 작성자 정보 추가
|
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
||||||
const writerValue = user?.userId || userName || "unknown";
|
const writerValue = user.userId;
|
||||||
|
const companyCodeValue = user.companyCode || "";
|
||||||
|
|
||||||
console.log("👤 현재 사용자 정보:", {
|
console.log("👤 현재 사용자 정보:", {
|
||||||
userId: user?.userId,
|
userId: user.userId,
|
||||||
userName: userName,
|
userName: userName,
|
||||||
writerValue: writerValue,
|
companyCode: user.companyCode, // ✅ 회사 코드
|
||||||
|
formDataWriter: dataToSave.writer, // ✅ 폼에서 입력한 writer 값
|
||||||
|
formDataCompanyCode: dataToSave.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||||
|
defaultWriterValue: writerValue,
|
||||||
|
companyCodeValue, // ✅ 최종 회사 코드 값
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataWithUserInfo = {
|
const dataWithUserInfo = {
|
||||||
...dataToSave,
|
...dataToSave,
|
||||||
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
|
writer: dataToSave.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||||
created_by: writerValue,
|
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||||
updated_by: writerValue,
|
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}
|
screenId={screenId}
|
||||||
tableName={screenData.tableName}
|
tableName={screenData.tableName}
|
||||||
|
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||||
|
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||||
|
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData}
|
originalData={originalData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
|
|
||||||
|
|
@ -381,19 +381,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 실행취소
|
// 실행취소
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (historyIndex > 0) {
|
setHistoryIndex((prevIndex) => {
|
||||||
setHistoryIndex((prev) => prev - 1);
|
if (prevIndex > 0) {
|
||||||
setLayout(history[historyIndex - 1]);
|
const newIndex = prevIndex - 1;
|
||||||
}
|
setHistory((prevHistory) => {
|
||||||
}, [history, historyIndex]);
|
if (prevHistory[newIndex]) {
|
||||||
|
setLayout(prevHistory[newIndex]);
|
||||||
|
}
|
||||||
|
return prevHistory;
|
||||||
|
});
|
||||||
|
return newIndex;
|
||||||
|
}
|
||||||
|
return prevIndex;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 다시실행
|
// 다시실행
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
if (historyIndex < history.length - 1) {
|
setHistoryIndex((prevIndex) => {
|
||||||
setHistoryIndex((prev) => prev + 1);
|
let newIndex = prevIndex;
|
||||||
setLayout(history[historyIndex + 1]);
|
setHistory((prevHistory) => {
|
||||||
}
|
if (prevIndex < prevHistory.length - 1) {
|
||||||
}, [history, historyIndex]);
|
newIndex = prevIndex + 1;
|
||||||
|
if (prevHistory[newIndex]) {
|
||||||
|
setLayout(prevHistory[newIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prevHistory;
|
||||||
|
});
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 컴포넌트 속성 업데이트
|
// 컴포넌트 속성 업데이트
|
||||||
const updateComponentProperty = useCallback(
|
const updateComponentProperty = useCallback(
|
||||||
|
|
@ -1994,76 +2012,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const isTableList = component.id === "table-list";
|
const isTableList = component.id === "table-list";
|
||||||
|
|
||||||
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
||||||
|
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
|
||||||
let gridColumns = 1; // 기본값
|
let gridColumns = 1; // 기본값
|
||||||
|
|
||||||
// 특수 컴포넌트
|
// 특수 컴포넌트
|
||||||
if (isCardDisplay) {
|
if (isCardDisplay) {
|
||||||
gridColumns = 8;
|
gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67%
|
||||||
} else if (isTableList) {
|
} else if (isTableList) {
|
||||||
gridColumns = 12; // 테이블은 전체 너비
|
gridColumns = currentGridColumns; // 테이블은 전체 너비
|
||||||
} else {
|
} else {
|
||||||
// 웹타입별 적절한 그리드 컬럼 수 설정
|
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||||
const webType = component.webType;
|
const webType = component.webType;
|
||||||
const componentId = component.id;
|
const componentId = component.id;
|
||||||
|
|
||||||
// 웹타입별 기본 컬럼 수 매핑
|
// 웹타입별 기본 비율 매핑 (12컬럼 기준 비율)
|
||||||
const gridColumnsMap: Record<string, number> = {
|
const gridColumnsRatioMap: Record<string, number> = {
|
||||||
// 입력 컴포넌트 (INPUT 카테고리)
|
// 입력 컴포넌트 (INPUT 카테고리)
|
||||||
"text-input": 4, // 텍스트 입력 (33%)
|
"text-input": 4 / 12, // 텍스트 입력 (33%)
|
||||||
"number-input": 2, // 숫자 입력 (16.67%)
|
"number-input": 2 / 12, // 숫자 입력 (16.67%)
|
||||||
"email-input": 4, // 이메일 입력 (33%)
|
"email-input": 4 / 12, // 이메일 입력 (33%)
|
||||||
"tel-input": 3, // 전화번호 입력 (25%)
|
"tel-input": 3 / 12, // 전화번호 입력 (25%)
|
||||||
"date-input": 3, // 날짜 입력 (25%)
|
"date-input": 3 / 12, // 날짜 입력 (25%)
|
||||||
"datetime-input": 4, // 날짜시간 입력 (33%)
|
"datetime-input": 4 / 12, // 날짜시간 입력 (33%)
|
||||||
"time-input": 2, // 시간 입력 (16.67%)
|
"time-input": 2 / 12, // 시간 입력 (16.67%)
|
||||||
"textarea-basic": 6, // 텍스트 영역 (50%)
|
"textarea-basic": 6 / 12, // 텍스트 영역 (50%)
|
||||||
"select-basic": 3, // 셀렉트 (25%)
|
"select-basic": 3 / 12, // 셀렉트 (25%)
|
||||||
"checkbox-basic": 2, // 체크박스 (16.67%)
|
"checkbox-basic": 2 / 12, // 체크박스 (16.67%)
|
||||||
"radio-basic": 3, // 라디오 (25%)
|
"radio-basic": 3 / 12, // 라디오 (25%)
|
||||||
"file-basic": 4, // 파일 (33%)
|
"file-basic": 4 / 12, // 파일 (33%)
|
||||||
"file-upload": 4, // 파일 업로드 (33%)
|
"file-upload": 4 / 12, // 파일 업로드 (33%)
|
||||||
"slider-basic": 3, // 슬라이더 (25%)
|
"slider-basic": 3 / 12, // 슬라이더 (25%)
|
||||||
"toggle-switch": 2, // 토글 스위치 (16.67%)
|
"toggle-switch": 2 / 12, // 토글 스위치 (16.67%)
|
||||||
"repeater-field-group": 6, // 반복 필드 그룹 (50%)
|
"repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%)
|
||||||
|
|
||||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||||
"label-basic": 2, // 라벨 (16.67%)
|
"label-basic": 2 / 12, // 라벨 (16.67%)
|
||||||
"text-display": 3, // 텍스트 표시 (25%)
|
"text-display": 3 / 12, // 텍스트 표시 (25%)
|
||||||
"card-display": 8, // 카드 (66.67%)
|
"card-display": 8 / 12, // 카드 (66.67%)
|
||||||
"badge-basic": 1, // 배지 (8.33%)
|
"badge-basic": 1 / 12, // 배지 (8.33%)
|
||||||
"alert-basic": 6, // 알림 (50%)
|
"alert-basic": 6 / 12, // 알림 (50%)
|
||||||
"divider-basic": 12, // 구분선 (100%)
|
"divider-basic": 1, // 구분선 (100%)
|
||||||
"divider-line": 12, // 구분선 (100%)
|
"divider-line": 1, // 구분선 (100%)
|
||||||
"accordion-basic": 12, // 아코디언 (100%)
|
"accordion-basic": 1, // 아코디언 (100%)
|
||||||
"table-list": 12, // 테이블 리스트 (100%)
|
"table-list": 1, // 테이블 리스트 (100%)
|
||||||
"image-display": 4, // 이미지 표시 (33%)
|
"image-display": 4 / 12, // 이미지 표시 (33%)
|
||||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
|
||||||
"flow-widget": 12, // 플로우 위젯 (100%)
|
"flow-widget": 1, // 플로우 위젯 (100%)
|
||||||
|
|
||||||
// 액션 컴포넌트 (ACTION 카테고리)
|
// 액션 컴포넌트 (ACTION 카테고리)
|
||||||
"button-basic": 1, // 버튼 (8.33%)
|
"button-basic": 1 / 12, // 버튼 (8.33%)
|
||||||
"button-primary": 1, // 프라이머리 버튼 (8.33%)
|
"button-primary": 1 / 12, // 프라이머리 버튼 (8.33%)
|
||||||
"button-secondary": 1, // 세컨더리 버튼 (8.33%)
|
"button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%)
|
||||||
"icon-button": 1, // 아이콘 버튼 (8.33%)
|
"icon-button": 1 / 12, // 아이콘 버튼 (8.33%)
|
||||||
|
|
||||||
// 레이아웃 컴포넌트
|
// 레이아웃 컴포넌트
|
||||||
"container-basic": 6, // 컨테이너 (50%)
|
"container-basic": 6 / 12, // 컨테이너 (50%)
|
||||||
"section-basic": 12, // 섹션 (100%)
|
"section-basic": 1, // 섹션 (100%)
|
||||||
"panel-basic": 6, // 패널 (50%)
|
"panel-basic": 6 / 12, // 패널 (50%)
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
"image-basic": 4, // 이미지 (33%)
|
"image-basic": 4 / 12, // 이미지 (33%)
|
||||||
"icon-basic": 1, // 아이콘 (8.33%)
|
"icon-basic": 1 / 12, // 아이콘 (8.33%)
|
||||||
"progress-bar": 4, // 프로그레스 바 (33%)
|
"progress-bar": 4 / 12, // 프로그레스 바 (33%)
|
||||||
"chart-basic": 6, // 차트 (50%)
|
"chart-basic": 6 / 12, // 차트 (50%)
|
||||||
};
|
};
|
||||||
|
|
||||||
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
|
// defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용
|
||||||
if (component.defaultSize?.gridColumnSpan === "full") {
|
if (component.defaultSize?.gridColumnSpan === "full") {
|
||||||
gridColumns = 12;
|
gridColumns = currentGridColumns;
|
||||||
} else {
|
} else {
|
||||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
// componentId 또는 webType으로 비율 찾기, 없으면 기본값 25%
|
||||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25;
|
||||||
|
// 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns)
|
||||||
|
gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns)));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
||||||
|
|
@ -2123,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 = {
|
const newComponent: ComponentData = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||||
|
|
@ -2144,6 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
labelColor: "#212121",
|
labelColor: "#212121",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "4px",
|
labelMarginBottom: "4px",
|
||||||
|
width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -4220,7 +4254,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
<UnifiedPropertiesPanel
|
<UnifiedPropertiesPanel
|
||||||
selectedComponent={selectedComponent || undefined}
|
selectedComponent={selectedComponent || undefined}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
|
gridSettings={layout.gridSettings}
|
||||||
onUpdateProperty={updateComponentProperty}
|
onUpdateProperty={updateComponentProperty}
|
||||||
|
onGridSettingsChange={(newSettings) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gridSettings: newSettings,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
onDeleteComponent={deleteComponent}
|
onDeleteComponent={deleteComponent}
|
||||||
onCopyComponent={copyComponent}
|
onCopyComponent={copyComponent}
|
||||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||||
|
|
|
||||||
|
|
@ -1250,14 +1250,33 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
{/* 실제 컴포넌트 */}
|
{/* 실제 컴포넌트 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={(() => {
|
||||||
position: "absolute",
|
const style = {
|
||||||
left: `${component.position.x}px`,
|
position: "absolute" as const,
|
||||||
top: `${component.position.y}px`,
|
left: `${component.position.x}px`,
|
||||||
width: component.style?.width || `${component.size.width}px`,
|
top: `${component.position.y}px`,
|
||||||
height: component.style?.height || `${component.size.height}px`,
|
width: component.style?.width || `${component.size.width}px`,
|
||||||
zIndex: component.position.z || 1,
|
height: component.style?.height || `${component.size.height}px`,
|
||||||
}}
|
zIndex: component.position.z || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
|
||||||
|
if (
|
||||||
|
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||||
|
(component.type === "component" && (component as any).componentType?.includes("button"))
|
||||||
|
) {
|
||||||
|
console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
|
||||||
|
id: component.id,
|
||||||
|
label: component.label,
|
||||||
|
position: component.position,
|
||||||
|
size: component.size,
|
||||||
|
componentStyle: component.style,
|
||||||
|
appliedStyle: style,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
})()}
|
||||||
>
|
>
|
||||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||||
{component.type !== "widget" ? (
|
{component.type !== "widget" ? (
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="modal">모달 열기</SelectItem>
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||||
|
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||||
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||||
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -709,6 +712,132 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</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">
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
|
|
|
||||||
|
|
@ -2198,6 +2198,90 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
{/* 🆕 자동 필터 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
현재 사용자 정보로 필터링
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 p-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-filter-enabled"
|
||||||
|
checked={component.autoFilter?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdateComponent({
|
||||||
|
autoFilter: {
|
||||||
|
enabled: checked as boolean,
|
||||||
|
filterColumn: component.autoFilter?.filterColumn || 'company_code',
|
||||||
|
userField: component.autoFilter?.userField || 'companyCode',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-filter-enabled" className="font-normal">
|
||||||
|
현재 사용자 정보로 자동 필터링
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{component.autoFilter?.enabled && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="filter-column" className="text-xs">
|
||||||
|
필터링할 테이블 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="filter-column"
|
||||||
|
value={component.autoFilter?.filterColumn || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateComponent({
|
||||||
|
autoFilter: {
|
||||||
|
...component.autoFilter!,
|
||||||
|
filterColumn: e.target.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="company_code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
예: company_code, dept_code, user_id
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-field" className="text-xs">
|
||||||
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={component.autoFilter?.userField || 'companyCode'}
|
||||||
|
onValueChange={(value: any) => {
|
||||||
|
onUpdateComponent({
|
||||||
|
autoFilter: {
|
||||||
|
...component.autoFilter!,
|
||||||
|
userField: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="user-field" className="text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="companyCode">현재 로그인한 사용자 회사 코드</SelectItem>
|
||||||
|
<SelectItem value="userId">현재 로그인한 사용자 ID</SelectItem>
|
||||||
|
<SelectItem value="deptCode">현재 로그인한 사용자 부서 코드</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
로그인한 사용자 정보에서 가져올 필드
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 설정 */}
|
{/* 페이지네이션 설정 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-medium">페이지네이션 설정</h4>
|
<h4 className="text-sm font-medium">페이지네이션 설정</h4>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings, Database } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||||
import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
|
import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
|
||||||
import {
|
import {
|
||||||
|
|
@ -1125,6 +1129,136 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */}
|
||||||
|
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
테이블 데이터 자동 입력
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-fill-enabled-component"
|
||||||
|
checked={selectedComponent.autoFill?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "autoFill", {
|
||||||
|
enabled: checked as boolean,
|
||||||
|
sourceTable: selectedComponent.autoFill?.sourceTable || "",
|
||||||
|
filterColumn: selectedComponent.autoFill?.filterColumn || "company_code",
|
||||||
|
userField: selectedComponent.autoFill?.userField || "companyCode",
|
||||||
|
displayColumn: selectedComponent.autoFill?.displayColumn || "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-fill-enabled-component" className="text-xs font-normal">
|
||||||
|
현재 사용자 정보로 테이블 조회하여 자동 입력
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedComponent.autoFill?.enabled && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="source-table-component" className="text-xs">
|
||||||
|
조회할 테이블 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedComponent.autoFill?.sourceTable || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "autoFill", {
|
||||||
|
...selectedComponent.autoFill!,
|
||||||
|
sourceTable: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="source-table-component" className="text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">데이터를 조회할 테이블</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="filter-column-autofill-component" className="text-xs">
|
||||||
|
필터링할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="filter-column-autofill-component"
|
||||||
|
value={selectedComponent.autoFill?.filterColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "autoFill", {
|
||||||
|
...selectedComponent.autoFill!,
|
||||||
|
filterColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="company_code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">예: company_code, dept_code, user_id</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-field-autofill-component" className="text-xs">
|
||||||
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedComponent.autoFill?.userField || "companyCode"}
|
||||||
|
onValueChange={(value: any) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "autoFill", {
|
||||||
|
...selectedComponent.autoFill!,
|
||||||
|
userField: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="user-field-autofill-component" className="text-xs">
|
||||||
|
<SelectValue placeholder="사용자 정보 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="companyCode" className="text-xs">
|
||||||
|
현재 로그인한 사용자 회사 코드
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="userId" className="text-xs">
|
||||||
|
현재 로그인한 사용자 ID
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deptCode" className="text-xs">
|
||||||
|
현재 로그인한 사용자 부서 코드
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">로그인한 사용자의 정보를 필터로 사용</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="display-column-autofill-component" className="text-xs">
|
||||||
|
표시할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display-column-autofill-component"
|
||||||
|
value={selectedComponent.autoFill?.displayColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "autoFill", {
|
||||||
|
...selectedComponent.autoFill!,
|
||||||
|
displayColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="company_name"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
조회된 레코드에서 표시할 컬럼 (예: company_name)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1202,7 +1336,144 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상세 설정 영역 */}
|
{/* 상세 설정 영역 */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">{renderWebTypeConfig(widget)}</div>
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
|
||||||
|
{/* 🆕 자동 입력 섹션 */}
|
||||||
|
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
🔥 테이블 데이터 자동 입력 (테스트)
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-fill-enabled"
|
||||||
|
checked={widget.autoFill?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdateProperty(widget.id, "autoFill", {
|
||||||
|
enabled: checked as boolean,
|
||||||
|
sourceTable: widget.autoFill?.sourceTable || '',
|
||||||
|
filterColumn: widget.autoFill?.filterColumn || 'company_code',
|
||||||
|
userField: widget.autoFill?.userField || 'companyCode',
|
||||||
|
displayColumn: widget.autoFill?.displayColumn || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-fill-enabled" className="font-normal text-xs">
|
||||||
|
현재 사용자 정보로 테이블 조회하여 자동 입력
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{widget.autoFill?.enabled && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="source-table" className="text-xs">
|
||||||
|
조회할 테이블 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={widget.autoFill?.sourceTable || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty(widget.id, "autoFill", {
|
||||||
|
...widget.autoFill!,
|
||||||
|
sourceTable: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="source-table" className="text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
데이터를 조회할 테이블
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="filter-column-autofill" className="text-xs">
|
||||||
|
필터링할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="filter-column-autofill"
|
||||||
|
value={widget.autoFill?.filterColumn || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty(widget.id, "autoFill", {
|
||||||
|
...widget.autoFill!,
|
||||||
|
filterColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="company_code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
예: company_code, dept_code, user_id
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-field-autofill" className="text-xs">
|
||||||
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={widget.autoFill?.userField || 'companyCode'}
|
||||||
|
onValueChange={(value: any) => {
|
||||||
|
onUpdateProperty(widget.id, "autoFill", {
|
||||||
|
...widget.autoFill!,
|
||||||
|
userField: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="user-field-autofill" className="text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="companyCode">현재 로그인한 사용자 회사 코드</SelectItem>
|
||||||
|
<SelectItem value="userId">현재 로그인한 사용자 ID</SelectItem>
|
||||||
|
<SelectItem value="deptCode">현재 로그인한 사용자 부서 코드</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
로그인한 사용자 정보에서 가져올 필드
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="display-column" className="text-xs">
|
||||||
|
표시할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display-column"
|
||||||
|
value={widget.autoFill?.displayColumn || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty(widget.id, "autoFill", {
|
||||||
|
...widget.autoFill!,
|
||||||
|
displayColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="company_name"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Input에 표시할 컬럼명 (예: company_name, dept_name)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 웹타입 설정 */}
|
||||||
|
<Separator />
|
||||||
|
{renderWebTypeConfig(widget)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
|
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
|
||||||
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
||||||
import { formatFileSize } from "@/lib/utils";
|
import { formatFileSize, cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface FileComponentConfigPanelProps {
|
interface FileComponentConfigPanelProps {
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,27 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="columns" className="text-xs font-medium">
|
<Label htmlFor="columns" className="text-xs font-medium">
|
||||||
컬럼 수: <span className="text-primary">{gridSettings.columns}</span>
|
컬럼 수
|
||||||
</Label>
|
</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
|
<Slider
|
||||||
id="columns"
|
id="columns-slider"
|
||||||
min={1}
|
min={1}
|
||||||
max={24}
|
max={24}
|
||||||
step={1}
|
step={1}
|
||||||
|
|
@ -139,8 +156,8 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground flex justify-between text-xs">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>1</span>
|
<span>1열</span>
|
||||||
<span>24</span>
|
<span>24열</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ interface PropertiesPanelProps {
|
||||||
draggedComponent: ComponentData | null;
|
draggedComponent: ComponentData | null;
|
||||||
currentPosition: { x: number; y: number; z: number };
|
currentPosition: { x: number; y: number; z: number };
|
||||||
};
|
};
|
||||||
|
gridSettings?: {
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
padding: number;
|
||||||
|
snapToGrid: boolean;
|
||||||
|
};
|
||||||
onUpdateProperty: (path: string, value: unknown) => void;
|
onUpdateProperty: (path: string, value: unknown) => void;
|
||||||
onDeleteComponent: () => void;
|
onDeleteComponent: () => void;
|
||||||
onCopyComponent: () => void;
|
onCopyComponent: () => void;
|
||||||
|
|
@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
tables = [],
|
tables = [],
|
||||||
dragState,
|
dragState,
|
||||||
|
gridSettings,
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
onCopyComponent,
|
onCopyComponent,
|
||||||
|
|
@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
||||||
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
||||||
<>
|
<>
|
||||||
{/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
|
{/* 🆕 그리드 컬럼 수 직접 입력 */}
|
||||||
<div className="col-span-2">
|
<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
|
<Select
|
||||||
value={localWidthSpan}
|
value={localWidthSpan}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ import {
|
||||||
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
||||||
|
|
||||||
// 컬럼 스팬 숫자 배열 (1~12)
|
// 컬럼 스팬 숫자 배열 (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 { cn } from "@/lib/utils";
|
||||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||||
|
|
@ -52,11 +55,23 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||||
import StyleEditor from "../StyleEditor";
|
import StyleEditor from "../StyleEditor";
|
||||||
import ResolutionPanel from "./ResolutionPanel";
|
import ResolutionPanel from "./ResolutionPanel";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
|
||||||
|
|
||||||
interface UnifiedPropertiesPanelProps {
|
interface UnifiedPropertiesPanelProps {
|
||||||
selectedComponent?: ComponentData;
|
selectedComponent?: ComponentData;
|
||||||
tables: TableInfo[];
|
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;
|
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||||
|
onGridSettingsChange?: (settings: any) => void;
|
||||||
onDeleteComponent?: (componentId: string) => void;
|
onDeleteComponent?: (componentId: string) => void;
|
||||||
onCopyComponent?: (componentId: string) => void;
|
onCopyComponent?: (componentId: string) => void;
|
||||||
currentTable?: TableInfo;
|
currentTable?: TableInfo;
|
||||||
|
|
@ -74,7 +89,9 @@ interface UnifiedPropertiesPanelProps {
|
||||||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
tables,
|
tables,
|
||||||
|
gridSettings,
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
|
onGridSettingsChange,
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
onCopyComponent,
|
onCopyComponent,
|
||||||
currentTable,
|
currentTable,
|
||||||
|
|
@ -98,29 +115,154 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
}, [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}
|
||||||
|
value={gridSettings.columns}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1) {
|
||||||
|
updateGridSetting("columns", value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 px-2 py-0 text-xs"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
placeholder="1 이상의 숫자"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
1 이상의 숫자를 입력하세요
|
||||||
|
</p>
|
||||||
|
</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) {
|
if (!selectedComponent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-white">
|
<div className="flex h-full flex-col bg-white">
|
||||||
{/* 해상도 설정만 표시 */}
|
{/* 해상도 설정과 격자 설정 표시 */}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
|
{/* 해상도 설정 */}
|
||||||
{currentResolution && onResolutionChange && (
|
{currentResolution && onResolutionChange && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="space-y-2">
|
||||||
<Monitor className="text-primary h-3 w-3" />
|
<div className="flex items-center gap-1.5">
|
||||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
<Monitor className="text-primary h-3 w-3" />
|
||||||
|
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||||
|
</div>
|
||||||
|
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||||
</div>
|
</div>
|
||||||
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
<Separator className="my-2" />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 격자 설정 */}
|
||||||
|
{renderGridSettings()}
|
||||||
|
|
||||||
{/* 안내 메시지 */}
|
{/* 안내 메시지 */}
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Settings className="mb-2 h-8 w-8 text-muted-foreground/30" />
|
<Settings className="text-muted-foreground/30 mb-2 h-8 w-8" />
|
||||||
<p className="text-[10px] text-muted-foreground">컴포넌트를 선택하여</p>
|
<p className="text-muted-foreground text-[10px]">컴포넌트를 선택하여</p>
|
||||||
<p className="text-[10px] text-muted-foreground">속성을 편집하세요</p>
|
<p className="text-muted-foreground text-[10px]">속성을 편집하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -283,22 +425,31 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{(selectedComponent as any).gridColumns !== undefined && (
|
{(selectedComponent as any).gridColumns !== undefined && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Grid</Label>
|
<Label className="text-xs">차지 컬럼 수</Label>
|
||||||
<Select
|
<div className="flex items-center gap-1">
|
||||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
<Input
|
||||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
type="number"
|
||||||
>
|
min={1}
|
||||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
max={gridSettings?.columns || 12}
|
||||||
<SelectValue />
|
value={(selectedComponent as any).gridColumns || 1}
|
||||||
</SelectTrigger>
|
onChange={(e) => {
|
||||||
<SelectContent>
|
const value = parseInt(e.target.value, 10);
|
||||||
{COLUMN_NUMBERS.map((span) => (
|
const maxColumns = gridSettings?.columns || 12;
|
||||||
<SelectItem key={span} value={span.toString()} style={{ fontSize: "12px" }}>
|
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
||||||
{span}열
|
handleUpdate("gridColumns", value);
|
||||||
</SelectItem>
|
|
||||||
))}
|
// width를 퍼센트로 계산하여 업데이트
|
||||||
</SelectContent>
|
const widthPercent = (value / maxColumns) * 100;
|
||||||
</Select>
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -412,8 +563,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
|
|
||||||
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
|
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
|
||||||
const renderDetailTab = () => {
|
const renderDetailTab = () => {
|
||||||
|
console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type);
|
||||||
|
|
||||||
// 1. DataTable 컴포넌트
|
// 1. DataTable 컴포넌트
|
||||||
if (selectedComponent.type === "datatable") {
|
if (selectedComponent.type === "datatable") {
|
||||||
|
console.log("✅ [renderDetailTab] DataTable 컴포넌트");
|
||||||
return (
|
return (
|
||||||
<DataTableConfigPanel
|
<DataTableConfigPanel
|
||||||
component={selectedComponent as DataTableComponent}
|
component={selectedComponent as DataTableComponent}
|
||||||
|
|
@ -470,6 +624,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
|
|
||||||
// 5. 새로운 컴포넌트 시스템 (type: "component")
|
// 5. 새로운 컴포넌트 시스템 (type: "component")
|
||||||
if (selectedComponent.type === "component") {
|
if (selectedComponent.type === "component") {
|
||||||
|
console.log("✅ [renderDetailTab] Component 타입");
|
||||||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||||
const webType = selectedComponent.componentConfig?.webType;
|
const webType = selectedComponent.componentConfig?.webType;
|
||||||
|
|
||||||
|
|
@ -479,7 +634,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
if (!componentId) {
|
if (!componentId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">컴포넌트 ID가 설정되지 않았습니다</p>
|
<p className="text-muted-foreground text-sm">컴포넌트 ID가 설정되지 않았습니다</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -511,7 +666,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{option.label}</div>
|
<div className="font-medium">{option.label}</div>
|
||||||
<div className="text-xs text-muted-foreground">{option.description}</div>
|
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -535,45 +690,154 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 🆕 테이블 데이터 자동 입력 (component 타입용) */}
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="text-primary h-4 w-4" />
|
||||||
|
<h4 className="text-xs font-semibold">테이블 데이터 자동 입력</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성화 체크박스 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autoFill-enabled-component"
|
||||||
|
checked={selectedComponent.autoFill?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...selectedComponent.autoFill,
|
||||||
|
enabled: Boolean(checked),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="autoFill-enabled-component" className="cursor-pointer text-xs">
|
||||||
|
현재 사용자 정보로 테이블 조회하여 자동 입력
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedComponent.autoFill?.enabled && (
|
||||||
|
<>
|
||||||
|
{/* 조회할 테이블 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-sourceTable-component" className="text-xs">
|
||||||
|
조회할 테이블 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedComponent.autoFill?.sourceTable || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...selectedComponent.autoFill,
|
||||||
|
enabled: selectedComponent.autoFill?.enabled || false,
|
||||||
|
sourceTable: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName} className="text-xs">
|
||||||
|
{table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터링할 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-filterColumn-component" className="text-xs">
|
||||||
|
필터링할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoFill-filterColumn-component"
|
||||||
|
value={selectedComponent.autoFill?.filterColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...selectedComponent.autoFill,
|
||||||
|
enabled: selectedComponent.autoFill?.enabled || false,
|
||||||
|
filterColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="예: company_code"
|
||||||
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 정보 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-userField-component" className="text-xs">
|
||||||
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedComponent.autoFill?.userField || ""}
|
||||||
|
onValueChange={(value: "companyCode" | "userId" | "deptCode") => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...selectedComponent.autoFill,
|
||||||
|
enabled: selectedComponent.autoFill?.enabled || false,
|
||||||
|
userField: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||||
|
<SelectValue placeholder="사용자 정보 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="companyCode" className="text-xs">
|
||||||
|
현재 로그인한 사용자 회사 코드
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="userId" className="text-xs">
|
||||||
|
현재 로그인한 사용자 ID
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deptCode" className="text-xs">
|
||||||
|
현재 로그인한 사용자 부서 코드
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시할 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-displayColumn-component" className="text-xs">
|
||||||
|
표시할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoFill-displayColumn-component"
|
||||||
|
value={selectedComponent.autoFill?.displayColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...selectedComponent.autoFill,
|
||||||
|
enabled: selectedComponent.autoFill?.enabled || false,
|
||||||
|
displayColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="예: company_name"
|
||||||
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Widget 컴포넌트
|
// 6. Widget 컴포넌트
|
||||||
if (selectedComponent.type === "widget") {
|
if (selectedComponent.type === "widget") {
|
||||||
|
console.log("✅ [renderDetailTab] Widget 타입");
|
||||||
const widget = selectedComponent as WidgetComponent;
|
const widget = selectedComponent as WidgetComponent;
|
||||||
|
console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType);
|
||||||
|
|
||||||
// Widget에 webType이 있는 경우
|
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크
|
||||||
if (widget.webType) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* WebType 선택 */}
|
|
||||||
<div>
|
|
||||||
<Label>입력 타입</Label>
|
|
||||||
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
|
|
||||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{webTypes.map((wt) => (
|
|
||||||
<SelectItem key={wt.web_type} value={wt.web_type}>
|
|
||||||
{wt.web_type_name_kor || wt.web_type}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등)
|
|
||||||
if (
|
if (
|
||||||
widget.widgetType &&
|
widget.widgetType &&
|
||||||
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
|
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
|
||||||
widget.widgetType,
|
widget.widgetType,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)");
|
||||||
return (
|
return (
|
||||||
<DynamicComponentConfigPanel
|
<DynamicComponentConfigPanel
|
||||||
componentId={widget.widgetType}
|
componentId={widget.widgetType}
|
||||||
|
|
@ -589,12 +853,168 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일반 위젯 (webType 기반)
|
||||||
|
console.log("✅ [renderDetailTab] 일반 위젯 렌더링 시작");
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{console.log("🔍 [UnifiedPropertiesPanel] widget.webType:", widget.webType, "widget:", widget)}
|
||||||
|
{/* WebType 선택 (있는 경우만) */}
|
||||||
|
{widget.webType && (
|
||||||
|
<div>
|
||||||
|
<Label>입력 타입</Label>
|
||||||
|
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
|
||||||
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{webTypes.map((wt) => (
|
||||||
|
<SelectItem key={wt.web_type} value={wt.web_type}>
|
||||||
|
{wt.web_type_name_kor || wt.web_type}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3 border-4 border-red-500 bg-yellow-100 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="text-primary h-4 w-4" />
|
||||||
|
<h4 className="text-xs font-semibold">테이블 데이터 자동 입력</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성화 체크박스 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autoFill-enabled"
|
||||||
|
checked={widget.autoFill?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...widget.autoFill,
|
||||||
|
enabled: Boolean(checked),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="autoFill-enabled" className="cursor-pointer text-xs">
|
||||||
|
현재 사용자 정보로 테이블 조회하여 자동 입력
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{widget.autoFill?.enabled && (
|
||||||
|
<>
|
||||||
|
{/* 조회할 테이블 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-sourceTable" className="text-xs">
|
||||||
|
조회할 테이블 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={widget.autoFill?.sourceTable || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...widget.autoFill,
|
||||||
|
enabled: widget.autoFill?.enabled || false,
|
||||||
|
sourceTable: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName} className="text-xs">
|
||||||
|
{table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터링할 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-filterColumn" className="text-xs">
|
||||||
|
필터링할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoFill-filterColumn"
|
||||||
|
value={widget.autoFill?.filterColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...widget.autoFill,
|
||||||
|
enabled: widget.autoFill?.enabled || false,
|
||||||
|
filterColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="예: company_code"
|
||||||
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 정보 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-userField" className="text-xs">
|
||||||
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={widget.autoFill?.userField || ""}
|
||||||
|
onValueChange={(value: "companyCode" | "userId" | "deptCode") => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...widget.autoFill,
|
||||||
|
enabled: widget.autoFill?.enabled || false,
|
||||||
|
userField: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||||
|
<SelectValue placeholder="사용자 정보 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="companyCode" className="text-xs">
|
||||||
|
현재 로그인한 사용자 회사 코드
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="userId" className="text-xs">
|
||||||
|
현재 로그인한 사용자 ID
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deptCode" className="text-xs">
|
||||||
|
현재 로그인한 사용자 부서 코드
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시할 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoFill-displayColumn" className="text-xs">
|
||||||
|
표시할 컬럼 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoFill-displayColumn"
|
||||||
|
value={widget.autoFill?.displayColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleUpdate("autoFill", {
|
||||||
|
...widget.autoFill,
|
||||||
|
enabled: widget.autoFill?.enabled || false,
|
||||||
|
displayColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="예: company_name"
|
||||||
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 메시지
|
// 기본 메시지
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">이 컴포넌트는 추가 설정이 없습니다</p>
|
<p className="text-muted-foreground text-sm">이 컴포넌트는 추가 설정이 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -602,9 +1022,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-white">
|
<div className="flex h-full flex-col bg-white">
|
||||||
{/* 헤더 - 간소화 */}
|
{/* 헤더 - 간소화 */}
|
||||||
<div className="border-b border-border px-3 py-2">
|
<div className="border-border border-b px-3 py-2">
|
||||||
{selectedComponent.type === "widget" && (
|
{selectedComponent.type === "widget" && (
|
||||||
<div className="truncate text-[10px] text-muted-foreground">
|
<div className="text-muted-foreground truncate text-[10px]">
|
||||||
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -627,6 +1047,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 격자 설정 - 해상도 설정 아래 표시 */}
|
||||||
|
{renderGridSettings()}
|
||||||
|
{gridSettings && onGridSettingsChange && <Separator className="my-2" />}
|
||||||
|
|
||||||
{/* 기본 설정 */}
|
{/* 기본 설정 */}
|
||||||
{renderBasicTab()}
|
{renderBasicTab()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
|
import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,6 +8,9 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 테이블 템플릿 컴포넌트
|
* 데이터 테이블 템플릿 컴포넌트
|
||||||
|
|
@ -121,6 +124,13 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
||||||
className = "",
|
className = "",
|
||||||
isPreview = true,
|
isPreview = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// 🆕 검색 필터 관련 상태
|
||||||
|
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set());
|
||||||
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||||
|
const [searchValues, setSearchValues] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 설정된 컬럼만 사용 (자동 생성 안함)
|
// 설정된 컬럼만 사용 (자동 생성 안함)
|
||||||
const defaultColumns = React.useMemo(() => {
|
const defaultColumns = React.useMemo(() => {
|
||||||
return columns || [];
|
return columns || [];
|
||||||
|
|
@ -138,6 +148,54 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
||||||
}, [isPreview]);
|
}, [isPreview]);
|
||||||
|
|
||||||
const visibleColumns = defaultColumns.filter((col) => col.visible);
|
const visibleColumns = defaultColumns.filter((col) => col.visible);
|
||||||
|
|
||||||
|
// 🆕 컬럼명 -> 라벨 매핑
|
||||||
|
const columnLabels = React.useMemo(() => {
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
defaultColumns.forEach(col => {
|
||||||
|
labels[col.id] = col.label;
|
||||||
|
});
|
||||||
|
return labels;
|
||||||
|
}, [defaultColumns]);
|
||||||
|
|
||||||
|
// 🆕 localStorage에서 필터 설정 복원
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.userId && title) {
|
||||||
|
const storageKey = `datatable-search-filter-${user.userId}-${title}`;
|
||||||
|
const savedFilter = localStorage.getItem(storageKey);
|
||||||
|
if (savedFilter) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedFilter);
|
||||||
|
setSearchFilterColumns(new Set(parsed));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("필터 설정 복원 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user?.userId, title]);
|
||||||
|
|
||||||
|
// 🆕 필터 저장 함수
|
||||||
|
const handleSaveSearchFilter = useCallback(() => {
|
||||||
|
if (user?.userId && title) {
|
||||||
|
const storageKey = `datatable-search-filter-${user.userId}-${title}`;
|
||||||
|
const filterArray = Array.from(searchFilterColumns);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(filterArray));
|
||||||
|
toast.success("검색 필터 설정이 저장되었습니다.");
|
||||||
|
}
|
||||||
|
}, [user?.userId, title, searchFilterColumns]);
|
||||||
|
|
||||||
|
// 🆕 필터 토글 함수
|
||||||
|
const handleToggleFilterColumn = useCallback((columnId: string) => {
|
||||||
|
setSearchFilterColumns((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(columnId)) {
|
||||||
|
newSet.delete(columnId);
|
||||||
|
} else {
|
||||||
|
newSet.add(columnId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`h-full w-full ${className}`} style={style}>
|
<Card className={`h-full w-full ${className}`} style={style}>
|
||||||
|
|
@ -178,23 +236,65 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{/* 🆕 검색 필터 설정 버튼 영역 */}
|
||||||
|
{defaultColumns.length > 0 && (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
|
disabled={isPreview}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
검색 필터 설정
|
||||||
|
{searchFilterColumns.size > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
|
||||||
|
{searchFilterColumns.size}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 선택된 컬럼의 검색 입력 필드 */}
|
||||||
|
{searchFilterColumns.size > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 rounded-lg border bg-gray-50/50 p-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from(searchFilterColumns).map((columnId) => {
|
||||||
|
const column = defaultColumns.find(col => col.id === columnId);
|
||||||
|
if (!column) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={columnId} className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-700">
|
||||||
|
{column.label}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder={`${column.label} 검색...`}
|
||||||
|
value={searchValues[columnId] || ""}
|
||||||
|
onChange={(e) => setSearchValues(prev => ({...prev, [columnId]: e.target.value}))}
|
||||||
|
disabled={isPreview}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 검색 및 필터 영역 */}
|
{/* 검색 및 필터 영역 */}
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
{/* 검색 입력 */}
|
{/* 검색 입력 */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input placeholder="검색어를 입력하세요..." className="pl-10" disabled={isPreview} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actions.showSearchButton && (
|
{actions.showSearchButton && (
|
||||||
<Button variant="outline" disabled={isPreview}>
|
<Button variant="outline" disabled={isPreview}>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
{actions.searchButtonText}
|
{actions.searchButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 영역 */}
|
{/* 기존 필터 영역 (이제는 사용하지 않음) */}
|
||||||
{filters.length > 0 && (
|
{filters.length > 0 && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Filter className="text-muted-foreground h-4 w-4" />
|
<Filter className="text-muted-foreground h-4 w-4" />
|
||||||
|
|
@ -352,6 +452,46 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
{/* 🆕 검색 필터 설정 다이얼로그 */}
|
||||||
|
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>검색 필터 설정</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-y-auto py-4">
|
||||||
|
{defaultColumns.map((column) => (
|
||||||
|
<div key={column.id} className="flex items-center space-x-3 rounded-lg p-3 hover:bg-gray-50">
|
||||||
|
<Checkbox
|
||||||
|
id={`filter-${column.id}`}
|
||||||
|
checked={searchFilterColumns.has(column.id)}
|
||||||
|
onCheckedChange={() => handleToggleFilterColumn(column.id)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`filter-${column.id}`} className="flex-1 cursor-pointer text-sm">
|
||||||
|
{column.label}
|
||||||
|
<span className="ml-2 text-xs text-gray-500">({column.type})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setIsFilterSettingOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
handleSaveSearchFilter();
|
||||||
|
setIsFilterSettingOpen(false);
|
||||||
|
}}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlowComponent } from "@/types/screen-management";
|
import { FlowComponent } from "@/types/screen-management";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react";
|
import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getFlowById,
|
getFlowById,
|
||||||
getAllStepCounts,
|
getAllStepCounts,
|
||||||
|
|
@ -40,6 +40,14 @@ import { Input } from "@/components/ui/input";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
// 그룹화된 데이터 인터페이스
|
||||||
|
interface GroupedData {
|
||||||
|
groupKey: string;
|
||||||
|
groupValues: Record<string, any>;
|
||||||
|
items: any[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowWidgetProps {
|
interface FlowWidgetProps {
|
||||||
component: FlowComponent;
|
component: FlowComponent;
|
||||||
onStepClick?: (stepId: number, stepName: string) => void;
|
onStepClick?: (stepId: number, stepName: string) => void;
|
||||||
|
|
@ -58,6 +66,28 @@ export function FlowWidget({
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
|
|
||||||
|
// 숫자 포맷팅 함수
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
|
||||||
|
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
// 🆕 전역 상태 관리
|
// 🆕 전역 상태 관리
|
||||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||||
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
||||||
|
|
@ -84,6 +114,11 @@ export function FlowWidget({
|
||||||
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||||
|
|
||||||
|
// 🆕 그룹 설정 관련 상태
|
||||||
|
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
||||||
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); // 접힌 그룹
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 컬럼 표시 결정 함수
|
* 🆕 컬럼 표시 결정 함수
|
||||||
* 1순위: 플로우 스텝 기본 설정 (displayConfig)
|
* 1순위: 플로우 스텝 기본 설정 (displayConfig)
|
||||||
|
|
@ -125,6 +160,12 @@ export function FlowWidget({
|
||||||
return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`;
|
return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`;
|
||||||
}, [flowId, selectedStepId, user?.userId]);
|
}, [flowId, selectedStepId, user?.userId]);
|
||||||
|
|
||||||
|
// 🆕 그룹 설정 localStorage 키 생성
|
||||||
|
const groupSettingKey = useMemo(() => {
|
||||||
|
if (!selectedStepId) return null;
|
||||||
|
return `flowWidget_groupSettings_step_${selectedStepId}`;
|
||||||
|
}, [selectedStepId]);
|
||||||
|
|
||||||
// 🆕 저장된 필터 설정 불러오기
|
// 🆕 저장된 필터 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return;
|
if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return;
|
||||||
|
|
@ -141,43 +182,30 @@ export function FlowWidget({
|
||||||
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
||||||
setSearchFilterColumns(new Set());
|
setSearchFilterColumns(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거)
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const currentUserId = user.userId;
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
|
|
||||||
// localStorage의 모든 키를 확인
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key && key.startsWith("flowWidget_searchFilters_")) {
|
|
||||||
// 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId}
|
|
||||||
// split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"]
|
|
||||||
// 따라서 userId는 parts[2]입니다
|
|
||||||
const parts = key.split("_");
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId
|
|
||||||
// 현재 사용자 ID와 다른 사용자의 설정은 제거
|
|
||||||
if (userIdFromKey !== currentUserId) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이전 사용자의 설정 제거
|
|
||||||
if (keysToRemove.length > 0) {
|
|
||||||
keysToRemove.forEach(key => {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("필터 설정 불러오기 실패:", error);
|
console.error("필터 설정 불러오기 실패:", error);
|
||||||
setSearchFilterColumns(new Set());
|
setSearchFilterColumns(new Set());
|
||||||
}
|
}
|
||||||
}, [filterSettingKey, stepDataColumns, user?.userId]);
|
}, [filterSettingKey, stepDataColumns, user?.userId]);
|
||||||
|
|
||||||
|
// 🆕 저장된 그룹 설정 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupSettingKey || stepDataColumns.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(groupSettingKey);
|
||||||
|
if (saved) {
|
||||||
|
const savedGroups = JSON.parse(saved);
|
||||||
|
// 현재 단계에 표시되는 컬럼만 필터링
|
||||||
|
const validGroups = savedGroups.filter((col: string) => stepDataColumns.includes(col));
|
||||||
|
setGroupByColumns(validGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 불러오기 실패:", error);
|
||||||
|
setGroupByColumns([]);
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, stepDataColumns]);
|
||||||
|
|
||||||
// 🆕 필터 설정 저장
|
// 🆕 필터 설정 저장
|
||||||
const saveFilterSettings = useCallback(() => {
|
const saveFilterSettings = useCallback(() => {
|
||||||
if (!filterSettingKey) return;
|
if (!filterSettingKey) return;
|
||||||
|
|
@ -225,6 +253,92 @@ export function FlowWidget({
|
||||||
setFilteredData([]);
|
setFilteredData([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 그룹 설정 저장
|
||||||
|
const saveGroupSettings = useCallback(() => {
|
||||||
|
if (!groupSettingKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||||
|
setIsGroupSettingOpen(false);
|
||||||
|
toast.success("그룹 설정이 저장되었습니다");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 저장 실패:", error);
|
||||||
|
toast.error("설정 저장에 실패했습니다");
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, groupByColumns]);
|
||||||
|
|
||||||
|
// 🆕 그룹 컬럼 토글
|
||||||
|
const toggleGroupColumn = useCallback((columnName: string) => {
|
||||||
|
setGroupByColumns((prev) => {
|
||||||
|
if (prev.includes(columnName)) {
|
||||||
|
return prev.filter((col) => col !== columnName);
|
||||||
|
} else {
|
||||||
|
return [...prev, columnName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 그룹 펼치기/접기 토글
|
||||||
|
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(groupKey)) {
|
||||||
|
newSet.delete(groupKey);
|
||||||
|
} else {
|
||||||
|
newSet.add(groupKey);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 그룹 해제
|
||||||
|
const clearGrouping = useCallback(() => {
|
||||||
|
setGroupByColumns([]);
|
||||||
|
setCollapsedGroups(new Set());
|
||||||
|
if (groupSettingKey) {
|
||||||
|
localStorage.removeItem(groupSettingKey);
|
||||||
|
}
|
||||||
|
toast.success("그룹이 해제되었습니다");
|
||||||
|
}, [groupSettingKey]);
|
||||||
|
|
||||||
|
// 🆕 데이터 그룹화
|
||||||
|
const groupedData = useMemo((): GroupedData[] => {
|
||||||
|
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
||||||
|
|
||||||
|
if (groupByColumns.length === 0 || dataToGroup.length === 0) return [];
|
||||||
|
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
|
dataToGroup.forEach((item) => {
|
||||||
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||||
|
const keyParts = groupByColumns.map((col) => {
|
||||||
|
const value = item[col];
|
||||||
|
const label = columnLabels[col] || col;
|
||||||
|
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
|
||||||
|
});
|
||||||
|
const groupKey = keyParts.join(" > ");
|
||||||
|
|
||||||
|
if (!grouped.has(groupKey)) {
|
||||||
|
grouped.set(groupKey, []);
|
||||||
|
}
|
||||||
|
grouped.get(groupKey)!.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||||
|
const groupValues: Record<string, any> = {};
|
||||||
|
groupByColumns.forEach((col) => {
|
||||||
|
groupValues[col] = items[0]?.[col];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
groupValues,
|
||||||
|
items,
|
||||||
|
count: items.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [filteredData, stepData, groupByColumns, columnLabels]);
|
||||||
|
|
||||||
// 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리)
|
// 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stepData || stepData.length === 0) {
|
if (!stepData || stepData.length === 0) {
|
||||||
|
|
@ -657,17 +771,6 @@ export function FlowWidget({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container flex w-full flex-col p-2 sm:p-4 lg:p-6">
|
<div className="@container flex w-full flex-col p-2 sm:p-4 lg:p-6">
|
||||||
{/* 플로우 제목 */}
|
|
||||||
<div className="mb-3 flex-shrink-0 sm:mb-4">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{flowData.description && (
|
|
||||||
<p className="text-muted-foreground mt-1 text-center text-xs sm:text-sm">{flowData.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 플로우 스텝 목록 */}
|
{/* 플로우 스텝 목록 */}
|
||||||
<div className={`${containerClass} flex-shrink-0`}>
|
<div className={`${containerClass} flex-shrink-0`}>
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
|
|
@ -698,7 +801,7 @@ export function FlowWidget({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium sm:text-base">
|
<span className="text-sm font-medium sm:text-base">
|
||||||
{stepCounts[step.id] || 0}
|
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-normal sm:text-sm">건</span>
|
<span className="text-xs font-normal sm:text-sm">건</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -754,85 +857,115 @@ export function FlowWidget({
|
||||||
{/* 선택된 스텝의 데이터 리스트 */}
|
{/* 선택된 스텝의 데이터 리스트 */}
|
||||||
{selectedStepId !== null && (
|
{selectedStepId !== null && (
|
||||||
<div className="mt-4 flex w-full flex-col sm:mt-6 lg:mt-8">
|
<div className="mt-4 flex w-full flex-col sm:mt-6 lg:mt-8">
|
||||||
{/* 헤더 - 자동 높이 */}
|
{/* 필터 및 그룹 설정 */}
|
||||||
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
{stepDataColumns.length > 0 && (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<>
|
||||||
<div className="flex-1">
|
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
||||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
{/* 검색 필터 입력 영역 */}
|
||||||
</h4>
|
{searchFilterColumns.size > 0 && (
|
||||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
<>
|
||||||
총 {stepData.length}건의 데이터
|
{Array.from(searchFilterColumns).map((col) => (
|
||||||
{filteredData.length > 0 && (
|
<Input
|
||||||
<span className="text-primary ml-2 font-medium">(필터링: {filteredData.length}건)</span>
|
key={col}
|
||||||
|
value={searchValues[col] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSearchValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[col]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={`${columnLabels[col] || col} 검색...`}
|
||||||
|
className="h-8 text-xs w-40"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Object.keys(searchValues).length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-8 text-xs">
|
||||||
|
<X className="mr-1 h-3 w-3" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedRows.size > 0 && (
|
|
||||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
{/* 필터/그룹 설정 버튼 */}
|
||||||
)}
|
<div className="flex gap-2 ml-auto">
|
||||||
</p>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (isPreviewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsFilterSettingOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={isPreviewMode}
|
||||||
|
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Filter className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
검색 필터 설정
|
||||||
|
{searchFilterColumns.size > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||||
|
{searchFilterColumns.size}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (isPreviewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsGroupSettingOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={isPreviewMode}
|
||||||
|
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
그룹 설정
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||||
|
{groupByColumns.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 필터 설정 버튼 */}
|
{/* 🆕 그룹 표시 배지 */}
|
||||||
{stepDataColumns.length > 0 && (
|
{groupByColumns.length > 0 && (
|
||||||
<Button
|
<div className="border-b border-border bg-muted/30 px-4 py-2">
|
||||||
variant="outline"
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
size="sm"
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
onClick={() => {
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
if (isPreviewMode) {
|
{groupByColumns.map((col, idx) => (
|
||||||
return;
|
<span key={col} className="flex items-center">
|
||||||
}
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||||
setIsFilterSettingOpen(true);
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||||
}}
|
{columnLabels[col] || col}
|
||||||
disabled={isPreviewMode}
|
</span>
|
||||||
className="h-8 shrink-0 text-xs sm:text-sm"
|
</span>
|
||||||
>
|
))}
|
||||||
<Filter className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
</div>
|
||||||
검색 필터 설정
|
<button
|
||||||
{searchFilterColumns.size > 0 && (
|
onClick={() => {
|
||||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
if (!isPreviewMode) {
|
||||||
{searchFilterColumns.size}
|
clearGrouping();
|
||||||
</Badge>
|
}
|
||||||
)}
|
}}
|
||||||
</Button>
|
disabled={isPreviewMode}
|
||||||
)}
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||||
</div>
|
title="그룹 해제"
|
||||||
|
>
|
||||||
{/* 🆕 검색 필터 입력 영역 */}
|
<X className="h-4 w-4" />
|
||||||
{searchFilterColumns.size > 0 && (
|
</button>
|
||||||
<div className="mt-2 space-y-3 p-4">
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
{Object.keys(searchValues).length > 0 && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-7 text-xs">
|
|
||||||
<X className="mr-1 h-3 w-3" />
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
|
||||||
{Array.from(searchFilterColumns).map((col) => (
|
|
||||||
<div key={col} className="space-y-1.5">
|
|
||||||
<Label htmlFor={`search-${col}`} className="text-xs">
|
|
||||||
{columnLabels[col] || col}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`search-${col}`}
|
|
||||||
value={searchValues[col] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSearchValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[col]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={`${columnLabels[col] || col} 검색...`}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데이터 영역 - 고정 높이 + 스크롤 */}
|
{/* 데이터 영역 - 고정 높이 + 스크롤 */}
|
||||||
{stepDataLoading ? (
|
{stepDataLoading ? (
|
||||||
|
|
@ -884,13 +1017,7 @@ export function FlowWidget({
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||||
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
||||||
<span className="text-foreground truncate">
|
<span className="text-foreground truncate">{formatValue(row[col])}</span>
|
||||||
{row[col] !== null && row[col] !== undefined ? (
|
|
||||||
String(row[col])
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -924,33 +1051,87 @@ export function FlowWidget({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedStepData.map((row, pageIndex) => {
|
{groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
// 그룹화된 렌더링
|
||||||
return (
|
groupedData.flatMap((group) => {
|
||||||
<TableRow
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
key={actualIndex}
|
const groupRows = [
|
||||||
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
<TableRow key={`group-${group.groupKey}`}>
|
||||||
>
|
<TableCell
|
||||||
{allowDataMove && (
|
colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)}
|
||||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
className="bg-muted/50 border-b"
|
||||||
<Checkbox
|
>
|
||||||
checked={selectedRows.has(actualIndex)}
|
<div
|
||||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
className="flex items-center gap-3 p-2 cursor-pointer hover:bg-muted"
|
||||||
/>
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
</TableRow>,
|
||||||
{stepDataColumns.map((col) => (
|
];
|
||||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
|
||||||
{row[col] !== null && row[col] !== undefined ? (
|
if (!isCollapsed) {
|
||||||
String(row[col])
|
const dataRows = group.items.map((row, itemIndex) => {
|
||||||
) : (
|
const actualIndex = displayData.indexOf(row);
|
||||||
<span className="text-muted-foreground">-</span>
|
return (
|
||||||
)}
|
<TableRow
|
||||||
</TableCell>
|
key={`${group.groupKey}-${itemIndex}`}
|
||||||
))}
|
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||||
</TableRow>
|
>
|
||||||
);
|
{allowDataMove && (
|
||||||
})}
|
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(actualIndex)}
|
||||||
|
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||||
|
{formatValue(row[col])}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
groupRows.push(...dataRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupRows;
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
// 일반 렌더링 (그룹 없음)
|
||||||
|
paginatedStepData.map((row, pageIndex) => {
|
||||||
|
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={actualIndex}
|
||||||
|
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||||
|
>
|
||||||
|
{allowDataMove && (
|
||||||
|
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(actualIndex)}
|
||||||
|
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||||
|
{formatValue(row[col])}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -964,7 +1145,7 @@ export function FlowWidget({
|
||||||
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
|
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
|
||||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
||||||
<div className="text-muted-foreground text-xs sm:text-sm">
|
<div className="text-muted-foreground text-xs sm:text-sm">
|
||||||
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건)
|
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground text-xs">표시 개수:</span>
|
<span className="text-muted-foreground text-xs">표시 개수:</span>
|
||||||
|
|
@ -1150,6 +1331,63 @@ export function FlowWidget({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 🆕 그룹 설정 다이얼로그 */}
|
||||||
|
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 컬럼 목록 */}
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<div key={col} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${col}`}
|
||||||
|
checked={groupByColumns.includes(col)}
|
||||||
|
onCheckedChange={() => toggleGroupColumn(col)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`group-${col}`} className="flex-1 cursor-pointer text-xs font-normal sm:text-sm">
|
||||||
|
{columnLabels[col] || col}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 그룹 안내 */}
|
||||||
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||||
|
{groupByColumns.length === 0 ? (
|
||||||
|
<span>그룹화할 컬럼을 선택하세요</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
선택된 그룹:{" "}
|
||||||
|
<span className="text-primary font-semibold">
|
||||||
|
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsGroupSettingOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -31,7 +31,11 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
|
className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
|
||||||
style={style}
|
style={{
|
||||||
|
...style,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
title={config?.tooltip || placeholder}
|
title={config?.tooltip || placeholder}
|
||||||
>
|
>
|
||||||
{config?.label || config?.text || value || placeholder || "버튼"}
|
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,12 @@ export const MOCK_COMPANIES: Company[] = [
|
||||||
// 새 회사 등록 시 기본값
|
// 새 회사 등록 시 기본값
|
||||||
export const DEFAULT_COMPANY_FORM_DATA = {
|
export const DEFAULT_COMPANY_FORM_DATA = {
|
||||||
company_name: "",
|
company_name: "",
|
||||||
|
business_registration_number: "",
|
||||||
|
representative_name: "",
|
||||||
|
representative_phone: "",
|
||||||
|
email: "",
|
||||||
|
website: "",
|
||||||
|
address: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 페이징 관련 상수
|
// 페이징 관련 상수
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,12 @@ export const useCompanyManagement = () => {
|
||||||
selectedCompany: company,
|
selectedCompany: company,
|
||||||
formData: {
|
formData: {
|
||||||
company_name: company.company_name,
|
company_name: company.company_name,
|
||||||
|
business_registration_number: company.business_registration_number || "",
|
||||||
|
representative_name: company.representative_name || "",
|
||||||
|
representative_phone: company.representative_phone || "",
|
||||||
|
email: company.email || "",
|
||||||
|
website: company.website || "",
|
||||||
|
address: company.address || "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -175,6 +181,10 @@ export const useCompanyManagement = () => {
|
||||||
setError("회사명을 입력해주세요.");
|
setError("회사명을 입력해주세요.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!modalState.formData.business_registration_number.trim()) {
|
||||||
|
setError("사업자등록번호를 입력해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -199,6 +209,10 @@ export const useCompanyManagement = () => {
|
||||||
setError("올바른 데이터를 입력해주세요.");
|
setError("올바른 데이터를 입력해주세요.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!modalState.formData.business_registration_number.trim()) {
|
||||||
|
setError("사업자등록번호를 입력해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -206,6 +220,12 @@ export const useCompanyManagement = () => {
|
||||||
try {
|
try {
|
||||||
await companyAPI.update(modalState.selectedCompany.company_code, {
|
await companyAPI.update(modalState.selectedCompany.company_code, {
|
||||||
company_name: modalState.formData.company_name,
|
company_name: modalState.formData.company_name,
|
||||||
|
business_registration_number: modalState.formData.business_registration_number,
|
||||||
|
representative_name: modalState.formData.representative_name,
|
||||||
|
representative_phone: modalState.formData.representative_phone,
|
||||||
|
email: modalState.formData.email,
|
||||||
|
website: modalState.formData.website,
|
||||||
|
address: modalState.formData.address,
|
||||||
status: modalState.selectedCompany.status,
|
status: modalState.selectedCompany.status,
|
||||||
});
|
});
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
/**
|
||||||
|
* 부서 관리 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import { Department, DepartmentMember, DepartmentFormData } from "@/types/department";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 목록 조회 (회사별)
|
||||||
|
*/
|
||||||
|
export async function getDepartments(companyCode: string) {
|
||||||
|
try {
|
||||||
|
const url = `/departments/companies/${companyCode}/departments`;
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서 목록 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getDepartment(deptCode: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서 상세 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 생성
|
||||||
|
*/
|
||||||
|
export async function createDepartment(companyCode: string, data: DepartmentFormData) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ success: boolean; data: Department }>(
|
||||||
|
`/departments/companies/${companyCode}/departments`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서 생성 실패:", error);
|
||||||
|
const isDuplicate = error.response?.status === 409;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
isDuplicate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 수정
|
||||||
|
*/
|
||||||
|
export async function updateDepartment(deptCode: string, data: DepartmentFormData) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서 수정 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteDepartment(deptCode: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서 삭제 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서원 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getDepartmentMembers(deptCode: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>(
|
||||||
|
`/departments/${deptCode}/members`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서원 목록 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 검색 (부서원 추가용)
|
||||||
|
*/
|
||||||
|
export async function searchUsers(companyCode: string, search: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: any[] }>(
|
||||||
|
`/departments/companies/${companyCode}/users/search`,
|
||||||
|
{ params: { search } },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("사용자 검색 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서원 추가
|
||||||
|
*/
|
||||||
|
export async function addDepartmentMember(deptCode: string, userId: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, {
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서원 추가 실패:", error);
|
||||||
|
const isDuplicate = error.response?.status === 409;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
isDuplicate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서원 제거
|
||||||
|
*/
|
||||||
|
export async function removeDepartmentMember(deptCode: string, userId: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("부서원 제거 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주 부서 설정
|
||||||
|
*/
|
||||||
|
export async function setPrimaryDepartment(deptCode: string, userId: string) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("주 부서 설정 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -410,6 +410,128 @@ export class DynamicFormApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 데이터 조회 (페이징 + 검색)
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param params 검색 파라미터
|
||||||
|
* @returns 테이블 데이터
|
||||||
|
*/
|
||||||
|
static async getTableData(
|
||||||
|
tableName: string,
|
||||||
|
params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
},
|
||||||
|
): Promise<ApiResponse<any[]>> {
|
||||||
|
try {
|
||||||
|
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {});
|
||||||
|
|
||||||
|
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
|
||||||
|
console.log("🔍 response.data 상세:", {
|
||||||
|
type: typeof response.data,
|
||||||
|
isArray: Array.isArray(response.data),
|
||||||
|
keys: response.data ? Object.keys(response.data) : [],
|
||||||
|
hasData: response.data?.data !== undefined,
|
||||||
|
dataType: response.data?.data ? typeof response.data.data : "N/A",
|
||||||
|
dataIsArray: response.data?.data ? Array.isArray(response.data.data) : false,
|
||||||
|
dataLength: response.data?.data ? (Array.isArray(response.data.data) ? response.data.data.length : "not array") : "no data",
|
||||||
|
// 중첩 구조 확인
|
||||||
|
dataDataExists: response.data?.data?.data !== undefined,
|
||||||
|
dataDataIsArray: response.data?.data?.data ? Array.isArray(response.data.data.data) : false,
|
||||||
|
dataDataLength: response.data?.data?.data ? (Array.isArray(response.data.data.data) ? response.data.data.data.length : "not array") : "no nested data",
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 응답 구조: { data: [...], total, page, size, totalPages }
|
||||||
|
// 또는 중첩: { success: true, data: { data: [...], total, ... } }
|
||||||
|
// data 배열만 추출
|
||||||
|
let tableData: any[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
// 케이스 1: 응답이 배열이면 그대로 사용
|
||||||
|
console.log("✅ 케이스 1: 응답이 배열");
|
||||||
|
tableData = response.data;
|
||||||
|
} else if (response.data && Array.isArray(response.data.data)) {
|
||||||
|
// 케이스 2: 응답이 { data: [...] } 구조면 data 배열 추출
|
||||||
|
console.log("✅ 케이스 2: 응답이 { data: [...] } 구조");
|
||||||
|
tableData = response.data.data;
|
||||||
|
} else if (response.data?.data?.data && Array.isArray(response.data.data.data)) {
|
||||||
|
// 케이스 2-1: 중첩 구조 { success: true, data: { data: [...] } }
|
||||||
|
console.log("✅ 케이스 2-1: 중첩 구조 { data: { data: [...] } }");
|
||||||
|
tableData = response.data.data.data;
|
||||||
|
} else if (response.data && typeof response.data === "object") {
|
||||||
|
// 케이스 3: 응답이 객체면 배열로 감싸기 (최후의 수단)
|
||||||
|
console.log("⚠️ 케이스 3: 응답이 객체 (배열로 감싸기)");
|
||||||
|
tableData = [response.data];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 테이블 데이터 추출 완료:", {
|
||||||
|
originalType: typeof response.data,
|
||||||
|
isArray: Array.isArray(response.data),
|
||||||
|
hasDataProperty: response.data?.data !== undefined,
|
||||||
|
extractedCount: tableData.length,
|
||||||
|
firstRow: tableData[0],
|
||||||
|
allRows: tableData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tableData,
|
||||||
|
message: "테이블 데이터 조회가 완료되었습니다.",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||||
|
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || "테이블 데이터 조회 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: errorMessage,
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 (대량 데이터 삽입/업데이트)
|
||||||
|
* @param payload 업로드 데이터
|
||||||
|
* @returns 업로드 결과
|
||||||
|
*/
|
||||||
|
static async uploadExcelData(payload: {
|
||||||
|
tableName: string;
|
||||||
|
data: any[];
|
||||||
|
uploadMode: "insert" | "update" | "upsert";
|
||||||
|
keyColumn?: string;
|
||||||
|
}): Promise<ApiResponse<any>> {
|
||||||
|
try {
|
||||||
|
console.log("📤 엑셀 업로드 요청:", payload);
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/dynamic-form/excel-upload`, payload);
|
||||||
|
|
||||||
|
console.log("✅ 엑셀 업로드 성공:", response.data);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
message: "엑셀 파일이 성공적으로 업로드되었습니다.",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 엑셀 업로드 실패:", error);
|
||||||
|
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || "엑셀 업로드 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: errorMessage,
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편의를 위한 기본 export
|
// 편의를 위한 기본 export
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* 채번 규칙 관리 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/numbering-rules");
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "규칙 목록 조회 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/numbering-rules/${ruleId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "규칙 조회 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNumberingRule(
|
||||||
|
config: NumberingRuleConfig
|
||||||
|
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/numbering-rules", config);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "규칙 생성 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNumberingRule(
|
||||||
|
ruleId: string,
|
||||||
|
config: Partial<NumberingRuleConfig>
|
||||||
|
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/numbering-rules/${ruleId}`, config);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "규칙 수정 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<void>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/numbering-rules/${ruleId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "규칙 삭제 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCode(ruleId: string): Promise<ApiResponse<{ code: string }>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "코드 생성 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetSequence(ruleId: string): Promise<ApiResponse<void>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message || "시퀀스 초기화 실패" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -242,6 +242,12 @@ export const tableTypeApi = {
|
||||||
return data.columns || data || [];
|
return data.columns || data || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 컬럼 입력 타입 정보 조회
|
||||||
|
getColumnInputTypes: async (tableName: string): Promise<any[]> => {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
// 컬럼 웹 타입 설정
|
// 컬럼 웹 타입 설정
|
||||||
setColumnWebType: async (
|
setColumnWebType: async (
|
||||||
tableName: string,
|
tableName: string,
|
||||||
|
|
@ -307,6 +313,21 @@ export const tableTypeApi = {
|
||||||
deleteTableData: async (tableName: string, data: Record<string, any>[] | { ids: string[] }): Promise<void> => {
|
deleteTableData: async (tableName: string, data: Record<string, any>[] | { ids: string[] }): Promise<void> => {
|
||||||
await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data });
|
await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 🆕 단일 레코드 조회 (자동 입력용)
|
||||||
|
getTableRecord: async (
|
||||||
|
tableName: string,
|
||||||
|
filterColumn: string,
|
||||||
|
filterValue: any,
|
||||||
|
displayColumn: string,
|
||||||
|
): Promise<{ value: any; record: Record<string, any> }> => {
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${tableName}/record`, {
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 메뉴-화면 할당 관련 API
|
// 메뉴-화면 할당 관련 API
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,16 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
|
|
||||||
// 디버깅: 전달받은 웹타입과 props 정보 로깅
|
// 디버깅: 전달받은 웹타입과 props 정보 로깅
|
||||||
console.log("🔍 DynamicWebTypeRenderer 호출:", {
|
if (webType === "button") {
|
||||||
webType,
|
console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
|
||||||
propsKeys: Object.keys(props),
|
webType,
|
||||||
component: props.component,
|
component: props.component,
|
||||||
isFileComponent: props.component?.type === "file" || webType === "file",
|
position: props.component?.position,
|
||||||
});
|
size: props.component?.size,
|
||||||
|
style: props.component?.style,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const webTypeDefinition = useMemo(() => {
|
const webTypeDefinition = useMemo(() => {
|
||||||
return WebTypeRegistry.getWebType(webType);
|
return WebTypeRegistry.getWebType(webType);
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||||
|
|
@ -247,8 +249,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 추가 안전장치: 모든 로딩 토스트 제거
|
// 추가 안전장치: 모든 로딩 토스트 제거
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
|
|
||||||
// UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시
|
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
||||||
const silentActions = ["edit", "modal", "navigate"];
|
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||||
if (!silentActions.includes(actionConfig.type)) {
|
if (!silentActions.includes(actionConfig.type)) {
|
||||||
currentLoadingToastRef.current = toast.loading(
|
currentLoadingToastRef.current = toast.loading(
|
||||||
actionConfig.type === "save"
|
actionConfig.type === "save"
|
||||||
|
|
@ -274,9 +276,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
// 실패한 경우 오류 처리
|
// 실패한 경우 오류 처리
|
||||||
if (!success) {
|
if (!success) {
|
||||||
// UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리
|
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
|
||||||
const silentActions = ["edit", "modal", "navigate"];
|
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||||
if (silentActions.includes(actionConfig.type)) {
|
if (silentErrorActions.includes(actionConfig.type)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 기본 에러 메시지 결정
|
// 기본 에러 메시지 결정
|
||||||
|
|
@ -302,8 +304,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성공한 경우에만 성공 토스트 표시
|
// 성공한 경우에만 성공 토스트 표시
|
||||||
// edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요)
|
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리
|
||||||
if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") {
|
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시)
|
||||||
|
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||||
|
if (!silentSuccessActions.includes(actionConfig.type)) {
|
||||||
// 기본 성공 메시지 결정
|
// 기본 성공 메시지 결정
|
||||||
const defaultSuccessMessage =
|
const defaultSuccessMessage =
|
||||||
actionConfig.type === "save"
|
actionConfig.type === "save"
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
position: "relative",
|
position: "relative",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
// 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정
|
||||||
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
componentStyle.border = "1px dashed hsl(var(--border))";
|
componentStyle.border = "1px dashed hsl(var(--border))";
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||||
import "./map/MapRenderer";
|
import "./map/MapRenderer";
|
||||||
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||||
import "./flow-widget/FlowWidgetRenderer";
|
import "./flow-widget/FlowWidgetRenderer";
|
||||||
|
import "./numbering-rule/NumberingRuleRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||||
|
import { NumberingRuleComponentConfig } from "./types";
|
||||||
|
|
||||||
|
interface NumberingRuleWrapperProps {
|
||||||
|
config: NumberingRuleComponentConfig;
|
||||||
|
onChange?: (config: NumberingRuleComponentConfig) => void;
|
||||||
|
isPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
isPreview = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<NumberingRuleDesigner
|
||||||
|
maxRules={config.maxRules || 6}
|
||||||
|
isPreview={isPreview}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NumberingRuleComponent = NumberingRuleWrapper;
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { NumberingRuleComponentConfig } from "./types";
|
||||||
|
|
||||||
|
interface NumberingRuleConfigPanelProps {
|
||||||
|
config: NumberingRuleComponentConfig;
|
||||||
|
onChange: (config: NumberingRuleComponentConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberingRuleConfigPanel: React.FC<NumberingRuleConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">최대 규칙 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={config.maxRules || 6}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...config, maxRules: parseInt(e.target.value) || 6 })
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
한 규칙에 추가할 수 있는 최대 파트 개수 (1-10)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm font-medium">읽기 전용 모드</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
편집 기능을 비활성화합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.readonly || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ ...config, readonly: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm font-medium">미리보기 표시</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
코드 미리보기를 항상 표시합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showPreview !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ ...config, showPreview: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm font-medium">규칙 목록 표시</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
저장된 규칙 목록을 표시합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showRuleList !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ ...config, showRuleList: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">카드 레이아웃</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cardLayout || "vertical"}
|
||||||
|
onValueChange={(value: "vertical" | "horizontal") =>
|
||||||
|
onChange({ ...config, cardLayout: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vertical">세로</SelectItem>
|
||||||
|
<SelectItem value="horizontal">가로</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
규칙 파트 카드의 배치 방향
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { NumberingRuleDefinition } from "./index";
|
||||||
|
import { NumberingRuleComponent } from "./NumberingRuleComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = NumberingRuleDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <NumberingRuleComponent {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙 컴포넌트 특화 메서드
|
||||||
|
*/
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
NumberingRuleRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
NumberingRuleRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# 코드 채번 규칙 컴포넌트
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집
|
||||||
|
- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합
|
||||||
|
- **실시간 미리보기**: 설정 즉시 생성될 코드 확인
|
||||||
|
- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀
|
||||||
|
|
||||||
|
## 생성 코드 예시
|
||||||
|
|
||||||
|
- 제품 코드: `PROD-20251104-0001`
|
||||||
|
- 프로젝트 코드: `PRJ-2025-001`
|
||||||
|
- 거래처 코드: `CUST-A-0001`
|
||||||
|
|
||||||
|
## 파트 유형
|
||||||
|
|
||||||
|
### 1. 접두사 (prefix)
|
||||||
|
고정된 문자열을 코드 앞에 추가합니다.
|
||||||
|
- 예: `PROD`, `PRJ`, `CUST`
|
||||||
|
|
||||||
|
### 2. 순번 (sequence)
|
||||||
|
자동으로 증가하는 번호를 생성합니다.
|
||||||
|
- 자릿수 설정 가능 (1-10)
|
||||||
|
- 시작 번호 설정 가능
|
||||||
|
- 예: `0001`, `00001`
|
||||||
|
|
||||||
|
### 3. 날짜 (date)
|
||||||
|
현재 날짜를 다양한 형식으로 추가합니다.
|
||||||
|
- YYYY: 2025
|
||||||
|
- YYYYMMDD: 20251104
|
||||||
|
- YYMMDD: 251104
|
||||||
|
|
||||||
|
### 4. 연도 (year)
|
||||||
|
현재 연도를 추가합니다.
|
||||||
|
- YYYY: 2025
|
||||||
|
- YY: 25
|
||||||
|
|
||||||
|
### 5. 월 (month)
|
||||||
|
현재 월을 2자리로 추가합니다.
|
||||||
|
- 예: 01, 02, ..., 12
|
||||||
|
|
||||||
|
### 6. 사용자 정의 (custom)
|
||||||
|
원하는 값을 직접 입력합니다.
|
||||||
|
|
||||||
|
## 생성 방식
|
||||||
|
|
||||||
|
### 자동 생성 (auto)
|
||||||
|
시스템이 자동으로 값을 생성합니다.
|
||||||
|
|
||||||
|
### 직접 입력 (manual)
|
||||||
|
사용자가 값을 직접 입력합니다.
|
||||||
|
|
||||||
|
## 설정 옵션
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `maxRules` | number | 6 | 최대 파트 개수 |
|
||||||
|
| `readonly` | boolean | false | 읽기 전용 모드 |
|
||||||
|
| `showPreview` | boolean | true | 미리보기 표시 |
|
||||||
|
| `showRuleList` | boolean | true | 규칙 목록 표시 |
|
||||||
|
| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 |
|
||||||
|
|
||||||
|
## 사용 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<NumberingRuleDesigner
|
||||||
|
maxRules={6}
|
||||||
|
isPreview={false}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 데이터베이스 구조
|
||||||
|
|
||||||
|
### numbering_rules (마스터 테이블)
|
||||||
|
- 규칙 ID, 규칙명, 구분자
|
||||||
|
- 초기화 주기, 현재 시퀀스
|
||||||
|
- 적용 대상 테이블/컬럼
|
||||||
|
|
||||||
|
### numbering_rule_parts (파트 테이블)
|
||||||
|
- 파트 순서, 파트 유형
|
||||||
|
- 생성 방식, 설정 (JSONB)
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
|
||||||
|
- `GET /api/numbering-rules` - 규칙 목록 조회
|
||||||
|
- `POST /api/numbering-rules` - 규칙 생성
|
||||||
|
- `PUT /api/numbering-rules/:ruleId` - 규칙 수정
|
||||||
|
- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제
|
||||||
|
- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성
|
||||||
|
|
||||||
|
## 버전 정보
|
||||||
|
|
||||||
|
- **버전**: 1.0.0
|
||||||
|
- **작성일**: 2025-11-04
|
||||||
|
- **작성자**: 개발팀
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* 채번 규칙 컴포넌트 기본 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NumberingRuleComponentConfig } from "./types";
|
||||||
|
|
||||||
|
export const defaultConfig: NumberingRuleComponentConfig = {
|
||||||
|
maxRules: 6,
|
||||||
|
readonly: false,
|
||||||
|
showPreview: true,
|
||||||
|
showRuleList: true,
|
||||||
|
enableReorder: false,
|
||||||
|
cardLayout: "vertical",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { NumberingRuleWrapper } from "./NumberingRuleComponent";
|
||||||
|
import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙 컴포넌트 정의
|
||||||
|
* 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트
|
||||||
|
*/
|
||||||
|
export const NumberingRuleDefinition = createComponentDefinition({
|
||||||
|
id: "numbering-rule",
|
||||||
|
name: "코드 채번 규칙",
|
||||||
|
nameEng: "Numbering Rule Component",
|
||||||
|
description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트",
|
||||||
|
category: ComponentCategory.DISPLAY,
|
||||||
|
webType: "component",
|
||||||
|
component: NumberingRuleWrapper,
|
||||||
|
defaultConfig: defaultConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
gridColumnSpan: "12",
|
||||||
|
},
|
||||||
|
configPanel: NumberingRuleConfigPanel,
|
||||||
|
icon: "Hash",
|
||||||
|
tags: ["코드", "채번", "규칙", "표시", "자동생성"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type { NumberingRuleComponentConfig } from "./types";
|
||||||
|
|
||||||
|
// 컴포넌트 내보내기
|
||||||
|
export { NumberingRuleComponent } from "./NumberingRuleComponent";
|
||||||
|
export { NumberingRuleRenderer } from "./NumberingRuleRenderer";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* 채번 규칙 컴포넌트 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
|
|
||||||
|
export interface NumberingRuleComponentConfig {
|
||||||
|
ruleConfig?: NumberingRuleConfig;
|
||||||
|
maxRules?: number;
|
||||||
|
readonly?: boolean;
|
||||||
|
showPreview?: boolean;
|
||||||
|
showRuleList?: boolean;
|
||||||
|
enableReorder?: boolean;
|
||||||
|
cardLayout?: "vertical" | "horizontal";
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,12 @@ import {
|
||||||
TableIcon,
|
TableIcon,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
|
Layers,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -35,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
|
||||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||||
import { CardModeRenderer } from "./CardModeRenderer";
|
import { CardModeRenderer } from "./CardModeRenderer";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 인터페이스
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// 그룹화된 데이터 인터페이스
|
||||||
|
interface GroupedData {
|
||||||
|
groupKey: string;
|
||||||
|
groupValues: Record<string, any>;
|
||||||
|
items: any[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 캐시 및 유틸리티
|
// 캐시 및 유틸리티
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -219,6 +234,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: "hsl(var(--background))",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -244,12 +261,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||||
|
const hasInitializedWidths = useRef(false);
|
||||||
|
const isResizing = useRef(false);
|
||||||
|
|
||||||
// 필터 설정 관련 상태
|
// 필터 설정 관련 상태
|
||||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||||
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 그룹 설정 관련 상태
|
||||||
|
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false);
|
||||||
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
||||||
enableBatchLoading: true,
|
enableBatchLoading: true,
|
||||||
preloadCommonCodes: true,
|
preloadCommonCodes: true,
|
||||||
|
|
@ -260,7 +286,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 컬럼 라벨 가져오기
|
// 컬럼 라벨 가져오기
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
const fetchColumnLabels = async () => {
|
const fetchColumnLabels = useCallback(async () => {
|
||||||
if (!tableConfig.selectedTable) return;
|
if (!tableConfig.selectedTable) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -284,20 +310,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
||||||
|
|
||||||
|
// 컬럼 입력 타입 정보 가져오기
|
||||||
|
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
||||||
|
const inputTypeMap: Record<string, string> = {};
|
||||||
|
inputTypes.forEach((col: any) => {
|
||||||
|
inputTypeMap[col.columnName] = col.inputType;
|
||||||
|
});
|
||||||
|
|
||||||
tableColumnCache.set(cacheKey, {
|
tableColumnCache.set(cacheKey, {
|
||||||
columns,
|
columns,
|
||||||
|
inputTypes,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||||
|
|
||||||
columns.forEach((col: any) => {
|
columns.forEach((col: any) => {
|
||||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||||
meta[col.columnName] = {
|
meta[col.columnName] = {
|
||||||
webType: col.webType,
|
webType: col.webType,
|
||||||
codeCategory: col.codeCategory,
|
codeCategory: col.codeCategory,
|
||||||
|
inputType: inputTypeMap[col.columnName],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -306,13 +341,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼 라벨 가져오기 실패:", error);
|
console.error("컬럼 라벨 가져오기 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 테이블 라벨 가져오기
|
// 테이블 라벨 가져오기
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
const fetchTableLabel = async () => {
|
const fetchTableLabel = useCallback(async () => {
|
||||||
if (!tableConfig.selectedTable) return;
|
if (!tableConfig.selectedTable) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -341,7 +376,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 라벨 가져오기 실패:", error);
|
console.error("테이블 라벨 가져오기 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 데이터 가져오기
|
// 데이터 가져오기
|
||||||
|
|
@ -498,7 +533,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
||||||
}
|
}
|
||||||
if (onFormDataChange) {
|
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)));
|
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||||
|
|
@ -516,7 +554,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
||||||
}
|
}
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data });
|
onFormDataChange({
|
||||||
|
selectedRows: Array.from(newSelectedRows),
|
||||||
|
selectedRowsData: data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
|
|
@ -642,12 +683,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = columnMeta[column.columnName];
|
const meta = columnMeta[column.columnName];
|
||||||
if (meta?.webType && meta?.codeCategory) {
|
|
||||||
const convertedValue = optimizedConvertCode(value, meta.codeCategory);
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||||
if (convertedValue !== value) return convertedValue;
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
|
||||||
|
// 코드 타입: 코드 값 → 코드명 변환
|
||||||
|
if (inputType === "code" && meta?.codeCategory && value) {
|
||||||
|
try {
|
||||||
|
// optimizedConvertCode(categoryCode, codeValue) 순서 주의!
|
||||||
|
const convertedValue = optimizedConvertCode(meta.codeCategory, value);
|
||||||
|
// 변환에 성공했으면 변환된 코드명 반환
|
||||||
|
if (convertedValue && convertedValue !== value) {
|
||||||
|
return convertedValue;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
|
||||||
|
}
|
||||||
|
// 변환 실패 시 원본 코드 값 반환
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 타입 포맷팅
|
||||||
|
if (inputType === "number" || inputType === "decimal") {
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (column.format) {
|
switch (column.format) {
|
||||||
|
case "number":
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
case "date":
|
case "date":
|
||||||
if (value) {
|
if (value) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -681,9 +756,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||||
}, [tableConfig.selectedTable]);
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
|
// 그룹 설정 localStorage 키 생성
|
||||||
|
const groupSettingKey = useMemo(() => {
|
||||||
|
if (!tableConfig.selectedTable) return null;
|
||||||
|
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||||
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
// 저장된 필터 설정 불러오기
|
// 저장된 필터 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterSettingKey) return;
|
if (!filterSettingKey || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(filterSettingKey);
|
const saved = localStorage.getItem(filterSettingKey);
|
||||||
|
|
@ -691,17 +772,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const savedFilters = JSON.parse(saved);
|
const savedFilters = JSON.parse(saved);
|
||||||
setVisibleFilterColumns(new Set(savedFilters));
|
setVisibleFilterColumns(new Set(savedFilters));
|
||||||
} else {
|
} else {
|
||||||
// 초기값: 모든 필터 표시
|
// 초기값: 빈 Set (아무것도 선택 안 함)
|
||||||
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
|
setVisibleFilterColumns(new Set());
|
||||||
setVisibleFilterColumns(new Set(allFilters));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("필터 설정 불러오기 실패:", error);
|
console.error("필터 설정 불러오기 실패:", error);
|
||||||
// 기본값으로 모든 필터 표시
|
setVisibleFilterColumns(new Set());
|
||||||
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
|
|
||||||
setVisibleFilterColumns(new Set(allFilters));
|
|
||||||
}
|
}
|
||||||
}, [filterSettingKey, tableConfig.filter?.filters]);
|
}, [filterSettingKey, visibleColumns]);
|
||||||
|
|
||||||
// 필터 설정 저장
|
// 필터 설정 저장
|
||||||
const saveFilterSettings = useCallback(() => {
|
const saveFilterSettings = useCallback(() => {
|
||||||
|
|
@ -710,12 +788,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
|
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
|
||||||
setIsFilterSettingOpen(false);
|
setIsFilterSettingOpen(false);
|
||||||
|
toast.success("검색 필터 설정이 저장되었습니다");
|
||||||
|
|
||||||
|
// 검색 값 초기화
|
||||||
|
setSearchValues({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("필터 설정 저장 실패:", error);
|
console.error("필터 설정 저장 실패:", error);
|
||||||
|
toast.error("설정 저장에 실패했습니다");
|
||||||
}
|
}
|
||||||
}, [filterSettingKey, visibleFilterColumns]);
|
}, [filterSettingKey, visibleFilterColumns]);
|
||||||
|
|
||||||
// 필터 토글
|
// 필터 컬럼 토글
|
||||||
const toggleFilterVisibility = useCallback((columnName: string) => {
|
const toggleFilterVisibility = useCallback((columnName: string) => {
|
||||||
setVisibleFilterColumns((prev) => {
|
setVisibleFilterColumns((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
|
|
@ -728,15 +811,134 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 표시할 필터 목록
|
// 전체 선택/해제
|
||||||
|
const toggleAllFilters = useCallback(() => {
|
||||||
|
const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
||||||
|
const columnNames = filterableColumns.map((col) => col.columnName);
|
||||||
|
|
||||||
|
if (visibleFilterColumns.size === columnNames.length) {
|
||||||
|
// 전체 해제
|
||||||
|
setVisibleFilterColumns(new Set());
|
||||||
|
} else {
|
||||||
|
// 전체 선택
|
||||||
|
setVisibleFilterColumns(new Set(columnNames));
|
||||||
|
}
|
||||||
|
}, [visibleFilterColumns, visibleColumns]);
|
||||||
|
|
||||||
|
// 표시할 필터 목록 (선택된 컬럼만)
|
||||||
const activeFilters = useMemo(() => {
|
const activeFilters = useMemo(() => {
|
||||||
return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName));
|
return visibleColumns
|
||||||
}, [tableConfig.filter?.filters, visibleFilterColumns]);
|
.filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName))
|
||||||
|
.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||||
|
type: col.format || "text",
|
||||||
|
}));
|
||||||
|
}, [visibleColumns, visibleFilterColumns, columnLabels]);
|
||||||
|
|
||||||
|
// 그룹 설정 저장
|
||||||
|
const saveGroupSettings = useCallback(() => {
|
||||||
|
if (!groupSettingKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||||
|
setIsGroupSettingOpen(false);
|
||||||
|
toast.success("그룹 설정이 저장되었습니다");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 저장 실패:", error);
|
||||||
|
toast.error("설정 저장에 실패했습니다");
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, groupByColumns]);
|
||||||
|
|
||||||
|
// 그룹 컬럼 토글
|
||||||
|
const toggleGroupColumn = useCallback((columnName: string) => {
|
||||||
|
setGroupByColumns((prev) => {
|
||||||
|
if (prev.includes(columnName)) {
|
||||||
|
return prev.filter((col) => col !== columnName);
|
||||||
|
} else {
|
||||||
|
return [...prev, columnName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 그룹 펼치기/접기 토글
|
||||||
|
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(groupKey)) {
|
||||||
|
newSet.delete(groupKey);
|
||||||
|
} else {
|
||||||
|
newSet.add(groupKey);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 그룹 해제
|
||||||
|
const clearGrouping = useCallback(() => {
|
||||||
|
setGroupByColumns([]);
|
||||||
|
setCollapsedGroups(new Set());
|
||||||
|
if (groupSettingKey) {
|
||||||
|
localStorage.removeItem(groupSettingKey);
|
||||||
|
}
|
||||||
|
toast.success("그룹이 해제되었습니다");
|
||||||
|
}, [groupSettingKey]);
|
||||||
|
|
||||||
|
// 데이터 그룹화
|
||||||
|
const groupedData = useMemo((): GroupedData[] => {
|
||||||
|
if (groupByColumns.length === 0 || data.length === 0) return [];
|
||||||
|
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||||
|
const keyParts = groupByColumns.map((col) => {
|
||||||
|
const value = item[col];
|
||||||
|
const label = columnLabels[col] || col;
|
||||||
|
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
|
||||||
|
});
|
||||||
|
const groupKey = keyParts.join(" > ");
|
||||||
|
|
||||||
|
if (!grouped.has(groupKey)) {
|
||||||
|
grouped.set(groupKey, []);
|
||||||
|
}
|
||||||
|
grouped.get(groupKey)!.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||||
|
const groupValues: Record<string, any> = {};
|
||||||
|
groupByColumns.forEach((col) => {
|
||||||
|
groupValues[col] = items[0]?.[col];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
groupValues,
|
||||||
|
items,
|
||||||
|
count: items.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [data, groupByColumns, columnLabels]);
|
||||||
|
|
||||||
|
// 저장된 그룹 설정 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupSettingKey || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(groupSettingKey);
|
||||||
|
if (saved) {
|
||||||
|
const savedGroups = JSON.parse(saved);
|
||||||
|
setGroupByColumns(savedGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, visibleColumns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchColumnLabels();
|
fetchColumnLabels();
|
||||||
fetchTableLabel();
|
fetchTableLabel();
|
||||||
}, [tableConfig.selectedTable]);
|
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesignMode && tableConfig.selectedTable) {
|
if (!isDesignMode && tableConfig.selectedTable) {
|
||||||
|
|
@ -751,6 +953,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
|
fetchTableDataDebounced,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -763,6 +966,38 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [tableConfig.refreshInterval, isDesignMode]);
|
}, [tableConfig.refreshInterval, isDesignMode]);
|
||||||
|
|
||||||
|
// 초기 컬럼 너비 측정 (한 번만)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
|
||||||
|
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
let hasAnyWidth = false;
|
||||||
|
|
||||||
|
visibleColumns.forEach((column) => {
|
||||||
|
// 체크박스 컬럼은 제외 (고정 48px)
|
||||||
|
if (column.columnName === "__checkbox__") return;
|
||||||
|
|
||||||
|
const thElement = columnRefs.current[column.columnName];
|
||||||
|
if (thElement) {
|
||||||
|
const measuredWidth = thElement.offsetWidth;
|
||||||
|
if (measuredWidth > 0) {
|
||||||
|
newWidths[column.columnName] = measuredWidth;
|
||||||
|
hasAnyWidth = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasAnyWidth) {
|
||||||
|
setColumnWidths(newWidths);
|
||||||
|
hasInitializedWidths.current = true;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 페이지네이션 JSX
|
// 페이지네이션 JSX
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -872,14 +1107,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (tableConfig.stickyHeader && !isDesignMode) {
|
if (tableConfig.stickyHeader && !isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div {...domProps}>
|
<div {...domProps}>
|
||||||
{tableConfig.showHeader && (
|
|
||||||
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
|
||||||
<h2 className="text-base font-semibold text-foreground sm:text-lg">
|
|
||||||
{tableConfig.title || tableLabel || finalSelectedTable}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tableConfig.filter?.enabled && (
|
{tableConfig.filter?.enabled && (
|
||||||
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||||
|
|
@ -892,20 +1119,57 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onClear={handleClearAdvancedFilters}
|
onClear={handleClearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
size="sm"
|
||||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
>
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
>
|
||||||
필터 설정
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
필터 설정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
{/* 그룹 표시 배지 */}
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{groupByColumns.map((col, idx) => (
|
||||||
|
<span key={col} className="flex items-center">
|
||||||
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||||
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||||
|
{columnLabels[col] || col}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearGrouping}
|
||||||
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||||
|
title="그룹 해제"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10" style={{ flex: 1, overflow: "hidden" }}>
|
||||||
<SingleTableWithSticky
|
<SingleTableWithSticky
|
||||||
data={data}
|
data={data}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
|
|
@ -935,15 +1199,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div {...domProps}>
|
<div {...domProps}>
|
||||||
{/* 헤더 */}
|
|
||||||
{tableConfig.showHeader && (
|
|
||||||
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
|
||||||
<h2 className="text-base font-semibold text-foreground sm:text-lg">
|
|
||||||
{tableConfig.title || tableLabel || finalSelectedTable}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 필터 */}
|
{/* 필터 */}
|
||||||
{tableConfig.filter?.enabled && (
|
{tableConfig.filter?.enabled && (
|
||||||
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
||||||
|
|
@ -957,21 +1212,58 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onClear={handleClearAdvancedFilters}
|
onClear={handleClearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
size="sm"
|
||||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
필터 설정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 표시 배지 */}
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{groupByColumns.map((col, idx) => (
|
||||||
|
<span key={col} className="flex items-center">
|
||||||
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||||
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||||
|
{columnLabels[col] || col}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearGrouping}
|
||||||
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||||
|
title="그룹 해제"
|
||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
필터 설정
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 테이블 컨테이너 */}
|
{/* 테이블 컨테이너 */}
|
||||||
<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
|
<div
|
||||||
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
||||||
|
|
@ -982,38 +1274,110 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
style={{
|
style={{
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
tableLayout: "fixed",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 헤더 (sticky) */}
|
{/* 헤더 (sticky) */}
|
||||||
<thead
|
<thead
|
||||||
className="sticky top-0 z-10 bg-background"
|
className="sticky top-0 z-10"
|
||||||
>
|
>
|
||||||
<tr className="h-10 border-b border-border sm:h-12">
|
<tr className="h-10 border-b-2 border-primary/20 bg-gradient-to-b from-muted/50 to-muted sm:h-12">
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column, columnIndex) => {
|
||||||
<th
|
const columnWidth = columnWidths[column.columnName];
|
||||||
key={column.columnName}
|
|
||||||
className={cn(
|
return (
|
||||||
"h-10 px-2 py-2 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background sm:h-12 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap",
|
<th
|
||||||
column.sortable && "cursor-pointer"
|
key={column.columnName}
|
||||||
)}
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
||||||
style={{
|
className={cn(
|
||||||
textAlign: column.align || "left",
|
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
|
||||||
width: `${100 / visibleColumns.length}%`, // 컬럼 수에 따라 균등 분배
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
|
||||||
}}
|
column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors"
|
||||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
)}
|
||||||
>
|
style={{
|
||||||
{column.columnName === "__checkbox__" ? (
|
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
||||||
renderCheckboxHeader()
|
width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
|
||||||
) : (
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
userSelect: 'none'
|
||||||
{column.sortable && sortColumn === column.columnName && (
|
}}
|
||||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
onClick={() => {
|
||||||
)}
|
if (isResizing.current) return;
|
||||||
</div>
|
if (column.sortable) handleSort(column.columnName);
|
||||||
)}
|
}}
|
||||||
</th>
|
>
|
||||||
))}
|
{column.columnName === "__checkbox__" ? (
|
||||||
|
renderCheckboxHeader()
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||||
|
{column.sortable && sortColumn === column.columnName && (
|
||||||
|
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 리사이즈 핸들 (체크박스 제외) */}
|
||||||
|
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||||
|
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const thElement = columnRefs.current[column.columnName];
|
||||||
|
if (!thElement) return;
|
||||||
|
|
||||||
|
isResizing.current = true;
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = columnWidth || thElement.offsetWidth;
|
||||||
|
|
||||||
|
// 드래그 중 텍스트 선택 방지
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
|
||||||
|
const diff = moveEvent.clientX - startX;
|
||||||
|
const newWidth = Math.max(80, startWidth + diff);
|
||||||
|
|
||||||
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
||||||
|
if (thElement) {
|
||||||
|
thElement.style.width = `${newWidth}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
// 최종 너비를 state에 저장
|
||||||
|
if (thElement) {
|
||||||
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||||
|
setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 선택 복원
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
|
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
||||||
|
setTimeout(() => {
|
||||||
|
isResizing.current = false;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
|
@ -1049,7 +1413,81 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||||
|
// 그룹화된 렌더링
|
||||||
|
groupedData.map((group) => {
|
||||||
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={group.groupKey}>
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={visibleColumns.length}
|
||||||
|
className="bg-muted/50 border-b border-border sticky top-[48px] z-[5]"
|
||||||
|
style={{ top: "48px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted"
|
||||||
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* 그룹 데이터 */}
|
||||||
|
{!isCollapsed &&
|
||||||
|
group.items.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
draggable={!isDesignMode}
|
||||||
|
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||||
|
onDragEnd={handleRowDragEnd}
|
||||||
|
className={cn(
|
||||||
|
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
|
||||||
|
)}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
|
{visibleColumns.map((column) => {
|
||||||
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||||
|
const cellValue = row[mappedColumnName];
|
||||||
|
|
||||||
|
const meta = columnMeta[column.columnName];
|
||||||
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.columnName}
|
||||||
|
className={cn(
|
||||||
|
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||||
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||||
|
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||||
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.columnName === "__checkbox__"
|
||||||
|
? renderCheckboxCell(row, index)
|
||||||
|
: formatCellValue(cellValue, column, row)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
|
// 일반 렌더링 (그룹 없음)
|
||||||
data.map((row, index) => (
|
data.map((row, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -1065,15 +1503,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||||
const cellValue = row[mappedColumnName];
|
const cellValue = row[mappedColumnName];
|
||||||
|
|
||||||
|
const meta = columnMeta[column.columnName];
|
||||||
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-14 px-2 py-2 text-xs text-foreground overflow-hidden text-ellipsis sm:h-16 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap"
|
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||||
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign: column.align || "left",
|
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||||
width: `${100 / visibleColumns.length}%`, // 컬럼 수에 따라 균등 분배
|
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||||
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.columnName === "__checkbox__"
|
{column.columnName === "__checkbox__"
|
||||||
|
|
@ -1100,26 +1545,63 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다.
|
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{(tableConfig.filter?.filters || []).map((filter) => (
|
{/* 전체 선택/해제 */}
|
||||||
<div className="flex items-center gap-3 rounded p-2 hover:bg-muted">
|
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`filter-${filter.columnName}`}
|
id="select-all-filters"
|
||||||
checked={visibleFilterColumns.has(filter.columnName)}
|
checked={
|
||||||
onCheckedChange={() => toggleFilterVisibility(filter.columnName)}
|
visibleFilterColumns.size ===
|
||||||
/>
|
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length &&
|
||||||
<Label
|
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0
|
||||||
htmlFor={`filter-${filter.columnName}`}
|
}
|
||||||
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
onCheckedChange={toggleAllFilters}
|
||||||
>
|
/>
|
||||||
{columnLabels[filter.columnName] || filter.label || filter.columnName}
|
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
|
||||||
</Label>
|
전체 선택/해제
|
||||||
</div>
|
</Label>
|
||||||
))}
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length}
|
||||||
|
개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 목록 */}
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||||
|
{visibleColumns
|
||||||
|
.filter((col) => col.columnName !== "__checkbox__")
|
||||||
|
.map((col) => (
|
||||||
|
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`filter-${col.columnName}`}
|
||||||
|
checked={visibleFilterColumns.has(col.columnName)}
|
||||||
|
onCheckedChange={() => toggleFilterVisibility(col.columnName)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`filter-${col.columnName}`}
|
||||||
|
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||||
|
>
|
||||||
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 개수 안내 */}
|
||||||
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
|
||||||
|
{visibleFilterColumns.size === 0 ? (
|
||||||
|
<span>검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
총 <span className="text-primary font-semibold">{visibleFilterColumns.size}개</span>의 검색 필터가
|
||||||
|
표시됩니다
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|
@ -1136,6 +1618,68 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 그룹 설정 다이얼로그 */}
|
||||||
|
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 컬럼 목록 */}
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||||
|
{visibleColumns
|
||||||
|
.filter((col) => col.columnName !== "__checkbox__")
|
||||||
|
.map((col) => (
|
||||||
|
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${col.columnName}`}
|
||||||
|
checked={groupByColumns.includes(col.columnName)}
|
||||||
|
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`group-${col.columnName}`}
|
||||||
|
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||||
|
>
|
||||||
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 그룹 안내 */}
|
||||||
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||||
|
{groupByColumns.length === 0 ? (
|
||||||
|
<span>그룹화할 컬럼을 선택하세요</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
선택된 그룹:{" "}
|
||||||
|
<span className="text-primary font-semibold">
|
||||||
|
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsGroupSettingOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
|
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||||
...(isHidden &&
|
...(isHidden &&
|
||||||
isDesignMode && {
|
isDesignMode && {
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue