Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard
This commit is contained in:
commit
9429033e2c
|
|
@ -64,8 +64,8 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
|||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -224,8 +224,8 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
|||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,15 +2,15 @@
|
|||
* 채번 규칙 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 규칙 목록 조회
|
||||
router.get("/", authenticateToken, async (req: Request, res: Response) => {
|
||||
// 규칙 목록 조회 (전체)
|
||||
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
try {
|
||||
|
|
@ -22,8 +22,25 @@ router.get("/", authenticateToken, async (req: Request, res: Response) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 메뉴별 사용 가능한 규칙 조회
|
||||
router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴별 사용 가능한 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
menuObjid,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
|
|
@ -40,7 +57,7 @@ router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) =>
|
|||
});
|
||||
|
||||
// 규칙 생성
|
||||
router.post("/", authenticateToken, async (req: Request, res: Response) => {
|
||||
router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
|
@ -66,7 +83,7 @@ router.post("/", authenticateToken, async (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
// 규칙 수정
|
||||
router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||
router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
|
|
@ -84,7 +101,7 @@ router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) =>
|
|||
});
|
||||
|
||||
// 규칙 삭제
|
||||
router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
|
||||
router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
|
|
@ -100,14 +117,42 @@ router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response)
|
|||
}
|
||||
});
|
||||
|
||||
// 코드 생성
|
||||
router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => {
|
||||
// 코드 미리보기 (순번 증가 없음)
|
||||
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 코드 생성 (기존 호환성 유지, deprecated)
|
||||
router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode);
|
||||
return res.json({ success: true, data: { code: generatedCode } });
|
||||
return res.json({ success: true, data: { generatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
|
|
@ -115,7 +160,7 @@ router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Re
|
|||
});
|
||||
|
||||
// 시퀀스 초기화
|
||||
router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => {
|
||||
router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
deleteCompany, // 회사 삭제
|
||||
getUserLocale,
|
||||
setUserLocale,
|
||||
getTableSchema, // 테이블 스키마 조회
|
||||
} from "../controllers/adminController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -67,4 +68,7 @@ router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
|
|||
router.get("/user-locale", getUserLocale);
|
||||
router.post("/user-locale", setUserLocale);
|
||||
|
||||
// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용)
|
||||
router.get("/tables/:tableName/schema", getTableSchema);
|
||||
|
||||
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;
|
||||
|
||||
|
|
@ -26,6 +26,8 @@ interface NumberingRuleConfig {
|
|||
tableName?: string;
|
||||
columnName?: string;
|
||||
companyCode?: string;
|
||||
menuObjid?: number;
|
||||
scopeType?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
|
|
@ -33,7 +35,7 @@ interface NumberingRuleConfig {
|
|||
|
||||
class NumberingRuleService {
|
||||
/**
|
||||
* 규칙 목록 조회
|
||||
* 규칙 목록 조회 (전체)
|
||||
*/
|
||||
async getRuleList(companyCode: string): Promise<NumberingRuleConfig[]> {
|
||||
try {
|
||||
|
|
@ -78,11 +80,16 @@ class NumberingRuleService {
|
|||
ORDER BY part_order
|
||||
`;
|
||||
|
||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||
const partsResult = await pool.query(partsQuery, [
|
||||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
}
|
||||
|
||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode });
|
||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
||||
companyCode,
|
||||
});
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`);
|
||||
|
|
@ -90,10 +97,170 @@ class NumberingRuleService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 메뉴에서 사용 가능한 규칙 목록 조회
|
||||
*/
|
||||
async getAvailableRulesForMenu(
|
||||
companyCode: string,
|
||||
menuObjid?: number
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
try {
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// menuObjid가 없으면 global 규칙만 반환
|
||||
if (!menuObjid) {
|
||||
const query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND scope_type = 'global'
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode]);
|
||||
|
||||
// 파트 정보 추가
|
||||
for (const rule of result.rows) {
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
||||
const partsResult = await pool.query(partsQuery, [
|
||||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기)
|
||||
const menuHierarchyQuery = `
|
||||
WITH RECURSIVE menu_path AS (
|
||||
SELECT objid, objid_parent, menu_level
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT mi.objid, mi.objid_parent, mi.menu_level
|
||||
FROM menu_info mi
|
||||
INNER JOIN menu_path mp ON mi.objid = mp.objid_parent
|
||||
)
|
||||
SELECT objid, menu_level
|
||||
FROM menu_path
|
||||
WHERE menu_level = 2
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]);
|
||||
const level2MenuObjid =
|
||||
hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null;
|
||||
|
||||
// 사용 가능한 규칙 조회
|
||||
const query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND (
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = $2)
|
||||
)
|
||||
ORDER BY scope_type DESC, created_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, level2MenuObjid]);
|
||||
|
||||
// 파트 정보 추가
|
||||
for (const rule of result.rows) {
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
||||
const partsResult = await pool.query(partsQuery, [
|
||||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
}
|
||||
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
level2MenuObjid,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴별 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 규칙 조회
|
||||
*/
|
||||
async getRuleById(ruleId: string, companyCode: string): Promise<NumberingRuleConfig | null> {
|
||||
async getRuleById(
|
||||
ruleId: string,
|
||||
companyCode: string
|
||||
): Promise<NumberingRuleConfig | null> {
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
SELECT
|
||||
|
|
@ -106,7 +273,7 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_id AS "menuId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
|
|
@ -223,7 +390,10 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode });
|
||||
logger.info("채번 규칙 생성 완료", {
|
||||
ruleId: config.ruleId,
|
||||
companyCode,
|
||||
});
|
||||
return { ...ruleResult.rows[0], parts };
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
|
@ -364,9 +534,63 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 코드 생성
|
||||
* 코드 미리보기 (순번 증가 없음)
|
||||
*/
|
||||
async generateCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
async previewCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
const parts = rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date": {
|
||||
// 날짜 (다양한 날짜 형식)
|
||||
return this.formatDate(
|
||||
new Date(),
|
||||
autoConfig.dateFormat || "YYYYMMDD"
|
||||
);
|
||||
}
|
||||
|
||||
case "text": {
|
||||
// 텍스트 (고정 문자열)
|
||||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const previewCode = parts.join(rule.separator || "");
|
||||
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode });
|
||||
return previewCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
*/
|
||||
async allocateCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
|
|
@ -386,37 +610,44 @@ class NumberingRuleService {
|
|||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "prefix":
|
||||
return autoConfig.prefix || "PREFIX";
|
||||
|
||||
case "sequence": {
|
||||
// 순번 (자동 증가 숫자)
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date":
|
||||
return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD");
|
||||
|
||||
case "year": {
|
||||
const format = autoConfig.dateFormat || "YYYY";
|
||||
const year = new Date().getFullYear();
|
||||
return format === "YY" ? String(year).slice(-2) : String(year);
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "month":
|
||||
return String(new Date().getMonth() + 1).padStart(2, "0");
|
||||
case "date": {
|
||||
// 날짜 (다양한 날짜 형식)
|
||||
return this.formatDate(
|
||||
new Date(),
|
||||
autoConfig.dateFormat || "YYYYMMDD"
|
||||
);
|
||||
}
|
||||
|
||||
case "custom":
|
||||
return autoConfig.value || "CUSTOM";
|
||||
case "text": {
|
||||
// 텍스트 (고정 문자열)
|
||||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const generatedCode = parts.join(rule.separator || "");
|
||||
const allocatedCode = parts.join(rule.separator || "");
|
||||
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
// 순번이 있는 경우에만 증가
|
||||
const hasSequence = rule.parts.some(
|
||||
(p: any) => p.partType === "sequence"
|
||||
);
|
||||
if (hasSequence) {
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
|
|
@ -425,30 +656,52 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("코드 생성 완료", { ruleId, generatedCode });
|
||||
return generatedCode;
|
||||
logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode });
|
||||
return allocatedCode;
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
logger.error("코드 할당 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 기존 generateCode는 allocateCode를 사용하세요
|
||||
*/
|
||||
async generateCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
logger.warn(
|
||||
"generateCode는 deprecated 되었습니다. previewCode 또는 allocateCode를 사용하세요"
|
||||
);
|
||||
return this.allocateCode(ruleId, companyCode);
|
||||
}
|
||||
|
||||
private formatDate(date: Date, format: string): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
case "YYYY":
|
||||
return String(year);
|
||||
case "YY":
|
||||
return String(year).slice(-2);
|
||||
case "YYYYMM":
|
||||
return `${year}${month}`;
|
||||
case "YYMM":
|
||||
return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD":
|
||||
return `${year}${month}${day}`;
|
||||
case "YYMMDD":
|
||||
return `${String(year).slice(-2)}${month}${day}`;
|
||||
default:
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -40,6 +40,11 @@ export default function ScreenViewPage() {
|
|||
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
||||
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 [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
|
||||
|
|
@ -425,9 +430,16 @@ export default function ScreenViewPage() {
|
|||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
columnOrder={tableColumnOrder}
|
||||
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder });
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
setTableColumnOrder(columnOrder);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
|
|
@ -479,9 +491,16 @@ export default function ScreenViewPage() {
|
|||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
columnOrder={tableColumnOrder}
|
||||
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder });
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
setTableColumnOrder(columnOrder);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
|
|
@ -613,8 +632,14 @@ export default function ScreenViewPage() {
|
|||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
columnOrder={tableColumnOrder}
|
||||
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
setTableColumnOrder(columnOrder);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -109,9 +109,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts
|
||||
.filter((part) => part.id !== partId)
|
||||
.map((part, index) => ({ ...part, order: index + 1 })),
|
||||
parts: prev.parts.filter((part) => part.id !== partId).map((part, index) => ({ ...part, order: index + 1 })),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +130,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
setLoading(true);
|
||||
try {
|
||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||
|
||||
|
||||
let response;
|
||||
if (existing) {
|
||||
response = await updateNumberingRule(currentRule.ruleId, currentRule);
|
||||
|
|
@ -170,29 +168,32 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSavedRule = useCallback(async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRule(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
||||
if (selectedRuleId === ruleId) {
|
||||
setSelectedRuleId(null);
|
||||
setCurrentRule(null);
|
||||
const handleDeleteSavedRule = useCallback(
|
||||
async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRule(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
||||
if (selectedRuleId === ruleId) {
|
||||
setSelectedRuleId(null);
|
||||
setCurrentRule(null);
|
||||
}
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
}
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
} catch (error: any) {
|
||||
toast.error(`삭제 실패: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`삭제 실패: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedRuleId]);
|
||||
},
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const handleNewRule = useCallback(() => {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
|
|
@ -207,7 +208,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(newRule);
|
||||
|
||||
|
||||
toast.success("새 규칙이 생성되었습니다");
|
||||
}, []);
|
||||
|
||||
|
|
@ -228,35 +229,29 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingLeftTitle(true)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 규칙 생성
|
||||
<Plus className="mr-2 h-4 w-4" />새 규칙 생성
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">로딩 중...</p>
|
||||
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
||||
</div>
|
||||
) : savedRules.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground">저장된 규칙이 없습니다</p>
|
||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-xs">저장된 규칙이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
savedRules.map((rule) => (
|
||||
<Card
|
||||
key={rule.ruleId}
|
||||
className={`cursor-pointer border-border transition-colors hover:bg-accent ${
|
||||
className={`border-border hover:bg-accent cursor-pointer transition-colors ${
|
||||
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||
}`}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
|
|
@ -265,9 +260,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
규칙 {rule.parts.length}개
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">규칙 {rule.parts.length}개</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -278,7 +271,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
handleDeleteSavedRule(rule.ruleId);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
<Trash2 className="text-destructive h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -292,19 +285,15 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-full w-px bg-border"></div>
|
||||
<div className="bg-border h-full w-px"></div>
|
||||
|
||||
{/* 우측: 편집 영역 */}
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
{!currentRule ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">
|
||||
규칙을 선택해주세요
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
좌측에서 규칙을 선택하거나 새로 생성하세요
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">규칙을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 규칙을 선택하거나 새로 생성하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -322,12 +311,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingRightTitle(true)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingRightTitle(true)}>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -336,9 +320,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<Label className="text-sm font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) =>
|
||||
setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
|
||||
className="h-9"
|
||||
placeholder="예: 프로젝트 코드"
|
||||
/>
|
||||
|
|
@ -348,9 +330,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<Label className="text-sm font-medium">적용 범위</Label>
|
||||
<Select
|
||||
value={currentRule.scopeType || "global"}
|
||||
onValueChange={(value: "global" | "menu") =>
|
||||
setCurrentRule((prev) => ({ ...prev!, scopeType: value }))
|
||||
}
|
||||
onValueChange={(value: "global" | "menu") => setCurrentRule((prev) => ({ ...prev!, scopeType: value }))}
|
||||
disabled={isPreview}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
|
|
@ -361,9 +341,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<SelectItem value="menu">메뉴별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
{currentRule.scopeType === "menu"
|
||||
? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다"
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
{currentRule.scopeType === "menu"
|
||||
? "⚠️ 현재 화면이 속한 2레벨 메뉴와 그 하위 메뉴(3레벨 이상)에서만 사용됩니다. 형제 메뉴와 구분하여 채번 규칙을 관리할 때 유용합니다."
|
||||
: "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -380,16 +360,14 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">코드 구성</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{currentRule.parts.length}/{maxRules}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentRule.parts.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
규칙을 추가하여 코드를 구성하세요
|
||||
</p>
|
||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
|
|
@ -416,11 +394,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<Plus className="mr-2 h-4 w-4" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isPreview || loading}
|
||||
className="h-9 flex-1 text-sm"
|
||||
>
|
||||
<Button onClick={handleSave} disabled={isPreview || loading} className="h-9 flex-1 text-sm">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
|
||||
// 자동값 생성 함수
|
||||
const generateAutoValue = useCallback(
|
||||
(autoValueType: string): string => {
|
||||
async (autoValueType: string, ruleId?: string): Promise<string> => {
|
||||
const now = new Date();
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
|
|
@ -99,6 +99,20 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
return crypto.randomUUID();
|
||||
case "sequence":
|
||||
return `SEQ_${Date.now()}`;
|
||||
case "numbering_rule":
|
||||
// 채번 규칙 사용
|
||||
if (ruleId) {
|
||||
try {
|
||||
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await generateNumberingCode(ruleId);
|
||||
if (response.success && response.data) {
|
||||
return response.data.generatedCode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 코드 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -129,24 +143,32 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
// 자동값 설정
|
||||
useEffect(() => {
|
||||
const widgetComponents = allComponents.filter((c) => c.type === "widget") as WidgetComponent[];
|
||||
const autoValueUpdates: Record<string, any> = {};
|
||||
|
||||
const loadAutoValues = async () => {
|
||||
const autoValueUpdates: Record<string, any> = {};
|
||||
|
||||
for (const widget of widgetComponents) {
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
const currentValue = finalFormData[fieldName];
|
||||
for (const widget of widgetComponents) {
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
const currentValue = finalFormData[fieldName];
|
||||
|
||||
// 자동값이 설정되어 있고 현재 값이 없는 경우
|
||||
if (widget.inputType === "auto" && widget.autoValueType && !currentValue) {
|
||||
const autoValue = generateAutoValue(widget.autoValueType);
|
||||
if (autoValue) {
|
||||
autoValueUpdates[fieldName] = autoValue;
|
||||
// 자동값이 설정되어 있고 현재 값이 없는 경우
|
||||
if (widget.inputType === "auto" && widget.autoValueType && !currentValue) {
|
||||
const autoValue = await generateAutoValue(
|
||||
widget.autoValueType,
|
||||
(widget as any).numberingRuleId // 채번 규칙 ID
|
||||
);
|
||||
if (autoValue) {
|
||||
autoValueUpdates[fieldName] = autoValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(autoValueUpdates).length > 0) {
|
||||
setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates }));
|
||||
}
|
||||
if (Object.keys(autoValueUpdates).length > 0) {
|
||||
setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates }));
|
||||
}
|
||||
};
|
||||
|
||||
loadAutoValues();
|
||||
}, [allComponents, finalFormData, generateAutoValue]);
|
||||
|
||||
// 향상된 저장 핸들러
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
: null;
|
||||
|
||||
// 자동값 생성 함수
|
||||
const generateAutoValue = useCallback((autoValueType: string): string => {
|
||||
const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise<string> => {
|
||||
const now = new Date();
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
|
|
@ -152,6 +152,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
return crypto.randomUUID();
|
||||
case "sequence":
|
||||
return `SEQ_${Date.now()}`;
|
||||
case "numbering_rule":
|
||||
// 채번 규칙 사용
|
||||
if (ruleId) {
|
||||
try {
|
||||
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await generateNumberingCode(ruleId);
|
||||
if (response.success && response.data) {
|
||||
return response.data.generatedCode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 코드 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ interface RealtimePreviewProps {
|
|||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||
// 플로우 선택 데이터 전달용
|
||||
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,
|
||||
children,
|
||||
onFlowSelectedDataChange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...restProps
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
|
|
@ -545,7 +552,13 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ interface RealtimePreviewProps {
|
|||
// 폼 데이터 관련 props
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// 테이블 정렬 정보
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
}
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
|
|
@ -109,6 +114,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onFlowSelectedDataChange,
|
||||
refreshKey,
|
||||
onRefresh,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
columnOrder,
|
||||
flowRefreshKey,
|
||||
onFlowRefresh,
|
||||
formData,
|
||||
|
|
@ -404,6 +412,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onFlowRefresh={onFlowRefresh}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
columnOrder={columnOrder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -610,16 +610,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
|
||||
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
|
||||
|
||||
// Y 좌표는 20px 단위로 스냅
|
||||
// Y 좌표는 10px 단위로 스냅
|
||||
const effectiveY = newComp.position.y - padding;
|
||||
const rowIndex = Math.round(effectiveY / 20);
|
||||
const snappedY = padding + rowIndex * 20;
|
||||
const rowIndex = Math.round(effectiveY / 10);
|
||||
const snappedY = padding + rowIndex * 10;
|
||||
|
||||
// 크기도 외부 격자와 동일하게 스냅
|
||||
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
|
||||
const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth));
|
||||
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
|
||||
const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20);
|
||||
const snappedHeight = Math.max(10, Math.round(newComp.size.height / 10) * 10);
|
||||
|
||||
newComp.position = {
|
||||
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
|
||||
|
|
|
|||
|
|
@ -270,6 +270,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -838,6 +839,53 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</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">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
|
|
|
|||
|
|
@ -961,27 +961,27 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
최소 높이 (40px 단위)
|
||||
최소 높이 (10px 단위)
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={Math.round((localInputs.height || 40) / 40)}
|
||||
max="100"
|
||||
value={Math.round((localInputs.height || 10) / 10)}
|
||||
onChange={(e) => {
|
||||
const rows = Math.max(1, Math.min(20, Number(e.target.value)));
|
||||
const newHeight = rows * 40;
|
||||
const units = Math.max(1, Math.min(100, Number(e.target.value)));
|
||||
const newHeight = units * 10;
|
||||
setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() }));
|
||||
onUpdateProperty("size.height", newHeight);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">행 = {localInputs.height || 40}px</span>
|
||||
<span className="text-sm text-gray-500">단위 = {localInputs.height || 10}px</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행) - 내부 콘텐츠에 맞춰 늘어남
|
||||
1단위 = 10px (현재 {Math.round((localInputs.height || 10) / 10)}단위) - 내부 콘텐츠에 맞춰 늘어남
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -364,11 +364,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
value={selectedComponent.size?.height || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
|
||||
const roundedValue = Math.max(10, Math.round(value / 10) * 10);
|
||||
handleUpdate("size.height", roundedValue);
|
||||
}}
|
||||
step={40}
|
||||
placeholder="40"
|
||||
step={10}
|
||||
placeholder="10"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TextTypeConfig } from "@/types/screen";
|
||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
interface TextTypeConfigPanelProps {
|
||||
config: TextTypeConfig;
|
||||
|
|
@ -26,9 +28,14 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
autoInput: false,
|
||||
autoValueType: "current_datetime" as const,
|
||||
customValue: "",
|
||||
numberingRuleId: "",
|
||||
...config,
|
||||
};
|
||||
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
||||
// 로컬 상태로 실시간 입력 관리
|
||||
const [localValues, setLocalValues] = useState({
|
||||
minLength: safeConfig.minLength?.toString() || "",
|
||||
|
|
@ -41,8 +48,33 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
autoInput: safeConfig.autoInput,
|
||||
autoValueType: safeConfig.autoValueType,
|
||||
customValue: safeConfig.customValue,
|
||||
numberingRuleId: safeConfig.numberingRuleId,
|
||||
});
|
||||
|
||||
// 채번 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
// TODO: 현재 메뉴 objid를 화면 정보에서 가져와야 함
|
||||
// 지금은 menuObjid 없이 호출 (global 규칙만 조회)
|
||||
const response = await getAvailableNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
// autoValueType이 numbering_rule일 때만 로드
|
||||
if (localValues.autoValueType === "numbering_rule") {
|
||||
loadRules();
|
||||
}
|
||||
}, [localValues.autoValueType]);
|
||||
|
||||
// config가 변경될 때 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalValues({
|
||||
|
|
@ -56,6 +88,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
autoInput: safeConfig.autoInput,
|
||||
autoValueType: safeConfig.autoValueType,
|
||||
customValue: safeConfig.customValue,
|
||||
numberingRuleId: safeConfig.numberingRuleId,
|
||||
});
|
||||
}, [
|
||||
safeConfig.minLength,
|
||||
|
|
@ -68,6 +101,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
safeConfig.autoInput,
|
||||
safeConfig.autoValueType,
|
||||
safeConfig.customValue,
|
||||
safeConfig.numberingRuleId,
|
||||
]);
|
||||
|
||||
const updateConfig = (key: keyof TextTypeConfig, value: any) => {
|
||||
|
|
@ -90,16 +124,10 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
autoInput: key === "autoInput" ? value : localValues.autoInput,
|
||||
autoValueType: key === "autoValueType" ? value : localValues.autoValueType,
|
||||
customValue: key === "customValue" ? value : localValues.customValue,
|
||||
numberingRuleId: key === "numberingRuleId" ? value : localValues.numberingRuleId,
|
||||
};
|
||||
|
||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||
// console.log("📝 TextTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// });
|
||||
|
||||
setTimeout(() => {
|
||||
onConfigChange(newConfig);
|
||||
|
|
@ -236,11 +264,45 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
<SelectItem value="current_user">현재 사용자</SelectItem>
|
||||
<SelectItem value="uuid">고유 ID (UUID)</SelectItem>
|
||||
<SelectItem value="sequence">순번</SelectItem>
|
||||
<SelectItem value="numbering_rule">채번 규칙</SelectItem>
|
||||
<SelectItem value="custom">사용자 정의</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{localValues.autoValueType === "numbering_rule" && (
|
||||
<div>
|
||||
<Label htmlFor="numberingRuleId" className="text-sm font-medium">
|
||||
채번 규칙 선택 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={localValues.numberingRuleId}
|
||||
onValueChange={(value) => updateConfig("numberingRuleId", value)}
|
||||
disabled={loadingRules}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.length === 0 ? (
|
||||
<SelectItem value="no-rules" disabled>
|
||||
사용 가능한 규칙이 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.ruleId} value={rule.ruleId}>
|
||||
{rule.ruleName} ({rule.ruleId})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localValues.autoValueType === "custom" && (
|
||||
<div>
|
||||
<Label htmlFor="customValue" className="text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,25 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴별 사용 가능한 채번 규칙 조회
|
||||
* @param menuObjid 현재 메뉴의 objid (선택)
|
||||
* @returns 사용 가능한 채번 규칙 목록
|
||||
*/
|
||||
export async function getAvailableNumberingRules(
|
||||
menuObjid?: number
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const url = menuObjid
|
||||
? `/numbering-rules/available/${menuObjid}`
|
||||
: "/numbering-rules/available";
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "사용 가능한 규칙 조회 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/${ruleId}`);
|
||||
|
|
@ -62,15 +81,49 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
|
|||
}
|
||||
}
|
||||
|
||||
export async function generateCode(ruleId: string): Promise<ApiResponse<{ code: string }>> {
|
||||
/**
|
||||
* 코드 미리보기 (순번 증가 없음)
|
||||
* 화면 표시용으로 사용
|
||||
*/
|
||||
export async function previewNumberingCode(
|
||||
ruleId: string
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`);
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "코드 생성 실패" };
|
||||
return { success: false, error: error.message || "코드 미리보기 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
* 실제 저장할 때만 호출
|
||||
*/
|
||||
export async function allocateNumberingCode(
|
||||
ruleId: string
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "코드 할당 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 기존 generateNumberingCode는 previewNumberingCode를 사용하세요
|
||||
*/
|
||||
export async function generateNumberingCode(
|
||||
ruleId: string
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장");
|
||||
return previewNumberingCode(ruleId);
|
||||
}
|
||||
|
||||
// 하위 호환성을 위한 별칭
|
||||
export const generateCode = generateNumberingCode;
|
||||
|
||||
export async function resetSequence(ruleId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`);
|
||||
|
|
@ -79,3 +132,4 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
|
|||
return { success: false, error: error.message || "시퀀스 초기화 실패" };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
if (!preloadCommonCodes) return;
|
||||
|
||||
console.log("🚀 공통 코드 프리로딩 시작");
|
||||
// console.log("🚀 공통 코드 프리로딩 시작");
|
||||
|
||||
// 현재 테이블의 코드 카테고리와 공통 카테고리 합치기
|
||||
const allCategories = [...new Set([...codeCategories, ...commonCodeCategories])];
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ export interface ComponentRenderer {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: 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[];
|
||||
flowSelectedStepId?: number | null;
|
||||
|
|
@ -101,7 +104,11 @@ export interface DynamicComponentRendererProps {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: 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[];
|
||||
flowSelectedStepId?: number | null;
|
||||
|
|
@ -191,6 +198,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
selectedRows,
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
onFlowSelectedDataChange,
|
||||
|
|
@ -278,6 +287,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
selectedRows,
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
// 테이블 정렬 정보 전달
|
||||
sortBy,
|
||||
sortOrder,
|
||||
// 플로우 선택된 데이터 정보 전달
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
|||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
isDesignMode: true, // 디자인 모드임을 명시
|
||||
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
|
||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
|
|
@ -74,6 +79,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
onRefresh,
|
||||
onClose,
|
||||
onFlowRefresh,
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
columnOrder, // 🆕 컬럼 순서
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
flowSelectedData,
|
||||
|
|
@ -405,6 +413,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 테이블 선택된 행 정보 추가
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
// 테이블 정렬 정보 추가
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
columnOrder, // 🆕 컬럼 순서
|
||||
// 플로우 선택된 데이터 정보 추가
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
|
|
|
|||
|
|
@ -8,41 +8,53 @@ import { cn } from "@/lib/utils";
|
|||
import { ColumnConfig } from "./types";
|
||||
|
||||
interface SingleTableWithStickyProps {
|
||||
visibleColumns: ColumnConfig[];
|
||||
visibleColumns?: ColumnConfig[];
|
||||
columns?: ColumnConfig[];
|
||||
data: Record<string, any>[];
|
||||
columnLabels: Record<string, string>;
|
||||
sortColumn: string | null;
|
||||
sortDirection: "asc" | "desc";
|
||||
tableConfig: any;
|
||||
isDesignMode: boolean;
|
||||
isAllSelected: boolean;
|
||||
handleSort: (columnName: string) => void;
|
||||
handleSelectAll: (checked: boolean) => void;
|
||||
handleRowClick: (row: any) => void;
|
||||
renderCheckboxCell: (row: any, index: number) => React.ReactNode;
|
||||
tableConfig?: any;
|
||||
isDesignMode?: boolean;
|
||||
isAllSelected?: boolean;
|
||||
handleSort?: (columnName: string) => void;
|
||||
onSort?: (columnName: string) => void;
|
||||
handleSelectAll?: (checked: boolean) => void;
|
||||
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;
|
||||
getColumnWidth: (column: ColumnConfig) => number;
|
||||
containerWidth?: string; // 컨테이너 너비 설정
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
visibleColumns,
|
||||
columns,
|
||||
data,
|
||||
columnLabels,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
tableConfig,
|
||||
isDesignMode,
|
||||
isAllSelected,
|
||||
isDesignMode = false,
|
||||
isAllSelected = false,
|
||||
handleSort,
|
||||
onSort,
|
||||
handleSelectAll,
|
||||
handleRowClick,
|
||||
renderCheckboxCell,
|
||||
renderCheckboxHeader,
|
||||
formatCellValue,
|
||||
getColumnWidth,
|
||||
containerWidth,
|
||||
loading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const checkboxConfig = tableConfig.checkbox || {};
|
||||
const checkboxConfig = tableConfig?.checkbox || {};
|
||||
const actualColumns = visibleColumns || columns || [];
|
||||
const sortHandler = onSort || handleSort || (() => {});
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -71,15 +83,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = visibleColumns
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.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 rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
|
|
@ -115,7 +127,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
|
|
|
|||
|
|
@ -138,7 +138,8 @@ export interface TableListComponentProps {
|
|||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
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;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
|
@ -163,6 +164,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onConfigChange,
|
||||
refreshKey,
|
||||
tableName,
|
||||
userId,
|
||||
}) => {
|
||||
// ========================================
|
||||
// 설정 및 스타일
|
||||
|
|
@ -178,18 +180,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
let finalSelectedTable =
|
||||
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 속성 추출 시도
|
||||
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
||||
|
|
@ -200,12 +191,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
tableConfig.selectedTable = finalSelectedTable;
|
||||
|
||||
console.log(
|
||||
"✅ 최종 tableConfig.selectedTable:",
|
||||
tableConfig.selectedTable,
|
||||
"타입:",
|
||||
typeof tableConfig.selectedTable,
|
||||
);
|
||||
// 디버그 로그 제거 (성능 최적화)
|
||||
|
||||
const buttonColor = component.style?.labelColor || "#212121";
|
||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||
|
|
@ -262,6 +248,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
||||
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 [isAllSelected, setIsAllSelected] = useState(false);
|
||||
const hasInitializedWidths = useRef(false);
|
||||
|
|
@ -390,10 +380,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 테이블명 확인 로그
|
||||
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
||||
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
||||
console.log("🔍 전체 tableConfig:", tableConfig);
|
||||
// 테이블명 확인 로그 (개발 시에만)
|
||||
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
||||
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
||||
// console.log("🔍 전체 tableConfig:", tableConfig);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -488,11 +478,43 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
|
||||
|
||||
let newSortColumn = column;
|
||||
let newSortDirection: "asc" | "desc" = "asc";
|
||||
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
setSortDirection(newSortDirection);
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
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)));
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({
|
||||
|
|
@ -551,7 +573,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setIsAllSelected(true);
|
||||
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({
|
||||
|
|
@ -564,7 +586,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setIsAllSelected(false);
|
||||
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange([], []);
|
||||
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||||
|
|
@ -588,6 +610,58 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
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) => {
|
||||
e.stopPropagation();
|
||||
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));
|
||||
}, [tableConfig.columns, tableConfig.checkbox]);
|
||||
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
|
||||
|
||||
const getColumnWidth = (column: ColumnConfig) => {
|
||||
if (column.columnName === "__checkbox__") return 50;
|
||||
|
|
@ -1169,7 +1261,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10" style={{ flex: 1, overflow: "hidden" }}>
|
||||
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px`, flex: 1, overflow: "hidden" }}>
|
||||
<SingleTableWithSticky
|
||||
data={data}
|
||||
columns={visibleColumns}
|
||||
|
|
@ -1178,6 +1270,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
tableConfig={tableConfig}
|
||||
isDesignMode={isDesignMode}
|
||||
isAllSelected={isAllSelected}
|
||||
handleSelectAll={handleSelectAll}
|
||||
handleRowClick={handleRowClick}
|
||||
columnLabels={columnLabels}
|
||||
renderCheckboxHeader={renderCheckboxHeader}
|
||||
renderCheckboxCell={renderCheckboxCell}
|
||||
|
|
@ -1263,7 +1360,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
)}
|
||||
|
||||
{/* 테이블 컨테이너 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full mt-10">
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden w-full max-w-full"
|
||||
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px` }}
|
||||
>
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
||||
|
|
@ -1289,10 +1389,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<th
|
||||
key={column.columnName}
|
||||
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(
|
||||
"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.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={{
|
||||
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
||||
|
|
@ -1303,7 +1423,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}}
|
||||
onClick={() => {
|
||||
if (isResizing.current) return;
|
||||
if (column.sortable) handleSort(column.columnName);
|
||||
if (column.sortable !== false && column.columnName !== "__checkbox__") {
|
||||
handleSort(column.columnName);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
|
|
@ -1311,7 +1433,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
{column.sortable !== false && sortColumn === column.columnName && (
|
||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1105,6 +1105,41 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 간격 설정 */}
|
||||
{config.filter?.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">필터 간격</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="filterBottomSpacing" className="text-xs">
|
||||
필터와 리스트 사이 간격 (px)
|
||||
</Label>
|
||||
<Input
|
||||
id="filterBottomSpacing"
|
||||
type="number"
|
||||
value={config.filter?.bottomSpacing ?? 40}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(0, Math.min(200, parseInt(e.target.value) || 40));
|
||||
handleChange("filter", {
|
||||
...config.filter,
|
||||
bottomSpacing: value,
|
||||
});
|
||||
}}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
placeholder="40"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
기본값: 40px (0-200px 범위, 10px 단위 권장)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export const TableListDefinition = createComponentDefinition({
|
|||
filter: {
|
||||
enabled: true,
|
||||
filters: [], // 사용자가 설정할 필터 목록
|
||||
bottomSpacing: 40, // 필터와 리스트 사이 간격 (px)
|
||||
},
|
||||
|
||||
// 액션 설정
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type AutoGenerationType =
|
|||
| "current_user" // 현재 사용자 ID
|
||||
| "current_time" // 현재 시간
|
||||
| "sequence" // 시퀀스 번호
|
||||
| "numbering_rule" // 채번 규칙
|
||||
| "random_string" // 랜덤 문자열
|
||||
| "random_number" // 랜덤 숫자
|
||||
| "company_code" // 회사 코드
|
||||
|
|
@ -37,6 +38,7 @@ export interface AutoGenerationConfig {
|
|||
suffix?: string; // 접미사
|
||||
format?: string; // 시간 형식 (current_time용)
|
||||
startValue?: number; // 시퀀스 시작값
|
||||
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +116,8 @@ export interface FilterConfig {
|
|||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}>;
|
||||
// 필터와 리스트 사이 간격 (px 단위, 기본: 40)
|
||||
bottomSpacing?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
|
||||
// 자동생성된 값 상태
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||
|
||||
// API 호출 중복 방지를 위한 ref
|
||||
const isGeneratingRef = React.useRef(false);
|
||||
const hasGeneratedRef = React.useRef(false);
|
||||
|
||||
// 테스트용: 컴포넌트 라벨에 "test"가 포함되면 강제로 UUID 자동생성 활성화
|
||||
const testAutoGeneration = component.label?.toLowerCase().includes("test")
|
||||
|
|
@ -79,32 +83,96 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// autoGeneratedValue,
|
||||
// });
|
||||
|
||||
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
|
||||
// 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행)
|
||||
useEffect(() => {
|
||||
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
|
||||
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
|
||||
const currentFormValue = formData?.[component.columnName];
|
||||
const currentComponentValue = component.value;
|
||||
|
||||
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||
const generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
|
||||
|
||||
if (generatedValue) {
|
||||
setAutoGeneratedValue(generatedValue);
|
||||
|
||||
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, generatedValue);
|
||||
}
|
||||
}
|
||||
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {
|
||||
// 디자인 모드에서도 미리보기용 자동생성 값 표시
|
||||
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
|
||||
setAutoGeneratedValue(previewValue);
|
||||
const generateAutoValue = async () => {
|
||||
// 이미 생성 중이거나 생성 완료된 경우 중복 실행 방지
|
||||
if (isGeneratingRef.current || hasGeneratedRef.current) {
|
||||
console.log("⏭️ 중복 실행 방지:", {
|
||||
isGenerating: isGeneratingRef.current,
|
||||
hasGenerated: hasGeneratedRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [testAutoGeneration, isInteractive, component.columnName, component.value, formData, onFormDataChange]);
|
||||
|
||||
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
|
||||
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
|
||||
const currentFormValue = formData?.[component.columnName];
|
||||
const currentComponentValue = component.value;
|
||||
|
||||
console.log("🔧 TextInput 자동생성 체크:", {
|
||||
componentId: component.id,
|
||||
columnName: component.columnName,
|
||||
autoGenType: testAutoGeneration.type,
|
||||
ruleId: testAutoGeneration.options?.numberingRuleId,
|
||||
currentFormValue,
|
||||
currentComponentValue,
|
||||
autoGeneratedValue,
|
||||
isInteractive,
|
||||
});
|
||||
|
||||
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||
isGeneratingRef.current = true; // 생성 시작 플래그
|
||||
let generatedValue: string | null = null;
|
||||
|
||||
// 채번 규칙은 비동기로 처리
|
||||
if (testAutoGeneration.type === "numbering_rule") {
|
||||
const ruleId = testAutoGeneration.options?.numberingRuleId;
|
||||
if (ruleId) {
|
||||
try {
|
||||
console.log("🚀 채번 규칙 API 호출 시작:", ruleId);
|
||||
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await generateNumberingCode(ruleId);
|
||||
console.log("✅ 채번 규칙 API 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
generatedValue = response.data.generatedCode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 채번 규칙 코드 생성 실패:", error);
|
||||
} finally {
|
||||
isGeneratingRef.current = false; // 생성 완료
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 채번 규칙 ID가 없습니다");
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// 기타 타입은 동기로 처리
|
||||
generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
|
||||
if (generatedValue) {
|
||||
console.log("✅ 자동생성 값 설정:", generatedValue);
|
||||
setAutoGeneratedValue(generatedValue);
|
||||
hasGeneratedRef.current = true; // 생성 완료 플래그
|
||||
|
||||
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
console.log("📝 formData 업데이트:", component.columnName, generatedValue);
|
||||
onFormDataChange(component.columnName, generatedValue);
|
||||
|
||||
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
|
||||
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
onFormDataChange(ruleIdKey, testAutoGeneration.options.numberingRuleId);
|
||||
console.log("📝 채번 규칙 ID 저장:", ruleIdKey, testAutoGeneration.options.numberingRuleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {
|
||||
// 디자인 모드에서도 미리보기용 자동생성 값 표시
|
||||
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
|
||||
console.log("👁️ 미리보기 값 설정:", previewValue);
|
||||
setAutoGeneratedValue(previewValue);
|
||||
hasGeneratedRef.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generateAutoValue();
|
||||
}, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive]);
|
||||
|
||||
// 실제 화면에서 숨김 처리된 컴포넌트는 렌더링하지 않음
|
||||
if (isHidden && !isDesignMode) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -8,6 +8,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { TextInputConfig } from "./types";
|
||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface TextInputConfigPanelProps {
|
||||
config: TextInputConfig;
|
||||
|
|
@ -19,6 +21,32 @@ export interface TextInputConfigPanelProps {
|
|||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
||||
// 채번 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const response = await getAvailableNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
// autoGeneration.type이 numbering_rule일 때만 로드
|
||||
if (config.autoGeneration?.type === "numbering_rule") {
|
||||
loadRules();
|
||||
}
|
||||
}, [config.autoGeneration?.type]);
|
||||
|
||||
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
|
@ -100,6 +128,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
|||
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||
<SelectItem value="numbering_rule">채번 규칙</SelectItem>
|
||||
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||
|
|
@ -113,6 +142,49 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
|||
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 규칙 선택 */}
|
||||
{config.autoGeneration?.type === "numbering_rule" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numberingRuleId">
|
||||
채번 규칙 선택 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.options?.numberingRuleId || ""}
|
||||
onValueChange={(value) => {
|
||||
const currentConfig = config.autoGeneration!;
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
options: {
|
||||
...currentConfig.options,
|
||||
numberingRuleId: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={loadingRules}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.length === 0 ? (
|
||||
<SelectItem value="no-rules" disabled>
|
||||
사용 가능한 규칙이 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.ruleId} value={rule.ruleId}>
|
||||
{rule.ruleName} ({rule.ruleId})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,11 +207,12 @@ export class AutoGenerationUtils {
|
|||
* 자동생성 타입별 설명 가져오기
|
||||
*/
|
||||
static getTypeDescription(type: AutoGenerationType): string {
|
||||
const descriptions: Record<AutoGenerationType, string> = {
|
||||
const descriptions: Record<string, string> = {
|
||||
uuid: "고유 식별자 (UUID) 생성",
|
||||
current_user: "현재 로그인한 사용자 ID",
|
||||
current_time: "현재 날짜/시간",
|
||||
sequence: "순차적 번호 생성",
|
||||
numbering_rule: "채번 규칙 기반 코드 생성",
|
||||
random_string: "랜덤 문자열 생성",
|
||||
random_number: "랜덤 숫자 생성",
|
||||
company_code: "현재 회사 코드",
|
||||
|
|
@ -246,6 +247,9 @@ export class AutoGenerationUtils {
|
|||
case "sequence":
|
||||
return `${options.prefix || ""}1${options.suffix || ""}`;
|
||||
|
||||
case "numbering_rule":
|
||||
return "CODE-20251104-001"; // 채번 규칙 미리보기
|
||||
|
||||
case "random_string":
|
||||
return `${options.prefix || ""}ABC123${options.suffix || ""}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export type ButtonActionType =
|
|||
| "view_table_history" // 테이블 이력 보기
|
||||
| "excel_download" // 엑셀 다운로드
|
||||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan"; // 바코드 스캔
|
||||
| "barcode_scan" // 바코드 스캔
|
||||
| "code_merge"; // 코드 병합
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
|
|
@ -73,6 +74,10 @@ export interface ButtonActionConfig {
|
|||
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
||||
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
|
||||
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
|
||||
|
||||
// 코드 병합 관련
|
||||
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -101,8 +106,11 @@ export interface ButtonActionContext {
|
|||
|
||||
// 제어 실행을 위한 추가 정보
|
||||
buttonId?: string;
|
||||
userId?: string;
|
||||
companyCode?: string;
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string; // 정렬 컬럼명
|
||||
sortOrder?: "asc" | "desc"; // 정렬 방향
|
||||
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -147,6 +155,9 @@ export class ButtonActionExecutor {
|
|||
case "barcode_scan":
|
||||
return await this.handleBarcodeScan(config, context);
|
||||
|
||||
case "code_merge":
|
||||
return await this.handleCodeMerge(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -246,6 +257,50 @@ export class ButtonActionExecutor {
|
|||
companyCodeValue, // ✅ 최종 회사 코드 값
|
||||
});
|
||||
|
||||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||
console.log("🔍 채번 규칙 할당 체크 시작");
|
||||
console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
|
||||
|
||||
const fieldsWithNumbering: Record<string, string> = {};
|
||||
|
||||
// formData에서 채번 규칙이 설정된 필드 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (key.endsWith("_numberingRuleId") && value) {
|
||||
const fieldName = key.replace("_numberingRuleId", "");
|
||||
fieldsWithNumbering[fieldName] = value as string;
|
||||
console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
||||
console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
|
||||
|
||||
// 각 필드에 대해 실제 코드 할당
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
try {
|
||||
console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`);
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await allocateNumberingCode(ruleId);
|
||||
|
||||
console.log(`📡 API 응답 (${fieldName}):`, response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const generatedCode = response.data.generatedCode;
|
||||
formData[fieldName] = generatedCode;
|
||||
console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`);
|
||||
} else {
|
||||
console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error);
|
||||
toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error);
|
||||
toast.error(`${fieldName} 채번 규칙 할당 오류`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 채번 규칙 할당 완료");
|
||||
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...formData,
|
||||
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||
|
|
@ -254,6 +309,13 @@ export class ButtonActionExecutor {
|
|||
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||
};
|
||||
|
||||
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
|
||||
for (const key of Object.keys(dataWithUserInfo)) {
|
||||
if (key.endsWith("_numberingRuleId")) {
|
||||
delete dataWithUserInfo[key];
|
||||
}
|
||||
}
|
||||
|
||||
saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
tableName,
|
||||
|
|
@ -1688,17 +1750,57 @@ export class ButtonActionExecutor {
|
|||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
dataToExport = context.selectedRowsData;
|
||||
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 호출)
|
||||
else if (context.tableName) {
|
||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
||||
console.log("📊 정렬 정보:", {
|
||||
sortBy: context.sortBy,
|
||||
sortOrder: context.sortOrder,
|
||||
});
|
||||
try {
|
||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||
page: 1,
|
||||
pageSize: 10000, // 최대 10,000개 행
|
||||
sortBy: "id", // 기본 정렬: id 컬럼
|
||||
sortOrder: "asc", // 오름차순
|
||||
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
|
||||
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
|
||||
});
|
||||
|
||||
console.log("📦 API 응답 구조:", {
|
||||
|
|
@ -1763,12 +1865,43 @@ export class ButtonActionExecutor {
|
|||
const sheetName = config.excelSheetName || "Sheet1";
|
||||
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("📥 엑셀 다운로드 실행:", {
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
dataCount: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
columnOrder: context.columnOrder,
|
||||
});
|
||||
|
||||
// 엑셀 다운로드 실행
|
||||
|
|
@ -1892,6 +2025,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 +2285,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
barcodeFormat: "all",
|
||||
barcodeAutoSubmit: false,
|
||||
},
|
||||
code_merge: {
|
||||
type: "code_merge",
|
||||
mergeShowPreview: true,
|
||||
confirmMessage: "선택한 두 항목을 병합하시겠습니까?",
|
||||
successMessage: "코드 병합이 완료되었습니다.",
|
||||
errorMessage: "코드 병합 중 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings:
|
|||
|
||||
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
|
||||
const cellHeight = 10; // 행 높이 10px 단위로 고정
|
||||
|
||||
// 패딩을 제외한 상대 위치
|
||||
const relativeX = position.x - padding;
|
||||
|
|
@ -92,9 +92,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
|
|||
|
||||
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||
|
||||
// 높이는 동적 행 높이 단위로 스냅
|
||||
const rowHeight = Math.max(20, gap);
|
||||
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
|
||||
// 높이는 10px 단위로 스냅
|
||||
const rowHeight = 10;
|
||||
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
||||
|
||||
console.log(
|
||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
|
|
@ -175,7 +175,7 @@ export function generateGridLines(
|
|||
|
||||
// 격자 셀 크기 (스냅 로직과 동일하게)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = Math.max(40, gap * 2);
|
||||
const cellHeight = 10; // 행 높이 10px 단위로 고정
|
||||
|
||||
// 세로 격자선
|
||||
const verticalLines: number[] = [];
|
||||
|
|
@ -254,8 +254,8 @@ export function alignGroupChildrenToGrid(
|
|||
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
|
||||
const snappedX = padding + columnIndex * (columnWidth + gap);
|
||||
|
||||
// Y 좌표는 동적 행 높이 단위로 스냅
|
||||
const rowHeight = Math.max(20, gap);
|
||||
// Y 좌표는 10px 단위로 스냅
|
||||
const rowHeight = 10;
|
||||
const effectiveY = child.position.y - padding;
|
||||
const rowIndex = Math.round(effectiveY / rowHeight);
|
||||
const snappedY = padding + rowIndex * rowHeight;
|
||||
|
|
@ -264,7 +264,7 @@ export function alignGroupChildrenToGrid(
|
|||
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
|
||||
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
|
||||
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
|
||||
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
|
||||
const snappedHeight = Math.max(10, Math.round(child.size.height / rowHeight) * rowHeight);
|
||||
|
||||
const snappedChild = {
|
||||
...child,
|
||||
|
|
@ -310,7 +310,7 @@ export function calculateOptimalGroupSize(
|
|||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (children.length === 0) {
|
||||
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
|
||||
return { width: gridInfo.columnWidth * 2, height: 10 * 4 };
|
||||
}
|
||||
|
||||
console.log("📏 calculateOptimalGroupSize 시작:", {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ export type AutoGenerationType =
|
|||
| "current_user" // 현재 사용자 ID
|
||||
| "current_time" // 현재 시간
|
||||
| "sequence" // 시퀀스 번호
|
||||
| "numbering_rule" // 채번 규칙
|
||||
| "random_string" // 랜덤 문자열
|
||||
| "random_number" // 랜덤 숫자
|
||||
| "company_code" // 회사 코드
|
||||
|
|
@ -178,6 +179,7 @@ export interface AutoGenerationConfig {
|
|||
suffix?: string; // 접미사
|
||||
format?: string; // 시간 형식 (current_time용)
|
||||
startValue?: number; // 시퀀스 시작값
|
||||
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -251,8 +251,22 @@ export interface TextTypeConfig {
|
|||
pattern?: string;
|
||||
format?: "none" | "email" | "phone" | "url" | "korean" | "english";
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
// 자동입력 관련 설정
|
||||
autoInput?: boolean;
|
||||
autoValueType?:
|
||||
| "current_datetime"
|
||||
| "current_date"
|
||||
| "current_time"
|
||||
| "current_user"
|
||||
| "uuid"
|
||||
| "sequence"
|
||||
| "numbering_rule"
|
||||
| "custom";
|
||||
customValue?: string;
|
||||
numberingRuleId?: string; // 채번 규칙 ID
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue