행 이동 화면 할당한 상황에서도 가능하게, 코드병합 버튼액션에 추가
This commit is contained in:
parent
66b735e864
commit
82ff18e388
|
|
@ -64,8 +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 departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
|
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
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"; // 임시 주석
|
||||||
|
|
@ -224,8 +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/departments", departmentRoutes); // 부서 관리
|
||||||
|
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||||
// 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);
|
||||||
|
|
|
||||||
|
|
@ -3084,3 +3084,84 @@ export const resetUserPassword = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||||
|
*/
|
||||||
|
export async function getTableSchema(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||||
|
|
||||||
|
// information_schema에서 컬럼 정보 가져오기
|
||||||
|
const schemaQuery = `
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable,
|
||||||
|
column_default,
|
||||||
|
character_maximum_length,
|
||||||
|
numeric_precision,
|
||||||
|
numeric_scale
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columns = await query<any>(schemaQuery, [tableName]);
|
||||||
|
|
||||||
|
if (columns.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `테이블 '${tableName}'을 찾을 수 없습니다.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 정보를 간단한 형태로 변환
|
||||||
|
const columnList = columns.map((col: any) => ({
|
||||||
|
name: col.column_name,
|
||||||
|
type: col.data_type,
|
||||||
|
nullable: col.is_nullable === "YES",
|
||||||
|
default: col.column_default,
|
||||||
|
maxLength: col.character_maximum_length,
|
||||||
|
precision: col.numeric_precision,
|
||||||
|
scale: col.numeric_scale,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "테이블 스키마 조회 성공",
|
||||||
|
data: {
|
||||||
|
tableName,
|
||||||
|
columns: columnList,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("테이블 스키마 조회 중 오류 발생:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "TABLE_SCHEMA_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import pool from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
companyCode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 병합 - 모든 관련 테이블에 적용
|
||||||
|
* 데이터(레코드)는 삭제하지 않고, 컬럼 값만 변경
|
||||||
|
*/
|
||||||
|
export async function mergeCodeAllTables(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { columnName, oldValue, newValue } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 입력값 검증
|
||||||
|
if (!columnName || !oldValue || !newValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (columnName, oldValue, newValue)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 값으로 병합 시도 방지
|
||||||
|
if (oldValue === newValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "기존 값과 새 값이 동일합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("코드 병합 시작", {
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PostgreSQL 함수 호출
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM merge_code_all_tables($1, $2, $3, $4)",
|
||||||
|
[columnName, oldValue, newValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과 처리 (pool.query 반환 타입 처리)
|
||||||
|
const affectedTables = Array.isArray(result) ? result : (result.rows || []);
|
||||||
|
const totalRows = affectedTables.reduce(
|
||||||
|
(sum, row) => sum + parseInt(row.rows_updated || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("코드 병합 완료", {
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
affectedTablesCount: affectedTables.length,
|
||||||
|
totalRowsUpdated: totalRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||||
|
data: {
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
affectedTables: affectedTables.map((row) => ({
|
||||||
|
tableName: row.table_name,
|
||||||
|
rowsUpdated: parseInt(row.rows_updated),
|
||||||
|
})),
|
||||||
|
totalRowsUpdated: totalRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("코드 병합 실패:", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 병합 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CODE_MERGE_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 컬럼을 가진 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getTablesWithColumn(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { columnName } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!columnName) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "컬럼명이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("컬럼을 가진 테이블 목록 조회", { columnName });
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT t.table_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
JOIN information_schema.tables t
|
||||||
|
ON c.table_name = t.table_name
|
||||||
|
WHERE c.column_name = $1
|
||||||
|
AND t.table_schema = 'public'
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns c2
|
||||||
|
WHERE c2.table_name = t.table_name
|
||||||
|
AND c2.column_name = 'company_code'
|
||||||
|
)
|
||||||
|
ORDER BY t.table_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [columnName]);
|
||||||
|
|
||||||
|
logger.info(`컬럼을 가진 테이블 조회 완료: ${result.rows.length}개`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "테이블 목록 조회 성공",
|
||||||
|
data: {
|
||||||
|
columnName,
|
||||||
|
tables: result.rows.map((row) => row.table_name),
|
||||||
|
count: result.rows.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("테이블 목록 조회 실패:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "TABLE_LIST_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||||
|
*/
|
||||||
|
export async function previewCodeMerge(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { columnName, oldValue } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!columnName || !oldValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (columnName, oldValue)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("코드 병합 미리보기", { columnName, oldValue, companyCode });
|
||||||
|
|
||||||
|
// 해당 컬럼을 가진 테이블 찾기
|
||||||
|
const tablesQuery = `
|
||||||
|
SELECT DISTINCT t.table_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
JOIN information_schema.tables t
|
||||||
|
ON c.table_name = t.table_name
|
||||||
|
WHERE c.column_name = $1
|
||||||
|
AND t.table_schema = 'public'
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns c2
|
||||||
|
WHERE c2.table_name = t.table_name
|
||||||
|
AND c2.column_name = 'company_code'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tablesResult = await pool.query(tablesQuery, [columnName]);
|
||||||
|
|
||||||
|
// 각 테이블에서 영향받을 행 수 계산
|
||||||
|
const preview = [];
|
||||||
|
const tableRows = Array.isArray(tablesResult) ? tablesResult : (tablesResult.rows || []);
|
||||||
|
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const tableName = row.table_name;
|
||||||
|
|
||||||
|
// 동적 SQL 생성 (테이블명과 컬럼명은 파라미터 바인딩 불가)
|
||||||
|
// SQL 인젝션 방지: 테이블명과 컬럼명은 information_schema에서 검증된 값
|
||||||
|
const countQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${columnName}" = $1 AND company_code = $2`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const countResult = await pool.query(countQuery, [oldValue, companyCode]);
|
||||||
|
const count = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
preview.push({
|
||||||
|
tableName,
|
||||||
|
affectedRows: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn(`테이블 ${tableName} 조회 실패:`, error.message);
|
||||||
|
// 테이블 접근 실패 시 건너뛰기
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRows = preview.reduce((sum, item) => sum + item.affectedRows, 0);
|
||||||
|
|
||||||
|
logger.info("코드 병합 미리보기 완료", {
|
||||||
|
tablesCount: preview.length,
|
||||||
|
totalRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "코드 병합 미리보기 완료",
|
||||||
|
data: {
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
preview,
|
||||||
|
totalAffectedRows: totalRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("코드 병합 미리보기 실패:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PREVIEW_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
deleteCompany, // 회사 삭제
|
deleteCompany, // 회사 삭제
|
||||||
getUserLocale,
|
getUserLocale,
|
||||||
setUserLocale,
|
setUserLocale,
|
||||||
|
getTableSchema, // 테이블 스키마 조회
|
||||||
} from "../controllers/adminController";
|
} from "../controllers/adminController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -67,4 +68,7 @@ router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
|
||||||
router.get("/user-locale", getUserLocale);
|
router.get("/user-locale", getUserLocale);
|
||||||
router.post("/user-locale", setUserLocale);
|
router.post("/user-locale", setUserLocale);
|
||||||
|
|
||||||
|
// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용)
|
||||||
|
router.get("/tables/:tableName/schema", getTableSchema);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
mergeCodeAllTables,
|
||||||
|
getTablesWithColumn,
|
||||||
|
previewCodeMerge,
|
||||||
|
} from "../controllers/codeMergeController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/code-merge/merge-all-tables
|
||||||
|
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
||||||
|
* Body: { columnName, oldValue, newValue }
|
||||||
|
*/
|
||||||
|
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/code-merge/tables-with-column/:columnName
|
||||||
|
* 특정 컬럼을 가진 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/code-merge/preview
|
||||||
|
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||||
|
* Body: { columnName, oldValue }
|
||||||
|
*/
|
||||||
|
router.post("/preview", previewCodeMerge);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -40,6 +40,11 @@ export default function ScreenViewPage() {
|
||||||
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
||||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||||
|
const [tableSortBy, setTableSortBy] = useState<string | undefined>();
|
||||||
|
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
|
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>();
|
||||||
|
|
||||||
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
|
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
|
||||||
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
||||||
const [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
|
const [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
|
||||||
|
|
@ -425,9 +430,16 @@ export default function ScreenViewPage() {
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
onSelectedRowsChange={(_, selectedData) => {
|
sortBy={tableSortBy}
|
||||||
|
sortOrder={tableSortOrder}
|
||||||
|
columnOrder={tableColumnOrder}
|
||||||
|
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||||
|
console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder });
|
||||||
setSelectedRowsData(selectedData);
|
setSelectedRowsData(selectedData);
|
||||||
|
setTableSortBy(sortBy);
|
||||||
|
setTableSortOrder(sortOrder || "asc");
|
||||||
|
setTableColumnOrder(columnOrder);
|
||||||
}}
|
}}
|
||||||
flowSelectedData={flowSelectedData}
|
flowSelectedData={flowSelectedData}
|
||||||
flowSelectedStepId={flowSelectedStepId}
|
flowSelectedStepId={flowSelectedStepId}
|
||||||
|
|
@ -479,9 +491,16 @@ export default function ScreenViewPage() {
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
onSelectedRowsChange={(_, selectedData) => {
|
sortBy={tableSortBy}
|
||||||
|
sortOrder={tableSortOrder}
|
||||||
|
columnOrder={tableColumnOrder}
|
||||||
|
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||||
|
console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder });
|
||||||
setSelectedRowsData(selectedData);
|
setSelectedRowsData(selectedData);
|
||||||
|
setTableSortBy(sortBy);
|
||||||
|
setTableSortOrder(sortOrder || "asc");
|
||||||
|
setTableColumnOrder(columnOrder);
|
||||||
}}
|
}}
|
||||||
refreshKey={tableRefreshKey}
|
refreshKey={tableRefreshKey}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
|
|
@ -613,8 +632,14 @@ export default function ScreenViewPage() {
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
onSelectedRowsChange={(_, selectedData) => {
|
sortBy={tableSortBy}
|
||||||
|
sortOrder={tableSortOrder}
|
||||||
|
columnOrder={tableColumnOrder}
|
||||||
|
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||||||
setSelectedRowsData(selectedData);
|
setSelectedRowsData(selectedData);
|
||||||
|
setTableSortBy(sortBy);
|
||||||
|
setTableSortOrder(sortOrder || "asc");
|
||||||
|
setTableColumnOrder(columnOrder);
|
||||||
}}
|
}}
|
||||||
flowSelectedData={flowSelectedData}
|
flowSelectedData={flowSelectedData}
|
||||||
flowSelectedStepId={flowSelectedStepId}
|
flowSelectedStepId={flowSelectedStepId}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -19,10 +19,23 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2 } from "lucide-react";
|
import {
|
||||||
|
Upload,
|
||||||
|
FileSpreadsheet,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
ArrowRight,
|
||||||
|
Save,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ExcelUploadModalProps {
|
export interface ExcelUploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -33,6 +46,17 @@ export interface ExcelUploadModalProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ColumnMapping {
|
||||||
|
excelColumn: string;
|
||||||
|
systemColumn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadConfig {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mappings: ColumnMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -41,19 +65,38 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
keyColumn,
|
keyColumn,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
// 1단계: 파일 선택
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||||
const [selectedSheet, setSelectedSheet] = 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 2단계: 범위 지정
|
||||||
|
const [autoCreateColumn, setAutoCreateColumn] = useState(false);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string>("");
|
||||||
|
const [selectedDataType, setSelectedDataType] = useState<string>("");
|
||||||
|
const [detectedRange, setDetectedRange] = useState<string>("");
|
||||||
|
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
|
||||||
|
const [allData, setAllData] = useState<Record<string, any>[]>([]);
|
||||||
|
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
|
||||||
|
|
||||||
|
// 3단계: 컬럼 매핑
|
||||||
|
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
||||||
|
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
||||||
|
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||||
|
const [configName, setConfigName] = useState<string>("");
|
||||||
|
const [configType, setConfigType] = useState<string>("");
|
||||||
|
|
||||||
|
// 4단계: 확인
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// 파일 선택 핸들러
|
// 파일 선택 핸들러
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = e.target.files?.[0];
|
const selectedFile = e.target.files?.[0];
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
|
|
||||||
// 파일 확장자 검증
|
|
||||||
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
|
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
|
||||||
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
|
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
|
||||||
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
|
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
|
||||||
|
|
@ -63,14 +106,20 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setFile(selectedFile);
|
setFile(selectedFile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 시트 목록 가져오기
|
|
||||||
const sheets = await getExcelSheetNames(selectedFile);
|
const sheets = await getExcelSheetNames(selectedFile);
|
||||||
setSheetNames(sheets);
|
setSheetNames(sheets);
|
||||||
setSelectedSheet(sheets[0] || "");
|
setSelectedSheet(sheets[0] || "");
|
||||||
|
|
||||||
// 미리보기 데이터 로드 (첫 5행)
|
|
||||||
const data = await importFromExcel(selectedFile, sheets[0]);
|
const data = await importFromExcel(selectedFile, sheets[0]);
|
||||||
setPreviewData(data.slice(0, 5));
|
setAllData(data);
|
||||||
|
setDisplayData(data.slice(0, 10));
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
const columns = Object.keys(data[0]);
|
||||||
|
const lastCol = String.fromCharCode(64 + columns.length);
|
||||||
|
setDetectedRange(`A1:${lastCol}${data.length + 1}`);
|
||||||
|
setExcelColumns(columns);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
|
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -83,124 +132,223 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 시트 변경 핸들러
|
// 시트 변경 핸들러
|
||||||
const handleSheetChange = async (sheetName: string) => {
|
const handleSheetChange = async (sheetName: string) => {
|
||||||
setSelectedSheet(sheetName);
|
setSelectedSheet(sheetName);
|
||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await importFromExcel(file, sheetName);
|
const data = await importFromExcel(file, sheetName);
|
||||||
setPreviewData(data.slice(0, 5));
|
setAllData(data);
|
||||||
|
setDisplayData(data.slice(0, 10));
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
const columns = Object.keys(data[0]);
|
||||||
|
const lastCol = String.fromCharCode(64 + columns.length);
|
||||||
|
setDetectedRange(`A1:${lastCol}${data.length + 1}`);
|
||||||
|
setExcelColumns(columns);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("시트 읽기 오류:", error);
|
console.error("시트 읽기 오류:", error);
|
||||||
toast.error("시트를 읽는 중 오류가 발생했습니다.");
|
toast.error("시트를 읽는 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업로드 핸들러
|
// 행 추가
|
||||||
const handleUpload = async () => {
|
const handleAddRow = () => {
|
||||||
if (!file) {
|
const newRow: Record<string, any> = {};
|
||||||
|
excelColumns.forEach((col) => {
|
||||||
|
newRow[col] = "";
|
||||||
|
});
|
||||||
|
setDisplayData([...displayData, newRow]);
|
||||||
|
toast.success("행이 추가되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행 삭제
|
||||||
|
const handleRemoveRow = () => {
|
||||||
|
if (displayData.length > 1) {
|
||||||
|
setDisplayData(displayData.slice(0, -1));
|
||||||
|
toast.success("마지막 행이 삭제되었습니다.");
|
||||||
|
} else {
|
||||||
|
toast.error("최소 1개의 행이 필요합니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 열 추가
|
||||||
|
const handleAddColumn = () => {
|
||||||
|
const newColName = `Column${excelColumns.length + 1}`;
|
||||||
|
setExcelColumns([...excelColumns, newColName]);
|
||||||
|
setDisplayData(
|
||||||
|
displayData.map((row) => ({
|
||||||
|
...row,
|
||||||
|
[newColName]: "",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
toast.success("열이 추가되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 열 삭제
|
||||||
|
const handleRemoveColumn = () => {
|
||||||
|
if (excelColumns.length > 1) {
|
||||||
|
const lastCol = excelColumns[excelColumns.length - 1];
|
||||||
|
setExcelColumns(excelColumns.slice(0, -1));
|
||||||
|
setDisplayData(
|
||||||
|
displayData.map((row) => {
|
||||||
|
const { [lastCol]: removed, ...rest } = row;
|
||||||
|
return rest;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast.success("마지막 열이 삭제되었습니다.");
|
||||||
|
} else {
|
||||||
|
toast.error("최소 1개의 열이 필요합니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 스키마 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 3 && tableName) {
|
||||||
|
loadTableSchema();
|
||||||
|
}
|
||||||
|
}, [currentStep, tableName]);
|
||||||
|
|
||||||
|
const loadTableSchema = async () => {
|
||||||
|
try {
|
||||||
|
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
|
||||||
|
|
||||||
|
const response = await getTableSchema(tableName);
|
||||||
|
|
||||||
|
console.log("📊 테이블 스키마 응답:", response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
|
||||||
|
setSystemColumns(response.data.columns);
|
||||||
|
|
||||||
|
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
|
excelColumn: col,
|
||||||
|
systemColumn: null,
|
||||||
|
}));
|
||||||
|
setColumnMappings(initialMappings);
|
||||||
|
} else {
|
||||||
|
console.error("❌ 테이블 스키마 로드 실패:", response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 테이블 스키마 로드 실패:", error);
|
||||||
|
toast.error("테이블 스키마를 불러올 수 없습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동 매핑
|
||||||
|
const handleAutoMapping = () => {
|
||||||
|
const newMappings = excelColumns.map((excelCol) => {
|
||||||
|
const matchedSystemCol = systemColumns.find(
|
||||||
|
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
excelColumn: excelCol,
|
||||||
|
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnMappings(newMappings);
|
||||||
|
const matchedCount = newMappings.filter((m) => m.systemColumn).length;
|
||||||
|
toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 매핑 변경
|
||||||
|
const handleMappingChange = (excelColumn: string, systemColumn: string | null) => {
|
||||||
|
setColumnMappings((prev) =>
|
||||||
|
prev.map((mapping) =>
|
||||||
|
mapping.excelColumn === excelColumn
|
||||||
|
? { ...mapping, systemColumn }
|
||||||
|
: mapping
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설정 저장
|
||||||
|
const handleSaveConfig = () => {
|
||||||
|
if (!configName.trim()) {
|
||||||
|
toast.error("거래처명을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: UploadConfig = {
|
||||||
|
name: configName,
|
||||||
|
type: configType,
|
||||||
|
mappings: columnMappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedConfigs = JSON.parse(
|
||||||
|
localStorage.getItem("excelUploadConfigs") || "[]"
|
||||||
|
);
|
||||||
|
savedConfigs.push(config);
|
||||||
|
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
|
||||||
|
|
||||||
|
toast.success("설정이 저장되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다음 단계
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep === 1 && !file) {
|
||||||
toast.error("파일을 선택해주세요.");
|
toast.error("파일을 선택해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tableName) {
|
if (currentStep === 2 && displayData.length === 0) {
|
||||||
toast.error("테이블명이 지정되지 않았습니다.");
|
toast.error("데이터가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep((prev) => Math.min(prev + 1, 4));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이전 단계
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업로드 핸들러
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file || !tableName) {
|
||||||
|
toast.error("필수 정보가 누락되었습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 엑셀 데이터 읽기
|
const mappedData = displayData.map((row) => {
|
||||||
const data = await importFromExcel(file, selectedSheet);
|
const mappedRow: Record<string, any> = {};
|
||||||
|
columnMappings.forEach((mapping) => {
|
||||||
console.log("📤 엑셀 업로드 시작:", {
|
if (mapping.systemColumn) {
|
||||||
tableName,
|
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
|
||||||
uploadMode,
|
}
|
||||||
rowCount: data.length,
|
});
|
||||||
|
return mappedRow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 업로드 모드에 따라 처리
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
for (const row of data) {
|
for (const row of mappedData) {
|
||||||
try {
|
try {
|
||||||
if (uploadMode === "insert") {
|
if (uploadMode === "insert") {
|
||||||
// 삽입 모드
|
|
||||||
const formData = { screenId: 0, tableName, data: row };
|
const formData = { screenId: 0, tableName, data: row };
|
||||||
const result = await DynamicFormApi.saveFormData(formData);
|
const result = await DynamicFormApi.saveFormData(formData);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++;
|
successCount++;
|
||||||
} else {
|
} else {
|
||||||
console.error("저장 실패:", result.message, row);
|
|
||||||
failCount++;
|
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) {
|
} catch (error) {
|
||||||
console.error("행 처리 오류:", row, error);
|
|
||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 엑셀 업로드 완료:", {
|
|
||||||
successCount,
|
|
||||||
failCount,
|
|
||||||
totalCount: data.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`);
|
toast.success(
|
||||||
// onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||||
|
);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
// onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음
|
|
||||||
} else {
|
} else {
|
||||||
toast.error("업로드에 실패했습니다.");
|
toast.error("업로드에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -212,18 +360,95 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 모달 닫기 시 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setFile(null);
|
||||||
|
setSheetNames([]);
|
||||||
|
setSelectedSheet("");
|
||||||
|
setAutoCreateColumn(false);
|
||||||
|
setSelectedCompany("");
|
||||||
|
setSelectedDataType("");
|
||||||
|
setDetectedRange("");
|
||||||
|
setPreviewData([]);
|
||||||
|
setAllData([]);
|
||||||
|
setDisplayData([]);
|
||||||
|
setExcelColumns([]);
|
||||||
|
setSystemColumns([]);
|
||||||
|
setColumnMappings([]);
|
||||||
|
setConfigName("");
|
||||||
|
setConfigType("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[900px]">
|
<DialogContent className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">엑셀 파일 업로드</DialogTitle>
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
|
엑셀 데이터 업로드
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
엑셀 파일을 선택하여 데이터를 업로드하세요.
|
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 스텝 인디케이터 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{[
|
||||||
|
{ num: 1, label: "파일 선택" },
|
||||||
|
{ num: 2, label: "범위 지정" },
|
||||||
|
{ num: 3, label: "컬럼 매핑" },
|
||||||
|
{ num: 4, label: "확인" },
|
||||||
|
].map((step, index) => (
|
||||||
|
<React.Fragment key={step.num}>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors sm:h-10 sm:w-10",
|
||||||
|
currentStep === step.num
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: currentStep > step.num
|
||||||
|
? "bg-success text-white"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentStep > step.num ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
) : (
|
||||||
|
step.num
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-medium sm:text-xs",
|
||||||
|
currentStep === step.num
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{index < 3 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-0.5 flex-1 transition-colors",
|
||||||
|
currentStep > step.num ? "bg-success" : "bg-muted"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스텝별 컨텐츠 */}
|
||||||
|
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
|
||||||
|
{/* 1단계: 파일 선택 */}
|
||||||
|
{currentStep === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 파일 선택 */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||||
파일 선택 *
|
파일 선택 *
|
||||||
|
|
@ -252,7 +477,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 시트 선택 */}
|
|
||||||
{sheetNames.length > 0 && (
|
{sheetNames.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sheet-select" className="text-xs sm:text-sm">
|
<Label htmlFor="sheet-select" className="text-xs sm:text-sm">
|
||||||
|
|
@ -264,7 +488,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sheetNames.map((sheetName) => (
|
{sheetNames.map((sheetName) => (
|
||||||
<SelectItem key={sheetName} value={sheetName} className="text-xs sm:text-sm">
|
<SelectItem
|
||||||
|
key={sheetName}
|
||||||
|
value={sheetName}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
<FileSpreadsheet className="mr-2 inline h-4 w-4" />
|
<FileSpreadsheet className="mr-2 inline h-4 w-4" />
|
||||||
{sheetName}
|
{sheetName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -273,43 +501,171 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</Select>
|
</Select>
|
||||||
</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">업로드 모드: {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
{uploadMode === "insert" && "새로운 데이터로 삽입됩니다."}
|
|
||||||
{uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`}
|
|
||||||
{uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2단계: 범위 지정 */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 상단: 3개 드롭다운 가로 배치 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Select value={selectedSheet} onValueChange={handleSheetChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="Sheet1" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheetNames.map((sheetName) => (
|
||||||
|
<SelectItem key={sheetName} value={sheetName} className="text-xs sm:text-sm">
|
||||||
|
{sheetName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="거래처 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="company1" className="text-xs sm:text-sm">
|
||||||
|
ABC 주식회사
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="company2" className="text-xs sm:text-sm">
|
||||||
|
XYZ 상사
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="company3" className="text-xs sm:text-sm">
|
||||||
|
대한물산
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={selectedDataType} onValueChange={setSelectedDataType}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="유형 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="type1" className="text-xs sm:text-sm">
|
||||||
|
유형 1
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="type2" className="text-xs sm:text-sm">
|
||||||
|
유형 2
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중간: 체크박스 + 버튼들 한 줄 배치 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-create"
|
||||||
|
checked={autoCreateColumn}
|
||||||
|
onCheckedChange={(checked) => setAutoCreateColumn(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="auto-create"
|
||||||
|
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm"
|
||||||
|
>
|
||||||
|
자동 거래처 열 생성
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddRow}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
행 추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddColumn}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
열 추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveRow}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Minus className="mr-1 h-3 w-3" />
|
||||||
|
행 삭제
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveColumn}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Minus className="mr-1 h-3 w-3" />
|
||||||
|
열 삭제
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 미리보기 */}
|
{/* 하단: 감지된 범위 + 테이블 */}
|
||||||
{previewData.length > 0 && (
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||||
<div>
|
감지된 범위: <span className="font-medium">{detectedRange}</span>
|
||||||
<Label className="text-xs sm:text-sm">미리보기 (최대 5행)</Label>
|
<span className="ml-2 text-[10px] sm:text-xs">
|
||||||
<div className="mt-2 max-h-[300px] overflow-auto rounded-md border border-border">
|
첫 행이 컬럼명, 데이터는 자동 감지됩니다
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayData.length > 0 && (
|
||||||
|
<div className="max-h-[250px] overflow-auto rounded-md border border-border">
|
||||||
<table className="min-w-full text-[10px] sm:text-xs">
|
<table className="min-w-full text-[10px] sm:text-xs">
|
||||||
<thead className="sticky top-0 bg-muted">
|
<thead className="sticky top-0 bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
{Object.keys(previewData[0]).map((key) => (
|
<th className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
|
||||||
<th key={key} className="whitespace-nowrap border-b border-border px-2 py-1 text-left font-medium">
|
|
||||||
{key}
|
</th>
|
||||||
|
{excelColumns.map((col, index) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium"
|
||||||
|
>
|
||||||
|
{String.fromCharCode(65 + index)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{previewData.map((row, index) => (
|
<tr className="bg-primary/5">
|
||||||
<tr key={index} className="border-b border-border last:border-0">
|
<td className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
|
||||||
{Object.values(row).map((value, i) => (
|
1
|
||||||
<td key={i} className="max-w-[150px] truncate whitespace-nowrap px-2 py-1" title={String(value)}>
|
</td>
|
||||||
{String(value)}
|
{excelColumns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="whitespace-nowrap border-b border-r border-border px-2 py-1 font-medium text-primary"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{displayData.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||||
|
<td className="whitespace-nowrap border-r border-border bg-muted/50 px-2 py-1 text-center font-medium text-muted-foreground">
|
||||||
|
{rowIndex + 2}
|
||||||
|
</td>
|
||||||
|
{excelColumns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="max-w-[150px] truncate whitespace-nowrap border-r border-border px-2 py-1"
|
||||||
|
title={String(row[col])}
|
||||||
|
>
|
||||||
|
{String(row[col] || "")}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -317,9 +673,179 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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" />
|
</div>
|
||||||
<span>총 {previewData.length}개 행 (미리보기)</span>
|
)}
|
||||||
|
|
||||||
|
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
|
||||||
|
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold sm:text-base">컬럼 매핑 설정</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAutoMapping}
|
||||||
|
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
자동 매핑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 매핑 리스트 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||||
|
<div>엑셀 컬럼</div>
|
||||||
|
<div></div>
|
||||||
|
<div>시스템 컬럼</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-y-auto">
|
||||||
|
{columnMappings.map((mapping, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||||
|
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
|
||||||
|
{mapping.excelColumn}
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select
|
||||||
|
value={mapping.systemColumn || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleMappingChange(
|
||||||
|
mapping.excelColumn,
|
||||||
|
value === "none" ? null : value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="매핑 안함" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none" className="text-xs sm:text-sm">
|
||||||
|
매핑 안함
|
||||||
|
</SelectItem>
|
||||||
|
{systemColumns.map((col) => (
|
||||||
|
<SelectItem
|
||||||
|
key={col.name}
|
||||||
|
value={col.name}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{col.name} ({col.type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 현재 설정 저장 */}
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
<h3 className="text-sm font-semibold sm:text-base">현재 설정 저장</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
|
||||||
|
거래처명 *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="config-name"
|
||||||
|
value={configName}
|
||||||
|
onChange={(e) => setConfigName(e.target.value)}
|
||||||
|
placeholder="거래처 선택"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
|
||||||
|
유형
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="config-type"
|
||||||
|
value={configType}
|
||||||
|
onChange={(e) => setConfigType(e.target.value)}
|
||||||
|
placeholder="유형을 입력하세요 (예: 원자재)"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveConfig}
|
||||||
|
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-3 w-3" />
|
||||||
|
설정 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4단계: 확인 */}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||||
|
<h3 className="text-sm font-medium sm:text-base">업로드 요약</h3>
|
||||||
|
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">파일:</span> {file?.name}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">시트:</span> {selectedSheet}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">데이터 행:</span> {displayData.length}개
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">테이블:</span> {tableName}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">모드:</span>{" "}
|
||||||
|
{uploadMode === "insert"
|
||||||
|
? "삽입"
|
||||||
|
: uploadMode === "update"
|
||||||
|
? "업데이트"
|
||||||
|
: "Upsert"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||||
|
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
||||||
|
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
{columnMappings
|
||||||
|
.filter((m) => m.systemColumn)
|
||||||
|
.map((mapping, index) => (
|
||||||
|
<p key={index}>
|
||||||
|
<span className="font-medium">{mapping.excelColumn}</span> →{" "}
|
||||||
|
{mapping.systemColumn}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
|
||||||
|
<p className="text-destructive">매핑된 컬럼이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-warning bg-warning/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 text-warning" />
|
||||||
|
<div className="text-[10px] text-warning sm:text-xs">
|
||||||
|
<p className="font-medium">주의사항</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -328,22 +854,31 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
취소
|
{currentStep === 1 ? "취소" : "이전"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{currentStep < 4 ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleNext}
|
||||||
disabled={!file || isUploading}
|
disabled={isUploading}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{isUploading ? "업로드 중..." : "업로드"}
|
다음
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading || columnMappings.filter((m) => m.systemColumn).length === 0}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isUploading ? "업로드 중..." : "다음"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ interface RealtimePreviewProps {
|
||||||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||||
// 플로우 선택 데이터 전달용
|
// 플로우 선택 데이터 전달용
|
||||||
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
||||||
|
// 테이블 정렬 정보 전달용
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
[key: string]: any; // 추가 props 허용
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영역 레이아웃에 따른 아이콘 반환
|
// 영역 레이아웃에 따른 아이콘 반환
|
||||||
|
|
@ -225,6 +229,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onGroupToggle,
|
onGroupToggle,
|
||||||
children,
|
children,
|
||||||
onFlowSelectedDataChange,
|
onFlowSelectedDataChange,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { type, id, position, size, style = {} } = component;
|
const { type, id, position, size, style = {} } = component;
|
||||||
|
|
@ -545,7 +552,13 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||||
{type === "widget" && !isFileComponent(component) && (
|
{type === "widget" && !isFileComponent(component) && (
|
||||||
<div className="pointer-events-none h-full w-full">
|
<div className="pointer-events-none h-full w-full">
|
||||||
<WidgetRenderer component={component} isDesignMode={isDesignMode} />
|
<WidgetRenderer
|
||||||
|
component={component}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ interface RealtimePreviewProps {
|
||||||
// 폼 데이터 관련 props
|
// 폼 데이터 관련 props
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
|
// 테이블 정렬 정보
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
columnOrder?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||||
|
|
@ -109,6 +114,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onFlowSelectedDataChange,
|
onFlowSelectedDataChange,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
columnOrder,
|
||||||
flowRefreshKey,
|
flowRefreshKey,
|
||||||
onFlowRefresh,
|
onFlowRefresh,
|
||||||
formData,
|
formData,
|
||||||
|
|
@ -395,6 +403,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onFlowRefresh={onFlowRefresh}
|
onFlowRefresh={onFlowRefresh}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
columnOrder={columnOrder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
|
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -838,6 +839,53 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 코드 병합 액션 설정 */}
|
||||||
|
{(component.componentConfig?.action?.type || "save") === "code_merge" && (
|
||||||
|
<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="merge-column-name">
|
||||||
|
병합할 컬럼명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="merge-column-name"
|
||||||
|
placeholder="예: item_code, product_id"
|
||||||
|
value={config.action?.mergeColumnName || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.mergeColumnName", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
병합할 컬럼명 (예: item_code). 이 컬럼이 있는 모든 테이블에 병합이 적용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="merge-show-preview">병합 전 미리보기</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">영향받을 테이블과 행 수를 미리 확인합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="merge-show-preview"
|
||||||
|
checked={config.action?.mergeShowPreview !== false}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.mergeShowPreview", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
<strong>사용 방법:</strong>
|
||||||
|
<br />
|
||||||
|
1. 테이블에서 병합할 두 개의 행을 선택합니다
|
||||||
|
<br />
|
||||||
|
2. 이 버튼을 클릭하면 병합 방향을 선택할 수 있습니다
|
||||||
|
<br />
|
||||||
|
3. 데이터는 삭제되지 않고, 컬럼 값만 변경됩니다
|
||||||
|
</p>
|
||||||
|
</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} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface TableColumn {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
default: string | null;
|
||||||
|
maxLength: number | null;
|
||||||
|
precision: number | null;
|
||||||
|
scale: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableSchemaResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
tableName: string;
|
||||||
|
columns: TableColumn[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||||
|
*/
|
||||||
|
export async function getTableSchema(
|
||||||
|
tableName: string
|
||||||
|
): Promise<TableSchemaResponse> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<TableSchemaResponse>(
|
||||||
|
`/admin/tables/${tableName}/schema`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("테이블 스키마 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "테이블 스키마 조회 실패",
|
||||||
|
data: {
|
||||||
|
tableName,
|
||||||
|
columns: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +142,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
const preloadCommonCodesOnMount = useCallback(async (): Promise<void> => {
|
const preloadCommonCodesOnMount = useCallback(async (): Promise<void> => {
|
||||||
if (!preloadCommonCodes) return;
|
if (!preloadCommonCodes) return;
|
||||||
|
|
||||||
console.log("🚀 공통 코드 프리로딩 시작");
|
// console.log("🚀 공통 코드 프리로딩 시작");
|
||||||
|
|
||||||
// 현재 테이블의 코드 카테고리와 공통 카테고리 합치기
|
// 현재 테이블의 코드 카테고리와 공통 카테고리 합치기
|
||||||
const allCategories = [...new Set([...codeCategories, ...commonCodeCategories])];
|
const allCategories = [...new Set([...codeCategories, ...commonCodeCategories])];
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ export interface ComponentRenderer {
|
||||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void;
|
||||||
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
|
|
@ -101,7 +104,11 @@ export interface DynamicComponentRendererProps {
|
||||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void;
|
||||||
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
columnOrder?: string[];
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
|
|
@ -191,6 +198,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
selectedRows,
|
selectedRows,
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
onSelectedRowsChange,
|
onSelectedRowsChange,
|
||||||
|
sortBy, // 🆕 정렬 컬럼
|
||||||
|
sortOrder, // 🆕 정렬 방향
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
flowSelectedStepId,
|
flowSelectedStepId,
|
||||||
onFlowSelectedDataChange,
|
onFlowSelectedDataChange,
|
||||||
|
|
@ -278,6 +287,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
selectedRows,
|
selectedRows,
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
onSelectedRowsChange,
|
onSelectedRowsChange,
|
||||||
|
// 테이블 정렬 정보 전달
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
// 플로우 선택된 데이터 정보 전달
|
// 플로우 선택된 데이터 정보 전달
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
flowSelectedStepId,
|
flowSelectedStepId,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
value: undefined, // 미리보기이므로 값은 없음
|
value: undefined, // 미리보기이므로 값은 없음
|
||||||
readonly: readonly,
|
readonly: readonly,
|
||||||
isDesignMode: true, // 디자인 모드임을 명시
|
isDesignMode: true, // 디자인 모드임을 명시
|
||||||
|
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
|
||||||
}}
|
}}
|
||||||
config={widget.webTypeConfig}
|
config={widget.webTypeConfig}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
|
|
||||||
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
columnOrder?: string[];
|
||||||
|
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
|
|
@ -74,6 +79,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onClose,
|
onClose,
|
||||||
onFlowRefresh,
|
onFlowRefresh,
|
||||||
|
sortBy, // 🆕 정렬 컬럼
|
||||||
|
sortOrder, // 🆕 정렬 방향
|
||||||
|
columnOrder, // 🆕 컬럼 순서
|
||||||
selectedRows,
|
selectedRows,
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
|
|
@ -405,6 +413,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 테이블 선택된 행 정보 추가
|
// 테이블 선택된 행 정보 추가
|
||||||
selectedRows,
|
selectedRows,
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
|
// 테이블 정렬 정보 추가
|
||||||
|
sortBy, // 🆕 정렬 컬럼
|
||||||
|
sortOrder, // 🆕 정렬 방향
|
||||||
|
columnOrder, // 🆕 컬럼 순서
|
||||||
// 플로우 선택된 데이터 정보 추가
|
// 플로우 선택된 데이터 정보 추가
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
flowSelectedStepId,
|
flowSelectedStepId,
|
||||||
|
|
|
||||||
|
|
@ -8,41 +8,53 @@ import { cn } from "@/lib/utils";
|
||||||
import { ColumnConfig } from "./types";
|
import { ColumnConfig } from "./types";
|
||||||
|
|
||||||
interface SingleTableWithStickyProps {
|
interface SingleTableWithStickyProps {
|
||||||
visibleColumns: ColumnConfig[];
|
visibleColumns?: ColumnConfig[];
|
||||||
|
columns?: ColumnConfig[];
|
||||||
data: Record<string, any>[];
|
data: Record<string, any>[];
|
||||||
columnLabels: Record<string, string>;
|
columnLabels: Record<string, string>;
|
||||||
sortColumn: string | null;
|
sortColumn: string | null;
|
||||||
sortDirection: "asc" | "desc";
|
sortDirection: "asc" | "desc";
|
||||||
tableConfig: any;
|
tableConfig?: any;
|
||||||
isDesignMode: boolean;
|
isDesignMode?: boolean;
|
||||||
isAllSelected: boolean;
|
isAllSelected?: boolean;
|
||||||
handleSort: (columnName: string) => void;
|
handleSort?: (columnName: string) => void;
|
||||||
handleSelectAll: (checked: boolean) => void;
|
onSort?: (columnName: string) => void;
|
||||||
handleRowClick: (row: any) => void;
|
handleSelectAll?: (checked: boolean) => void;
|
||||||
renderCheckboxCell: (row: any, index: number) => React.ReactNode;
|
handleRowClick?: (row: any) => void;
|
||||||
|
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
|
||||||
|
renderCheckboxHeader?: () => React.ReactNode;
|
||||||
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
||||||
getColumnWidth: (column: ColumnConfig) => number;
|
getColumnWidth: (column: ColumnConfig) => number;
|
||||||
containerWidth?: string; // 컨테이너 너비 설정
|
containerWidth?: string; // 컨테이너 너비 설정
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
|
columns,
|
||||||
data,
|
data,
|
||||||
columnLabels,
|
columnLabels,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
tableConfig,
|
tableConfig,
|
||||||
isDesignMode,
|
isDesignMode = false,
|
||||||
isAllSelected,
|
isAllSelected = false,
|
||||||
handleSort,
|
handleSort,
|
||||||
|
onSort,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
handleRowClick,
|
handleRowClick,
|
||||||
renderCheckboxCell,
|
renderCheckboxCell,
|
||||||
|
renderCheckboxHeader,
|
||||||
formatCellValue,
|
formatCellValue,
|
||||||
getColumnWidth,
|
getColumnWidth,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
}) => {
|
}) => {
|
||||||
const checkboxConfig = tableConfig.checkbox || {};
|
const checkboxConfig = tableConfig?.checkbox || {};
|
||||||
|
const actualColumns = visibleColumns || columns || [];
|
||||||
|
const sortHandler = onSort || handleSort || (() => {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -71,15 +83,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableRow className="border-b">
|
<TableRow className="border-b">
|
||||||
{visibleColumns.map((column, colIndex) => {
|
{actualColumns.map((column, colIndex) => {
|
||||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||||
const leftFixedWidth = visibleColumns
|
const leftFixedWidth = actualColumns
|
||||||
.slice(0, colIndex)
|
.slice(0, colIndex)
|
||||||
.filter((col) => col.fixed === "left")
|
.filter((col) => col.fixed === "left")
|
||||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||||
|
|
||||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||||
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||||
const rightFixedWidth =
|
const rightFixedWidth =
|
||||||
rightFixedIndex >= 0
|
rightFixedIndex >= 0
|
||||||
|
|
@ -115,7 +127,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
}}
|
}}
|
||||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{column.columnName === "__checkbox__" ? (
|
{column.columnName === "__checkbox__" ? (
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,8 @@ export interface TableListComponentProps {
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
screenId?: string;
|
screenId?: string;
|
||||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||||||
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void;
|
||||||
onConfigChange?: (config: any) => void;
|
onConfigChange?: (config: any) => void;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +164,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
tableName,
|
tableName,
|
||||||
|
userId,
|
||||||
}) => {
|
}) => {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 설정 및 스타일
|
// 설정 및 스타일
|
||||||
|
|
@ -178,18 +180,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
let finalSelectedTable =
|
let finalSelectedTable =
|
||||||
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
|
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
|
||||||
|
|
||||||
console.log("🔍 TableListComponent 초기화:", {
|
// 디버그 로그 제거 (성능 최적화)
|
||||||
componentConfigSelectedTable: componentConfig?.selectedTable,
|
|
||||||
componentConfigSelectedTableType: typeof componentConfig?.selectedTable,
|
|
||||||
componentConfigSelectedTable2: component.config?.selectedTable,
|
|
||||||
componentConfigSelectedTable2Type: typeof component.config?.selectedTable,
|
|
||||||
configSelectedTable: config?.selectedTable,
|
|
||||||
configSelectedTableType: typeof config?.selectedTable,
|
|
||||||
screenTableName: tableName,
|
|
||||||
screenTableNameType: typeof tableName,
|
|
||||||
finalSelectedTable,
|
|
||||||
finalSelectedTableType: typeof finalSelectedTable,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 객체인 경우 tableName 속성 추출 시도
|
// 객체인 경우 tableName 속성 추출 시도
|
||||||
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
||||||
|
|
@ -200,12 +191,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
tableConfig.selectedTable = finalSelectedTable;
|
tableConfig.selectedTable = finalSelectedTable;
|
||||||
|
|
||||||
console.log(
|
// 디버그 로그 제거 (성능 최적화)
|
||||||
"✅ 최종 tableConfig.selectedTable:",
|
|
||||||
tableConfig.selectedTable,
|
|
||||||
"타입:",
|
|
||||||
typeof tableConfig.selectedTable,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buttonColor = component.style?.labelColor || "#212121";
|
const buttonColor = component.style?.labelColor || "#212121";
|
||||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||||
|
|
@ -262,6 +248,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
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 [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverColumnIndex, setDragOverColumnIndex] = useState<number | null>(null);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||||
const hasInitializedWidths = useRef(false);
|
const hasInitializedWidths = useRef(false);
|
||||||
|
|
@ -390,10 +380,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블명 확인 로그
|
// 테이블명 확인 로그 (개발 시에만)
|
||||||
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
||||||
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
||||||
console.log("🔍 전체 tableConfig:", tableConfig);
|
// console.log("🔍 전체 tableConfig:", tableConfig);
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -488,11 +478,43 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column: string) => {
|
const handleSort = (column: string) => {
|
||||||
|
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
|
||||||
|
|
||||||
|
let newSortColumn = column;
|
||||||
|
let newSortDirection: "asc" | "desc" = "asc";
|
||||||
|
|
||||||
if (sortColumn === column) {
|
if (sortColumn === column) {
|
||||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
setSortDirection(newSortDirection);
|
||||||
} else {
|
} else {
|
||||||
setSortColumn(column);
|
setSortColumn(column);
|
||||||
setSortDirection("asc");
|
setSortDirection("asc");
|
||||||
|
newSortColumn = column;
|
||||||
|
newSortDirection = "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
|
||||||
|
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
|
||||||
|
|
||||||
|
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
|
||||||
|
if (onSelectedRowsChange) {
|
||||||
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
||||||
|
console.log("✅ 정렬 정보 전달:", {
|
||||||
|
selectedRowsCount: selectedRows.size,
|
||||||
|
selectedRowsDataCount: selectedRowsData.length,
|
||||||
|
sortBy: newSortColumn,
|
||||||
|
sortOrder: newSortDirection,
|
||||||
|
columnOrder: columnOrder.length > 0 ? columnOrder : undefined
|
||||||
|
});
|
||||||
|
onSelectedRowsChange(
|
||||||
|
Array.from(selectedRows),
|
||||||
|
selectedRowsData,
|
||||||
|
newSortColumn,
|
||||||
|
newSortDirection,
|
||||||
|
columnOrder.length > 0 ? columnOrder : undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -530,7 +552,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
|
||||||
}
|
}
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange({
|
onFormDataChange({
|
||||||
|
|
@ -551,7 +573,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setIsAllSelected(true);
|
setIsAllSelected(true);
|
||||||
|
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
||||||
}
|
}
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange({
|
onFormDataChange({
|
||||||
|
|
@ -564,7 +586,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setIsAllSelected(false);
|
setIsAllSelected(false);
|
||||||
|
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
onSelectedRowsChange([], []);
|
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
|
||||||
}
|
}
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||||||
|
|
@ -588,6 +610,58 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setDraggedRowIndex(null);
|
setDraggedRowIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 컬럼 드래그앤드롭 핸들러
|
||||||
|
const handleColumnDragStart = (e: React.DragEvent, columnIndex: number) => {
|
||||||
|
console.log("🔄 컬럼 드래그 시작:", columnIndex);
|
||||||
|
setDraggedColumnIndex(columnIndex);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnDragOver = (e: React.DragEvent, columnIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (draggedColumnIndex !== null && draggedColumnIndex !== columnIndex) {
|
||||||
|
setDragOverColumnIndex(columnIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnDrop = (e: React.DragEvent, dropColumnIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("📥 컬럼 드롭:", { from: draggedColumnIndex, to: dropColumnIndex });
|
||||||
|
|
||||||
|
if (draggedColumnIndex === null || draggedColumnIndex === dropColumnIndex) {
|
||||||
|
setDraggedColumnIndex(null);
|
||||||
|
setDragOverColumnIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 순서 변경
|
||||||
|
const newColumns = [...visibleColumns];
|
||||||
|
const [draggedColumn] = newColumns.splice(draggedColumnIndex, 1);
|
||||||
|
newColumns.splice(dropColumnIndex, 0, draggedColumn);
|
||||||
|
|
||||||
|
console.log("✅ 컬럼 순서 변경 완료:", newColumns.map(c => c.columnName));
|
||||||
|
|
||||||
|
// 로컬 스토리지에 저장 (사용자별 설정)
|
||||||
|
const userKey = userId || 'guest';
|
||||||
|
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
|
||||||
|
const newColumnOrder = newColumns.map(c => c.columnName);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(newColumnOrder));
|
||||||
|
console.log("💾 컬럼 순서 저장:", { storageKey, columnOrder: newColumnOrder });
|
||||||
|
|
||||||
|
// 상태 직접 업데이트 - React가 즉시 리렌더링하도록
|
||||||
|
setColumnOrder(newColumnOrder);
|
||||||
|
console.log("🔄 columnOrder 상태 업데이트:", newColumnOrder);
|
||||||
|
|
||||||
|
setDraggedColumnIndex(null);
|
||||||
|
setDragOverColumnIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnDragEnd = () => {
|
||||||
|
setDraggedColumnIndex(null);
|
||||||
|
setDragOverColumnIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.();
|
onClick?.();
|
||||||
|
|
@ -619,8 +693,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// columnOrder 상태가 있으면 그 순서대로 정렬
|
||||||
|
if (columnOrder.length > 0) {
|
||||||
|
const orderedCols = columnOrder
|
||||||
|
.map(colName => cols.find(c => c.columnName === colName))
|
||||||
|
.filter(Boolean) as ColumnConfig[];
|
||||||
|
|
||||||
|
// columnOrder에 없는 새로운 컬럼들 추가
|
||||||
|
const remainingCols = cols.filter(c => !columnOrder.includes(c.columnName));
|
||||||
|
|
||||||
|
console.log("🔄 columnOrder 기반 정렬:", {
|
||||||
|
columnOrder,
|
||||||
|
orderedColsCount: orderedCols.length,
|
||||||
|
remainingColsCount: remainingCols.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...orderedCols, ...remainingCols];
|
||||||
|
}
|
||||||
|
|
||||||
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
}, [tableConfig.columns, tableConfig.checkbox]);
|
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
|
||||||
|
|
||||||
const getColumnWidth = (column: ColumnConfig) => {
|
const getColumnWidth = (column: ColumnConfig) => {
|
||||||
if (column.columnName === "__checkbox__") return 50;
|
if (column.columnName === "__checkbox__") return 50;
|
||||||
|
|
@ -1178,6 +1270,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
|
tableConfig={tableConfig}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isAllSelected={isAllSelected}
|
||||||
|
handleSelectAll={handleSelectAll}
|
||||||
|
handleRowClick={handleRowClick}
|
||||||
columnLabels={columnLabels}
|
columnLabels={columnLabels}
|
||||||
renderCheckboxHeader={renderCheckboxHeader}
|
renderCheckboxHeader={renderCheckboxHeader}
|
||||||
renderCheckboxCell={renderCheckboxCell}
|
renderCheckboxCell={renderCheckboxCell}
|
||||||
|
|
@ -1289,10 +1386,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<th
|
<th
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
||||||
|
draggable={!isDesignMode && column.columnName !== "__checkbox__"}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (column.columnName !== "__checkbox__") {
|
||||||
|
handleColumnDragStart(e, columnIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (column.columnName !== "__checkbox__") {
|
||||||
|
handleColumnDragOver(e, columnIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (column.columnName !== "__checkbox__") {
|
||||||
|
handleColumnDrop(e, columnIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnd={handleColumnDragEnd}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
|
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
|
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"
|
(column.sortable !== false && column.columnName !== "__checkbox__") && "cursor-pointer hover:bg-muted/70 transition-colors",
|
||||||
|
!isDesignMode && column.columnName !== "__checkbox__" && "cursor-move",
|
||||||
|
draggedColumnIndex === columnIndex && "opacity-50",
|
||||||
|
dragOverColumnIndex === columnIndex && "bg-primary/20"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
||||||
|
|
@ -1303,7 +1420,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isResizing.current) return;
|
if (isResizing.current) return;
|
||||||
if (column.sortable) handleSort(column.columnName);
|
if (column.sortable !== false && column.columnName !== "__checkbox__") {
|
||||||
|
handleSort(column.columnName);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.columnName === "__checkbox__" ? (
|
{column.columnName === "__checkbox__" ? (
|
||||||
|
|
@ -1311,7 +1430,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||||
{column.sortable && sortColumn === column.columnName && (
|
{column.sortable !== false && sortColumn === column.columnName && (
|
||||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ export type ButtonActionType =
|
||||||
| "view_table_history" // 테이블 이력 보기
|
| "view_table_history" // 테이블 이력 보기
|
||||||
| "excel_download" // 엑셀 다운로드
|
| "excel_download" // 엑셀 다운로드
|
||||||
| "excel_upload" // 엑셀 업로드
|
| "excel_upload" // 엑셀 업로드
|
||||||
| "barcode_scan"; // 바코드 스캔
|
| "barcode_scan" // 바코드 스캔
|
||||||
|
| "code_merge"; // 코드 병합
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -73,6 +74,10 @@ export interface ButtonActionConfig {
|
||||||
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
||||||
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
|
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
|
||||||
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
|
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
|
||||||
|
|
||||||
|
// 코드 병합 관련
|
||||||
|
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||||
|
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,8 +106,11 @@ export interface ButtonActionContext {
|
||||||
|
|
||||||
// 제어 실행을 위한 추가 정보
|
// 제어 실행을 위한 추가 정보
|
||||||
buttonId?: string;
|
buttonId?: string;
|
||||||
userId?: string;
|
|
||||||
companyCode?: string;
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||||
|
sortBy?: string; // 정렬 컬럼명
|
||||||
|
sortOrder?: "asc" | "desc"; // 정렬 방향
|
||||||
|
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -147,6 +155,9 @@ export class ButtonActionExecutor {
|
||||||
case "barcode_scan":
|
case "barcode_scan":
|
||||||
return await this.handleBarcodeScan(config, context);
|
return await this.handleBarcodeScan(config, context);
|
||||||
|
|
||||||
|
case "code_merge":
|
||||||
|
return await this.handleCodeMerge(config, context);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -1688,17 +1699,57 @@ export class ButtonActionExecutor {
|
||||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
dataToExport = context.selectedRowsData;
|
dataToExport = context.selectedRowsData;
|
||||||
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
||||||
|
|
||||||
|
// 선택된 행도 정렬 적용
|
||||||
|
if (context.sortBy) {
|
||||||
|
console.log("🔄 선택된 행 데이터 정렬 적용:", {
|
||||||
|
sortBy: context.sortBy,
|
||||||
|
sortOrder: context.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
dataToExport = [...dataToExport].sort((a, b) => {
|
||||||
|
const aVal = a[context.sortBy!];
|
||||||
|
const bVal = b[context.sortBy!];
|
||||||
|
|
||||||
|
// null/undefined 처리
|
||||||
|
if (aVal == null && bVal == null) return 0;
|
||||||
|
if (aVal == null) return 1;
|
||||||
|
if (bVal == null) return -1;
|
||||||
|
|
||||||
|
// 숫자 비교
|
||||||
|
const aNum = Number(aVal);
|
||||||
|
const bNum = Number(bVal);
|
||||||
|
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||||
|
return context.sortOrder === "desc" ? bNum - aNum : aNum - bNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열 비교
|
||||||
|
const aStr = String(aVal);
|
||||||
|
const bStr = String(bVal);
|
||||||
|
const comparison = aStr.localeCompare(bStr);
|
||||||
|
return context.sortOrder === "desc" ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 정렬 완료:", {
|
||||||
|
firstRow: dataToExport[0],
|
||||||
|
lastRow: dataToExport[dataToExport.length - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 2순위: 테이블 전체 데이터 (API 호출)
|
// 2순위: 테이블 전체 데이터 (API 호출)
|
||||||
else if (context.tableName) {
|
else if (context.tableName) {
|
||||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
||||||
|
console.log("📊 정렬 정보:", {
|
||||||
|
sortBy: context.sortBy,
|
||||||
|
sortOrder: context.sortOrder,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10000, // 최대 10,000개 행
|
pageSize: 10000, // 최대 10,000개 행
|
||||||
sortBy: "id", // 기본 정렬: id 컬럼
|
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
|
||||||
sortOrder: "asc", // 오름차순
|
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📦 API 응답 구조:", {
|
console.log("📦 API 응답 구조:", {
|
||||||
|
|
@ -1763,12 +1814,43 @@ export class ButtonActionExecutor {
|
||||||
const sheetName = config.excelSheetName || "Sheet1";
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
|
// 🆕 컬럼 순서 재정렬 (사용자가 드래그앤드롭으로 변경한 순서 적용)
|
||||||
|
if (context.columnOrder && context.columnOrder.length > 0 && dataToExport.length > 0) {
|
||||||
|
console.log("🔄 컬럼 순서 재정렬:", context.columnOrder);
|
||||||
|
|
||||||
|
dataToExport = dataToExport.map((row: any) => {
|
||||||
|
const reorderedRow: any = {};
|
||||||
|
|
||||||
|
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
|
||||||
|
context.columnOrder!.forEach((colName: string) => {
|
||||||
|
if (colName in row) {
|
||||||
|
reorderedRow[colName] = row[colName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치)
|
||||||
|
Object.keys(row).forEach((key) => {
|
||||||
|
if (!(key in reorderedRow)) {
|
||||||
|
reorderedRow[key] = row[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reorderedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 컬럼 순서 재정렬 완료:", {
|
||||||
|
originalColumns: Object.keys(dataToExport[0] || {}),
|
||||||
|
reorderedColumns: Object.keys(dataToExport[0] || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📥 엑셀 다운로드 실행:", {
|
console.log("📥 엑셀 다운로드 실행:", {
|
||||||
fileName,
|
fileName,
|
||||||
sheetName,
|
sheetName,
|
||||||
includeHeaders,
|
includeHeaders,
|
||||||
dataCount: dataToExport.length,
|
dataCount: dataToExport.length,
|
||||||
firstRow: dataToExport[0],
|
firstRow: dataToExport[0],
|
||||||
|
columnOrder: context.columnOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 엑셀 다운로드 실행
|
// 엑셀 다운로드 실행
|
||||||
|
|
@ -1892,6 +1974,177 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 병합 액션 처리
|
||||||
|
*/
|
||||||
|
private static async handleCodeMerge(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log("🔀 코드 병합 액션 실행:", { config, context });
|
||||||
|
|
||||||
|
// 선택된 행 데이터 확인
|
||||||
|
const selectedRows = context.selectedRowsData || context.flowSelectedData;
|
||||||
|
if (!selectedRows || selectedRows.length !== 2) {
|
||||||
|
toast.error("병합할 두 개의 항목을 선택해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 병합할 컬럼명 확인
|
||||||
|
const columnName = config.mergeColumnName;
|
||||||
|
if (!columnName) {
|
||||||
|
toast.error("병합할 컬럼명이 설정되지 않았습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두 개의 선택된 행에서 컬럼 값 추출
|
||||||
|
const [row1, row2] = selectedRows;
|
||||||
|
const value1 = row1[columnName];
|
||||||
|
const value2 = row2[columnName];
|
||||||
|
|
||||||
|
if (!value1 || !value2) {
|
||||||
|
toast.error(`선택한 항목에 "${columnName}" 값이 없습니다.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value1 === value2) {
|
||||||
|
toast.error("같은 값은 병합할 수 없습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 병합 방향 선택 모달 표시
|
||||||
|
const confirmed = await new Promise<{ confirmed: boolean; oldValue: string; newValue: string }>((resolve) => {
|
||||||
|
const modalHtml = `
|
||||||
|
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
|
||||||
|
<div style="background: white; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%;">
|
||||||
|
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">코드 병합 방향 선택</h3>
|
||||||
|
<p style="margin: 0 0 24px 0; color: #666;">어느 코드로 병합하시겠습니까?</p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; margin-bottom: 24px;">
|
||||||
|
<button id="merge-option-1" style="flex: 1; padding: 16px; border: 2px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||||
|
<div style="font-weight: 600; margin-bottom: 4px;">${value1}</div>
|
||||||
|
<div style="font-size: 12px; color: #666;">← ${value2} 병합</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="merge-option-2" style="flex: 1; padding: 16px; border: 2px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||||
|
<div style="font-weight: 600; margin-bottom: 4px;">${value2}</div>
|
||||||
|
<div style="font-size: 12px; color: #666;">← ${value1} 병합</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||||
|
<button id="merge-cancel" style="padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: white; cursor: pointer;">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const modalContainer = document.createElement("div");
|
||||||
|
modalContainer.innerHTML = modalHtml;
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
|
const option1Btn = modalContainer.querySelector("#merge-option-1") as HTMLButtonElement;
|
||||||
|
const option2Btn = modalContainer.querySelector("#merge-option-2") as HTMLButtonElement;
|
||||||
|
const cancelBtn = modalContainer.querySelector("#merge-cancel") as HTMLButtonElement;
|
||||||
|
|
||||||
|
// 호버 효과
|
||||||
|
[option1Btn, option2Btn].forEach((btn) => {
|
||||||
|
btn.addEventListener("mouseenter", () => {
|
||||||
|
btn.style.borderColor = "#3b82f6";
|
||||||
|
btn.style.background = "#eff6ff";
|
||||||
|
});
|
||||||
|
btn.addEventListener("mouseleave", () => {
|
||||||
|
btn.style.borderColor = "#e5e7eb";
|
||||||
|
btn.style.background = "white";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
option1Btn.addEventListener("click", () => {
|
||||||
|
document.body.removeChild(modalContainer);
|
||||||
|
resolve({ confirmed: true, oldValue: value2, newValue: value1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
option2Btn.addEventListener("click", () => {
|
||||||
|
document.body.removeChild(modalContainer);
|
||||||
|
resolve({ confirmed: true, oldValue: value1, newValue: value2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener("click", () => {
|
||||||
|
document.body.removeChild(modalContainer);
|
||||||
|
resolve({ confirmed: false, oldValue: "", newValue: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed.confirmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { oldValue, newValue } = confirmed;
|
||||||
|
|
||||||
|
// 미리보기 표시 (옵션)
|
||||||
|
if (config.mergeShowPreview !== false) {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
const previewResponse = await apiClient.post("/code-merge/preview", {
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previewResponse.data.success) {
|
||||||
|
const preview = previewResponse.data.data;
|
||||||
|
const totalRows = preview.totalAffectedRows;
|
||||||
|
|
||||||
|
const confirmMerge = confirm(
|
||||||
|
`⚠️ 코드 병합 확인\n\n` +
|
||||||
|
`${oldValue} → ${newValue}\n\n` +
|
||||||
|
`영향받는 데이터:\n` +
|
||||||
|
`- 테이블 수: ${preview.preview.length}개\n` +
|
||||||
|
`- 총 행 수: ${totalRows}개\n\n` +
|
||||||
|
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
||||||
|
`계속하시겠습니까?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmMerge) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 병합 실행
|
||||||
|
toast.loading("코드 병합 중...", { duration: Infinity });
|
||||||
|
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
const response = await apiClient.post("/code-merge/merge-all-tables", {
|
||||||
|
columnName,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.dismiss();
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
const data = response.data.data;
|
||||||
|
toast.success(
|
||||||
|
`코드 병합 완료!\n` +
|
||||||
|
`${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 화면 새로고침
|
||||||
|
context.onRefresh?.();
|
||||||
|
context.onFlowRefresh?.();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || "코드 병합에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 코드 병합 실패:", error);
|
||||||
|
toast.dismiss();
|
||||||
|
toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 유효성 검사
|
* 폼 데이터 유효성 검사
|
||||||
*/
|
*/
|
||||||
|
|
@ -1981,4 +2234,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
||||||
barcodeFormat: "all",
|
barcodeFormat: "all",
|
||||||
barcodeAutoSubmit: false,
|
barcodeAutoSubmit: false,
|
||||||
},
|
},
|
||||||
|
code_merge: {
|
||||||
|
type: "code_merge",
|
||||||
|
mergeShowPreview: true,
|
||||||
|
confirmMessage: "선택한 두 항목을 병합하시겠습니까?",
|
||||||
|
successMessage: "코드 병합이 완료되었습니다.",
|
||||||
|
errorMessage: "코드 병합 중 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue