Merge pull request 'jskim-node' (#396) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/396
This commit is contained in:
commit
7ad17065f0
|
|
@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) {
|
|||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { tableName, detailTable } = req.body || {};
|
||||
const { tableName, detailTable, versionName } = req.body || {};
|
||||
|
||||
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
|
||||
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 생성 실패", { error: error.message });
|
||||
|
|
@ -129,6 +129,84 @@ export async function activateBomVersion(req: Request, res: Response) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function initializeBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
|
||||
const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 초기 버전 생성 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
|
||||
|
||||
export async function createBomFromExcel(req: Request, res: Response) {
|
||||
try {
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { rows } = req.body;
|
||||
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
|
||||
if (!result.success) {
|
||||
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBomVersionFromExcel(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { rows, versionName } = req.body;
|
||||
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
|
||||
if (!result.success) {
|
||||
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadBomExcelData(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
|
||||
const data = await bomService.downloadBomExcelData(bomId, companyCode);
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
|
|
|
|||
|
|
@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 필터 조건을 WHERE절에 적용하는 공통 헬퍼
|
||||
* filters JSON 배열: [{ column, operator, value }]
|
||||
*/
|
||||
function applyFilters(
|
||||
filtersJson: string | undefined,
|
||||
existingColumns: Set<string>,
|
||||
whereConditions: string[],
|
||||
params: any[],
|
||||
startParamIndex: number,
|
||||
tableName: string,
|
||||
): number {
|
||||
let paramIndex = startParamIndex;
|
||||
|
||||
if (!filtersJson) return paramIndex;
|
||||
|
||||
let filters: Array<{ column: string; operator: string; value: unknown }>;
|
||||
try {
|
||||
filters = JSON.parse(filtersJson as string);
|
||||
} catch {
|
||||
logger.warn("filters JSON 파싱 실패", { tableName, filtersJson });
|
||||
return paramIndex;
|
||||
}
|
||||
|
||||
if (!Array.isArray(filters)) return paramIndex;
|
||||
|
||||
for (const filter of filters) {
|
||||
const { column, operator = "=", value } = filter;
|
||||
if (!column || !existingColumns.has(column)) {
|
||||
logger.warn("필터 컬럼 미존재 제외", { tableName, column });
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "=":
|
||||
whereConditions.push(`"${column}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "!=":
|
||||
whereConditions.push(`"${column}" != $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">":
|
||||
case "<":
|
||||
case ">=":
|
||||
case "<=":
|
||||
whereConditions.push(`"${column}" ${operator} $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "in": {
|
||||
const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (inVals.length > 0) {
|
||||
const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${column}" IN (${ph})`);
|
||||
params.push(...inVals);
|
||||
paramIndex += inVals.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "notIn": {
|
||||
const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (notInVals.length > 0) {
|
||||
const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${column}" NOT IN (${ph})`);
|
||||
params.push(...notInVals);
|
||||
paramIndex += notInVals.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "like":
|
||||
whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`);
|
||||
params.push(`%${value}%`);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "isNull":
|
||||
whereConditions.push(`"${column}" IS NULL`);
|
||||
break;
|
||||
case "isNotNull":
|
||||
whereConditions.push(`"${column}" IS NOT NULL`);
|
||||
break;
|
||||
default:
|
||||
whereConditions.push(`"${column}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return paramIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
|
||||
* GET /api/entity/:tableName/distinct/:columnName
|
||||
*
|
||||
* 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환
|
||||
*
|
||||
* Query Params:
|
||||
* - labelColumn: 별도의 라벨 컬럼 (선택)
|
||||
* - filters: JSON 배열 형태의 필터 조건 (선택)
|
||||
* 예: [{"column":"status","operator":"=","value":"active"}]
|
||||
*/
|
||||
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
|
||||
const { labelColumn, filters: filtersParam } = req.query;
|
||||
|
||||
// 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
|
|
@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
whereConditions.push(`"${columnName}" IS NOT NULL`);
|
||||
whereConditions.push(`"${columnName}" != ''`);
|
||||
|
||||
// 필터 조건 적용
|
||||
paramIndex = applyFilters(
|
||||
filtersParam as string | undefined,
|
||||
existingColumns,
|
||||
whereConditions,
|
||||
params,
|
||||
paramIndex,
|
||||
tableName,
|
||||
);
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
|
@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
columnName,
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
hasFilters: !!filtersParam,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
|
|
@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
* Query Params:
|
||||
* - value: 값 컬럼 (기본: id)
|
||||
* - label: 표시 컬럼 (기본: name)
|
||||
* - fields: 추가 반환 컬럼 (콤마 구분)
|
||||
* - filters: JSON 배열 형태의 필터 조건 (선택)
|
||||
* 예: [{"column":"status","operator":"=","value":"active"}]
|
||||
*/
|
||||
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { value = "id", label = "name", fields } = req.query;
|
||||
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
|
|
@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
// 필터 조건 적용
|
||||
paramIndex = applyFilters(
|
||||
filtersParam as string | undefined,
|
||||
existingColumns,
|
||||
whereConditions,
|
||||
params,
|
||||
paramIndex,
|
||||
tableName,
|
||||
);
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
|
@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
|||
valueColumn,
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
hasFilters: !!filtersParam,
|
||||
rowCount: result.rowCount,
|
||||
extraFields: extraColumns ? true : false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import {
|
|||
PoolClient,
|
||||
QueryResult as PgQueryResult,
|
||||
QueryResultRow,
|
||||
types,
|
||||
} from "pg";
|
||||
import config from "../config/environment";
|
||||
|
||||
// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지)
|
||||
types.setTypeParser(1082, (val: string) => val);
|
||||
|
||||
// PostgreSQL 연결 풀
|
||||
let pool: Pool | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,15 @@ router.get("/:bomId/header", bomController.getBomHeader);
|
|||
router.get("/:bomId/history", bomController.getBomHistory);
|
||||
router.post("/:bomId/history", bomController.addBomHistory);
|
||||
|
||||
// 엑셀 업로드/다운로드
|
||||
router.post("/excel-upload", bomController.createBomFromExcel);
|
||||
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
|
||||
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
|
||||
|
||||
// 버전
|
||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
||||
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ export async function getBomHeader(bomId: string, tableName?: string) {
|
|||
const table = safeTableName(tableName || "", "bom");
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type, i.unit
|
||||
i.item_name, i.item_number, i.division as item_type,
|
||||
COALESCE(b.unit, i.unit) as unit,
|
||||
i.unit as item_unit,
|
||||
i.division, i.size, i.material
|
||||
FROM ${table} b
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
WHERE b.id = $1
|
||||
|
|
@ -98,6 +101,7 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa
|
|||
export async function createBomVersion(
|
||||
bomId: string, companyCode: string, createdBy: string,
|
||||
versionTableName?: string, detailTableName?: string,
|
||||
inputVersionName?: string,
|
||||
) {
|
||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||
|
|
@ -107,17 +111,24 @@ export async function createBomVersion(
|
|||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||
const bomData = bomRow.rows[0];
|
||||
|
||||
// 다음 버전 번호 결정
|
||||
const lastVersion = await client.query(
|
||||
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
|
||||
// 버전명: 사용자 입력 > 순번 자동 생성
|
||||
let versionName = inputVersionName?.trim();
|
||||
if (!versionName) {
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
let nextVersionNum = 1;
|
||||
if (lastVersion.rows.length > 0) {
|
||||
const parsed = parseFloat(lastVersion.rows[0].version_name);
|
||||
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
|
||||
versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const dupCheck = await client.query(
|
||||
`SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`,
|
||||
[bomId, versionName],
|
||||
);
|
||||
if (dupCheck.rows.length > 0) {
|
||||
throw new Error(`이미 존재하는 버전명입니다: ${versionName}`);
|
||||
}
|
||||
const versionName = `${nextVersionNum}.0`;
|
||||
|
||||
// 새 버전 레코드 생성 (snapshot_data 없이)
|
||||
const insertSql = `
|
||||
|
|
@ -249,6 +260,547 @@ export async function activateBomVersion(bomId: string, versionId: string, table
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 BOM 초기화: 첫 번째 버전 자동 생성 + version_id null인 디테일 보정
|
||||
* BOM 헤더의 version 필드를 그대로 버전명으로 사용 (사용자 입력값 존중)
|
||||
*/
|
||||
export async function initializeBomVersion(
|
||||
bomId: string, companyCode: string, createdBy: string,
|
||||
) {
|
||||
return transaction(async (client) => {
|
||||
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
|
||||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||
const bomData = bomRow.rows[0];
|
||||
|
||||
if (bomData.current_version_id) {
|
||||
await client.query(
|
||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
||||
[bomData.current_version_id, bomId],
|
||||
);
|
||||
return { versionId: bomData.current_version_id, created: false };
|
||||
}
|
||||
|
||||
// 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지)
|
||||
const existingVersion = await client.query(
|
||||
`SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`,
|
||||
[bomId],
|
||||
);
|
||||
if (existingVersion.rows.length > 0) {
|
||||
const existId = existingVersion.rows[0].id;
|
||||
await client.query(
|
||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
||||
[existId, bomId],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`,
|
||||
[existId, bomId],
|
||||
);
|
||||
return { versionId: existId, created: false };
|
||||
}
|
||||
|
||||
const versionName = bomData.version || "1.0";
|
||||
|
||||
const versionResult = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`,
|
||||
[bomId, versionName, createdBy, companyCode],
|
||||
);
|
||||
const versionId = versionResult.rows[0].id;
|
||||
|
||||
const updated = await client.query(
|
||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount });
|
||||
return { versionId, versionName, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── BOM 엑셀 업로드 ─────────────────────────────
|
||||
|
||||
interface BomExcelRow {
|
||||
level: number;
|
||||
item_number: string;
|
||||
item_name?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
process_type?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface BomExcelUploadResult {
|
||||
success: boolean;
|
||||
insertedCount: number;
|
||||
skippedCount: number;
|
||||
errors: string[];
|
||||
unmatchedItems: string[];
|
||||
createdBomId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 엑셀 업로드 - 새 BOM 생성
|
||||
*
|
||||
* 엑셀 레벨 체계:
|
||||
* 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT
|
||||
* 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||
* 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||
* 레벨 N = ... → bom_detail (DB level=N-1)
|
||||
*/
|
||||
export async function createBomFromExcel(
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
rows: BomExcelRow[],
|
||||
): Promise<BomExcelUploadResult> {
|
||||
const result: BomExcelUploadResult = {
|
||||
success: false,
|
||||
insertedCount: 0,
|
||||
skippedCount: 0,
|
||||
errors: [],
|
||||
unmatchedItems: [],
|
||||
};
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
result.errors.push("업로드할 데이터가 없습니다");
|
||||
return result;
|
||||
}
|
||||
|
||||
const headerRow = rows.find(r => r.level === 0);
|
||||
const detailRows = rows.filter(r => r.level > 0);
|
||||
|
||||
if (!headerRow) {
|
||||
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
|
||||
return result;
|
||||
}
|
||||
if (!headerRow.item_number?.trim()) {
|
||||
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
|
||||
return result;
|
||||
}
|
||||
if (detailRows.length === 0) {
|
||||
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 레벨 유효성 검사
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (row.level < 0) {
|
||||
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
|
||||
}
|
||||
if (i > 0 && row.level > rows[i - 1].level + 1) {
|
||||
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
|
||||
}
|
||||
if (row.level > 0 && !row.item_number?.trim()) {
|
||||
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
|
||||
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
|
||||
const itemLookup = await client.query(
|
||||
`SELECT id, item_number, item_name, unit FROM item_info
|
||||
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
||||
[companyCode, allItemNumbers],
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
|
||||
for (const item of itemLookup.rows) {
|
||||
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
|
||||
}
|
||||
|
||||
for (const num of allItemNumbers) {
|
||||
if (!itemMap.has(num)) {
|
||||
result.unmatchedItems.push(num);
|
||||
}
|
||||
}
|
||||
if (result.unmatchedItems.length > 0) {
|
||||
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. bom 마스터 생성 (레벨 0)
|
||||
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
|
||||
|
||||
// 동일 품목으로 이미 BOM이 존재하는지 확인
|
||||
const dupCheck = await client.query(
|
||||
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
|
||||
[headerItemInfo.id, companyCode],
|
||||
);
|
||||
if (dupCheck.rows.length > 0) {
|
||||
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const bomInsert = await client.query(
|
||||
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
|
||||
RETURNING id`,
|
||||
[
|
||||
headerItemInfo.id,
|
||||
headerRow.item_number.trim(),
|
||||
headerItemInfo.item_name,
|
||||
String(headerRow.quantity || 1),
|
||||
headerRow.unit || headerItemInfo.unit || null,
|
||||
headerRow.remark || null,
|
||||
userId,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
const newBomId = bomInsert.rows[0].id;
|
||||
result.createdBomId = newBomId;
|
||||
|
||||
// 3. bom_version 생성
|
||||
const versionInsert = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
|
||||
[newBomId, userId, companyCode],
|
||||
);
|
||||
const versionId = versionInsert.rows[0].id;
|
||||
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||
[versionId, newBomId],
|
||||
);
|
||||
|
||||
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
|
||||
const levelStack: string[] = [];
|
||||
const seqCounterByParent = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < detailRows.length; i++) {
|
||||
const row = detailRows[i];
|
||||
const itemInfo = itemMap.get(row.item_number.trim())!;
|
||||
const dbLevel = row.level - 1;
|
||||
|
||||
while (levelStack.length > dbLevel) {
|
||||
levelStack.pop();
|
||||
}
|
||||
|
||||
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
|
||||
const parentKey = parentDetailId || "__root__";
|
||||
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
|
||||
seqCounterByParent.set(parentKey, currentSeq);
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
|
||||
RETURNING id`,
|
||||
[
|
||||
newBomId,
|
||||
versionId,
|
||||
parentDetailId,
|
||||
itemInfo.id,
|
||||
String(dbLevel),
|
||||
String(currentSeq),
|
||||
String(row.quantity || 1),
|
||||
row.unit || itemInfo.unit || null,
|
||||
row.process_type || null,
|
||||
row.remark || null,
|
||||
userId,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
|
||||
levelStack.push(insertResult.rows[0].id);
|
||||
result.insertedCount++;
|
||||
}
|
||||
|
||||
// 5. 이력 기록
|
||||
await client.query(
|
||||
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
|
||||
VALUES ($1, 'excel_upload', $2, $3, $4)`,
|
||||
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
|
||||
);
|
||||
|
||||
result.success = true;
|
||||
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
|
||||
newBomId, companyCode,
|
||||
insertedCount: result.insertedCount,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성
|
||||
*
|
||||
* 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재)
|
||||
* 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결
|
||||
*/
|
||||
export async function createBomVersionFromExcel(
|
||||
bomId: string,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
rows: BomExcelRow[],
|
||||
versionName?: string,
|
||||
): Promise<BomExcelUploadResult> {
|
||||
const result: BomExcelUploadResult = {
|
||||
success: false,
|
||||
insertedCount: 0,
|
||||
skippedCount: 0,
|
||||
errors: [],
|
||||
unmatchedItems: [],
|
||||
};
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
result.errors.push("업로드할 데이터가 없습니다");
|
||||
return result;
|
||||
}
|
||||
|
||||
const detailRows = rows.filter(r => r.level > 0);
|
||||
result.skippedCount = rows.length - detailRows.length;
|
||||
|
||||
if (detailRows.length === 0) {
|
||||
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 레벨 유효성 검사
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (row.level < 0) {
|
||||
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
|
||||
}
|
||||
if (i > 0 && row.level > rows[i - 1].level + 1) {
|
||||
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
|
||||
}
|
||||
if (row.level > 0 && !row.item_number?.trim()) {
|
||||
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
// 1. BOM 존재 확인
|
||||
const bomRow = await client.query(
|
||||
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
|
||||
[bomId, companyCode],
|
||||
);
|
||||
if (bomRow.rows.length === 0) {
|
||||
result.errors.push("BOM을 찾을 수 없습니다");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 품번 → item_info 매핑
|
||||
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
|
||||
const itemLookup = await client.query(
|
||||
`SELECT id, item_number, item_name, unit FROM item_info
|
||||
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
||||
[companyCode, uniqueItemNumbers],
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
|
||||
for (const item of itemLookup.rows) {
|
||||
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
|
||||
}
|
||||
|
||||
for (const num of uniqueItemNumbers) {
|
||||
if (!itemMap.has(num)) {
|
||||
result.unmatchedItems.push(num);
|
||||
}
|
||||
}
|
||||
if (result.unmatchedItems.length > 0) {
|
||||
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. 버전명 결정 (미입력 시 자동 채번)
|
||||
let finalVersionName = versionName?.trim();
|
||||
if (!finalVersionName) {
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const dupCheck = await client.query(
|
||||
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
|
||||
[bomId, finalVersionName],
|
||||
);
|
||||
if (dupCheck.rows.length > 0) {
|
||||
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4. bom_version 생성
|
||||
const versionInsert = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
|
||||
[bomId, finalVersionName, userId, companyCode],
|
||||
);
|
||||
const newVersionId = versionInsert.rows[0].id;
|
||||
|
||||
// 5. bom_detail INSERT
|
||||
const levelStack: string[] = [];
|
||||
const seqCounterByParent = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < detailRows.length; i++) {
|
||||
const row = detailRows[i];
|
||||
const itemInfo = itemMap.get(row.item_number.trim())!;
|
||||
const dbLevel = row.level - 1;
|
||||
|
||||
while (levelStack.length > dbLevel) {
|
||||
levelStack.pop();
|
||||
}
|
||||
|
||||
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
|
||||
const parentKey = parentDetailId || "__root__";
|
||||
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
|
||||
seqCounterByParent.set(parentKey, currentSeq);
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
|
||||
RETURNING id`,
|
||||
[
|
||||
bomId,
|
||||
newVersionId,
|
||||
parentDetailId,
|
||||
itemInfo.id,
|
||||
String(dbLevel),
|
||||
String(currentSeq),
|
||||
String(row.quantity || 1),
|
||||
row.unit || itemInfo.unit || null,
|
||||
row.process_type || null,
|
||||
row.remark || null,
|
||||
userId,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
|
||||
levelStack.push(insertResult.rows[0].id);
|
||||
result.insertedCount++;
|
||||
}
|
||||
|
||||
// 6. BOM 헤더의 version과 current_version_id 갱신
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[finalVersionName, newVersionId, bomId],
|
||||
);
|
||||
|
||||
// 7. 이력 기록
|
||||
await client.query(
|
||||
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
|
||||
VALUES ($1, 'excel_upload', $2, $3, $4)`,
|
||||
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
|
||||
);
|
||||
|
||||
result.success = true;
|
||||
result.createdBomId = bomId;
|
||||
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
|
||||
bomId, companyCode, versionName: finalVersionName,
|
||||
insertedCount: result.insertedCount,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 엑셀 다운로드용 데이터 조회
|
||||
*
|
||||
* 화면과 동일한 레벨 체계로 출력:
|
||||
* 레벨 0 = BOM 헤더 (최상위 품목)
|
||||
* 레벨 1 = 직접 자품목 (DB level=0)
|
||||
* 레벨 N = DB level N-1
|
||||
*
|
||||
* DFS로 순회하여 부모-자식 순서 보장
|
||||
*/
|
||||
export async function downloadBomExcelData(
|
||||
bomId: string,
|
||||
companyCode: string,
|
||||
): Promise<Record<string, any>[]> {
|
||||
// BOM 헤더 정보 조회 (최상위 품목)
|
||||
const bomHeader = await queryOne<Record<string, any>>(
|
||||
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
|
||||
FROM bom b
|
||||
LEFT JOIN item_info ii ON b.item_id = ii.id
|
||||
WHERE b.id = $1 AND b.company_code = $2`,
|
||||
[bomId, companyCode],
|
||||
);
|
||||
|
||||
if (!bomHeader) return [];
|
||||
|
||||
const flatList: Record<string, any>[] = [];
|
||||
|
||||
// 레벨 0: BOM 헤더 (최상위 품목)
|
||||
flatList.push({
|
||||
level: 0,
|
||||
item_number: bomHeader.item_number || "",
|
||||
item_name: bomHeader.item_name || "",
|
||||
quantity: bomHeader.base_qty || "1",
|
||||
unit: bomHeader.item_unit || bomHeader.unit || "",
|
||||
process_type: "",
|
||||
remark: bomHeader.remark || "",
|
||||
_is_header: true,
|
||||
});
|
||||
|
||||
// 하위 품목 조회
|
||||
const versionId = bomHeader.current_version_id;
|
||||
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
|
||||
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
|
||||
|
||||
const details = await query(
|
||||
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
|
||||
FROM bom_detail bd
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
|
||||
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
|
||||
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
|
||||
params,
|
||||
);
|
||||
|
||||
// 부모 ID별 자식 목록으로 맵 구성
|
||||
const childrenMap = new Map<string, any[]>();
|
||||
const roots: any[] = [];
|
||||
for (const d of details) {
|
||||
if (!d.parent_detail_id) {
|
||||
roots.push(d);
|
||||
} else {
|
||||
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
|
||||
childrenMap.get(d.parent_detail_id)!.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
|
||||
const dfs = (nodes: any[], depth: number) => {
|
||||
for (const node of nodes) {
|
||||
flatList.push({
|
||||
level: depth,
|
||||
item_number: node.item_number || "",
|
||||
item_name: node.item_name || "",
|
||||
quantity: node.quantity || "1",
|
||||
unit: node.unit || node.item_unit || "",
|
||||
process_type: node.process_type || "",
|
||||
remark: node.remark || "",
|
||||
});
|
||||
const children = childrenMap.get(node.id) || [];
|
||||
if (children.length > 0) {
|
||||
dfs(children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
|
||||
dfs(roots, 1);
|
||||
|
||||
return flatList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -210,19 +210,62 @@ export class DynamicFormService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환
|
||||
*/
|
||||
async resolveBaseTable(tableName: string): Promise<string> {
|
||||
try {
|
||||
const result = await query<{ table_type: string }>(
|
||||
`SELECT table_type FROM information_schema.tables
|
||||
WHERE table_name = $1 AND table_schema = 'public'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (result.length === 0 || result[0].table_type !== 'VIEW') {
|
||||
return tableName;
|
||||
}
|
||||
|
||||
// VIEW의 FROM 절에서 첫 번째 테이블을 추출
|
||||
const viewDef = await query<{ view_definition: string }>(
|
||||
`SELECT view_definition FROM information_schema.views
|
||||
WHERE table_name = $1 AND table_schema = 'public'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (viewDef.length > 0) {
|
||||
const definition = viewDef[0].view_definition;
|
||||
// PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장
|
||||
const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i);
|
||||
if (fromMatch) {
|
||||
const baseTable = fromMatch[1];
|
||||
console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`);
|
||||
return baseTable;
|
||||
}
|
||||
}
|
||||
|
||||
return tableName;
|
||||
} catch (error) {
|
||||
console.error(`❌ VIEW 원본 테이블 조회 실패:`, error);
|
||||
return tableName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 저장 (실제 테이블에 직접 저장)
|
||||
*/
|
||||
async saveFormData(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
data: Record<string, any>,
|
||||
ipAddress?: string
|
||||
): Promise<FormDataResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||||
screenId,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
data,
|
||||
});
|
||||
|
||||
|
|
@ -813,14 +856,17 @@ export class DynamicFormService {
|
|||
*/
|
||||
async updateFormDataPartial(
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>
|
||||
): Promise<PartialUpdateResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("🔄 서비스: 부분 업데이트 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
originalData,
|
||||
newData,
|
||||
});
|
||||
|
|
@ -1008,13 +1054,16 @@ export class DynamicFormService {
|
|||
*/
|
||||
async updateFormData(
|
||||
id: string | number,
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
data: Record<string, any>
|
||||
): Promise<FormDataResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
data,
|
||||
});
|
||||
|
||||
|
|
@ -1033,6 +1082,9 @@ export class DynamicFormService {
|
|||
if (tableColumns.includes("updated_at")) {
|
||||
dataToUpdate.updated_at = new Date();
|
||||
}
|
||||
if (tableColumns.includes("updated_date")) {
|
||||
dataToUpdate.updated_date = new Date();
|
||||
}
|
||||
if (tableColumns.includes("regdate") && !dataToUpdate.regdate) {
|
||||
dataToUpdate.regdate = new Date();
|
||||
}
|
||||
|
|
@ -1212,9 +1264,13 @@ export class DynamicFormService {
|
|||
screenId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
|
||||
const actualTable = await this.resolveBaseTable(tableName);
|
||||
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
tableName: actualTable,
|
||||
originalTable: tableName !== actualTable ? tableName : undefined,
|
||||
});
|
||||
|
||||
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
|
||||
|
|
@ -1232,15 +1288,15 @@ export class DynamicFormService {
|
|||
`;
|
||||
|
||||
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
|
||||
console.log("🔍 테이블명:", tableName);
|
||||
console.log("🔍 테이블명:", actualTable);
|
||||
|
||||
const primaryKeyResult = await query<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}>(primaryKeyQuery, [tableName]);
|
||||
}>(primaryKeyQuery, [actualTable]);
|
||||
|
||||
if (!primaryKeyResult || primaryKeyResult.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||||
throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
const primaryKeyInfo = primaryKeyResult[0];
|
||||
|
|
@ -1272,7 +1328,7 @@ export class DynamicFormService {
|
|||
|
||||
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
|
||||
const deleteQuery = `
|
||||
DELETE FROM ${tableName}
|
||||
DELETE FROM ${actualTable}
|
||||
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
|
||||
RETURNING *
|
||||
`;
|
||||
|
|
@ -1292,7 +1348,7 @@ export class DynamicFormService {
|
|||
|
||||
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
|
||||
if (!result || !Array.isArray(result) || result.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
|
|
|||
|
|
@ -5083,8 +5083,8 @@ export class ScreenManagementService {
|
|||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin || companyCode === "*") {
|
||||
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* BOM Screen - Restoration Verification
|
||||
* Screen 4168 - verify split panel, BOM list, and tree with child items
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SCREENSHOT_DIR = join(process.cwd(), 'bom-detail-test-screenshots');
|
||||
|
||||
async function ensureDir(dir) {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
ensureDir(SCREENSHOT_DIR);
|
||||
await page.screenshot({ path: join(SCREENSHOT_DIR, `${name}.png`), fullPage: true });
|
||||
console.log(` [Screenshot] ${name}.png`);
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
try {
|
||||
console.log('\n--- Step 1-2: Login ---');
|
||||
await page.goto('http://localhost:9771/login', { waitUntil: 'load', timeout: 45000 });
|
||||
await page.locator('input[type="text"], input[placeholder*="ID"]').first().fill('topseal_admin');
|
||||
await page.locator('input[type="password"]').first().fill('qlalfqjsgh11');
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}),
|
||||
page.locator('button:has-text("로그인")').first().click(),
|
||||
]);
|
||||
await sleep(3000);
|
||||
|
||||
console.log('\n--- Step 4-5: Navigate to screen 4168 ---');
|
||||
await page.goto('http://localhost:9771/screens/4168', { waitUntil: 'load', timeout: 45000 });
|
||||
await sleep(5000);
|
||||
|
||||
console.log('\n--- Step 6: Screenshot after load ---');
|
||||
await screenshot(page, '10-bom-4168-initial');
|
||||
|
||||
const hasBomList = (await page.locator('text="BOM 목록"').count()) > 0;
|
||||
const hasSplitPanel = (await page.locator('text="BOM 상세정보"').count()) > 0 || hasBomList;
|
||||
const rowCount = await page.locator('table tbody tr').count();
|
||||
const hasBomRows = rowCount > 0;
|
||||
|
||||
console.log('\n========== INITIAL STATE (Step 7) ==========');
|
||||
console.log('BOM management screen loaded:', hasBomList || hasSplitPanel ? 'YES' : 'CHECK');
|
||||
console.log('Split panel (BOM list left):', hasSplitPanel ? 'YES' : 'NO');
|
||||
console.log('BOM data rows visible:', hasBomRows ? `YES (${rowCount} rows)` : 'NO');
|
||||
|
||||
if (hasBomRows) {
|
||||
console.log('\n--- Step 8-9: Click first row ---');
|
||||
await page.locator('table tbody tr').first().click();
|
||||
await sleep(5000);
|
||||
|
||||
console.log('\n--- Step 10: Screenshot after row click ---');
|
||||
await screenshot(page, '11-bom-4168-after-click');
|
||||
|
||||
const noDataMsg = (await page.locator('text="등록된 하위 품목이 없습니다"').count()) > 0;
|
||||
const treeArea = page.locator('div:has-text("BOM 구성"), div:has-text("BOM 상세정보")').first();
|
||||
const treeText = (await treeArea.textContent().catch(() => '') || '').substring(0, 600);
|
||||
const hasChildItems = !noDataMsg && (treeText.includes('품번') || treeText.includes('레벨') || treeText.length > 150);
|
||||
|
||||
console.log('\n========== AFTER ROW CLICK (Step 11) ==========');
|
||||
console.log('BOM tree shows child items:', hasChildItems ? 'YES' : noDataMsg ? 'NO (empty message)' : 'CHECK');
|
||||
console.log('Tree preview:', treeText.substring(0, 300) + (treeText.length > 300 ? '...' : ''));
|
||||
} else {
|
||||
console.log('\n--- No BOM rows to click ---');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
try { await page.screenshot({ path: join(SCREENSHOT_DIR, '99-error.png'), fullPage: true }); } catch (e) {}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[warning] Image with src "/images/vexplor.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.
|
||||
[log] 첫 번째 접근 가능한 메뉴로 이동: /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
|
||||
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
|
||||
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 📦 [SplitPanelLayout] Context에서 분할 패널 해제: split-panel-comp_split_panel
|
||||
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
|
||||
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] ✅ 분할 패널 좌측 선택: bom {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔴 [ButtonPrimary] 저장 시 formData 디버그: {propsFormDataKeys: Array(70), screenContextFormDataKeys: Array(0), effectiveFormDataKeys: Array(70), process_code: undefined, equipment_code: undefined}
|
||||
[log] [BomTree] openEditModal 가로채기 - editData 보정 {oldVersion: 1.0, newVersion: 1.0, oldCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd, newCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd}
|
||||
[log] 🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] [EditModal] 모달 열림: {mode: UPDATE (수정), hasEditData: true, editDataId: 64617576-fec9-4caa-8e72-653f9e83ba45, isCreateMode: false}
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] [EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작: 4154
|
||||
[log] [EditModal] loadConditionalLayersAndZones 호출됨: 4154
|
||||
[log] [EditModal] API 호출 시작: getScreenLayers, getScreenZones
|
||||
[log] [EditModal] API 응답: {layers: 1, zones: 0}
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# BOM 엑셀 업로드 기능 개발 계획
|
||||
|
||||
## 개요
|
||||
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
|
||||
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
|
||||
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
|
||||
|
||||
## 핵심 구조
|
||||
|
||||
### DB 테이블
|
||||
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
|
||||
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
|
||||
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
|
||||
|
||||
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
|
||||
엑셀 파일은 다음 컬럼으로 구성:
|
||||
|
||||
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|
||||
|------|------|------|--------|------|-----------|----------|------|
|
||||
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
|
||||
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
|
||||
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
|
||||
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
|
||||
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
|
||||
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
|
||||
|
||||
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
|
||||
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||
- 레벨 N: → bom_detail (DB level=N-1)
|
||||
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
|
||||
|
||||
### 트리 변환 로직 (레벨 1 이상만 처리)
|
||||
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
|
||||
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
|
||||
2. 스택으로 부모-자식 관계 추적
|
||||
|
||||
```
|
||||
행1(레벨0) → BOM 헤더, 건너뜀
|
||||
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
|
||||
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
|
||||
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
|
||||
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
|
||||
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
|
||||
```
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 백엔드 API
|
||||
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
|
||||
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
|
||||
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
|
||||
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
|
||||
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
|
||||
|
||||
### 2단계: 프론트엔드 모달
|
||||
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
|
||||
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
|
||||
- [x] 테스트 8: 업로드 실행 및 결과 표시
|
||||
|
||||
### 3단계: 통합
|
||||
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
|
||||
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
|
||||
|
||||
## 구현 파일 목록
|
||||
|
||||
### 백엔드
|
||||
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
|
||||
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
|
||||
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
|
||||
|
||||
### 프론트엔드
|
||||
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
|
||||
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
|
||||
|
||||
## 진행 상태
|
||||
- 완료된 테스트는 [x]로 표시
|
||||
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* /screen/{screenCode} → /screens/{screenId} 리다이렉트
|
||||
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
|
||||
*/
|
||||
export default function ScreenCodeRedirectPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const screenCode = params.screenCode as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenCode) return;
|
||||
|
||||
const numericId = parseInt(screenCode);
|
||||
if (!isNaN(numericId)) {
|
||||
router.replace(`/screens/${numericId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolve = async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/screen-management/screens", {
|
||||
params: { searchTerm: screenCode, size: 50 },
|
||||
});
|
||||
const items = res.data?.data?.data || res.data?.data || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
const exact = arr.find((s: any) => s.screenCode === screenCode);
|
||||
const target = exact || arr[0];
|
||||
if (target) {
|
||||
router.replace(`/screens/${target.screenId || target.screen_id}`);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch {
|
||||
router.replace("/");
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, [screenCode, router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -179,7 +179,25 @@ function ScreenViewPage() {
|
|||
} else {
|
||||
// V1 레이아웃 또는 빈 레이아웃
|
||||
const layoutData = await screenApi.getLayout(screenId);
|
||||
if (layoutData?.components?.length > 0) {
|
||||
setLayout(layoutData);
|
||||
} else {
|
||||
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
|
||||
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
||||
const converted = convertV2ToLegacy(baseLayerData);
|
||||
if (converted) {
|
||||
setLayout({
|
||||
...converted,
|
||||
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
|
||||
} as LayoutData);
|
||||
} else {
|
||||
setLayout(layoutData);
|
||||
}
|
||||
} else {
|
||||
setLayout(layoutData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (layoutError) {
|
||||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||||
|
|
|
|||
|
|
@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
for (const row of filteredData) {
|
||||
for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
|
||||
const row = filteredData[rowIdx];
|
||||
try {
|
||||
let dataToSave = { ...row };
|
||||
let shouldSkip = false;
|
||||
|
|
@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
|
||||
if (existingDataMap.has(key)) {
|
||||
existingRow = existingDataMap.get(key);
|
||||
// 중복 발견 - 전역 설정에 따라 처리
|
||||
if (duplicateAction === "skip") {
|
||||
shouldSkip = true;
|
||||
skipCount++;
|
||||
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
|
||||
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
||||
} else {
|
||||
shouldUpdate = true;
|
||||
console.log(`🔄 중복으로 덮어쓰기: ${key}`);
|
||||
console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
|
||||
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
|
||||
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
||||
if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
|
||||
const existingValue = dataToSave[numberingInfo.columnName];
|
||||
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
|
||||
|
||||
|
|
@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
tableName,
|
||||
data: dataToSave,
|
||||
};
|
||||
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
|
||||
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
|
||||
if (result.success) {
|
||||
overwriteCount++;
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "insert") {
|
||||
// 신규 등록
|
||||
} else if (uploadMode === "insert" || uploadMode === "upsert") {
|
||||
// 신규 등록 (insert, upsert 모드)
|
||||
const formData = { screenId: 0, tableName, data: dataToSave };
|
||||
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
|
||||
} else {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "update") {
|
||||
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
|
||||
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
|
||||
skipCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
|
||||
|
||||
if (successCount > 0 || skipCount > 0) {
|
||||
// 상세 결과 메시지 생성
|
||||
let message = "";
|
||||
if (successCount > 0) {
|
||||
message += `${successCount}개 행 업로드`;
|
||||
|
|
@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
message += `중복 건너뛰기 ${skipCount}개`;
|
||||
}
|
||||
if (failCount > 0) {
|
||||
message += ` (실패: ${failCount}개)`;
|
||||
message += `, 실패 ${failCount}개`;
|
||||
}
|
||||
|
||||
if (failCount > 0 && successCount === 0) {
|
||||
toast.warning(message);
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
await saveMappingTemplateInternal();
|
||||
|
||||
if (successCount > 0 || overwriteCount > 0) {
|
||||
onSuccess?.();
|
||||
}
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
|
||||
} else {
|
||||
toast.error("업로드에 실패했습니다.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1288,7 +1288,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
{/* 모달 닫기 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
화면을 닫으시겠습니까?
|
||||
|
|
|
|||
|
|
@ -275,7 +275,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
});
|
||||
|
||||
// 편집 데이터로 폼 데이터 초기화
|
||||
setFormData(editData || {});
|
||||
// entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑
|
||||
const enriched = { ...(editData || {}) };
|
||||
if (editData) {
|
||||
Object.keys(editData).forEach((key) => {
|
||||
// item_id_item_name → item_info.item_name 패턴 변환
|
||||
const match = key.match(/^(.+?)_([a-z_]+)$/);
|
||||
if (match && editData[key] != null) {
|
||||
const [, fkCol, fieldName] = match;
|
||||
// FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info)
|
||||
if (fkCol.endsWith("_id")) {
|
||||
const refTable = fkCol.replace(/_id$/, "_info");
|
||||
const dotKey = `${refTable}.${fieldName}`;
|
||||
if (!(dotKey in enriched)) {
|
||||
enriched[dotKey] = editData[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
setFormData(enriched);
|
||||
// originalData: changedData 계산(PATCH)에만 사용
|
||||
// INSERT/UPDATE 판단에는 사용하지 않음
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
|
|
@ -394,9 +413,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// V2 없으면 기존 API fallback
|
||||
if (!layoutData) {
|
||||
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
|
||||
layoutData = await screenApi.getLayout(screenId);
|
||||
}
|
||||
|
||||
// getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드
|
||||
if (!layoutData || !layoutData.components || layoutData.components.length === 0) {
|
||||
console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId);
|
||||
try {
|
||||
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
||||
layoutData = convertV2ToLegacy(baseLayerData);
|
||||
if (layoutData) {
|
||||
layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution;
|
||||
}
|
||||
} else if (baseLayerData?.components) {
|
||||
layoutData = baseLayerData;
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
||||
|
|
@ -1202,7 +1240,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
|
||||
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterForInsert) {
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
|
|
@ -1214,11 +1254,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
|
||||
parentId: masterRecordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
|
|
@ -1231,10 +1266,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
|
|
@ -1242,8 +1277,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
} else {
|
||||
// UPDATE 모드 - PUT (전체 업데이트)
|
||||
// originalData 비교 없이 formData 전체를 보냄
|
||||
const recordId = formData.id;
|
||||
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
|
||||
const recordId = formData.master_id || formData.id;
|
||||
|
||||
if (!recordId) {
|
||||
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
|
||||
|
|
@ -1296,15 +1331,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (response.success) {
|
||||
toast.success("데이터가 수정되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
|
|
@ -1341,7 +1367,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
|
||||
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterForUpdate) {
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
|
|
@ -1353,11 +1381,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
|
||||
parentId: recordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
|
|
@ -1370,11 +1393,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
}
|
||||
|
||||
// 리피터 저장 완료 후 메인 테이블 새로고침
|
||||
if (modalState.onSave) {
|
||||
try { modalState.onSave(); } catch {}
|
||||
}
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "수정에 실패했습니다.");
|
||||
|
|
@ -1432,7 +1459,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -1447,7 +1474,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative bg-white"
|
||||
className="relative m-auto bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
|
|
|
|||
|
|
@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
};
|
||||
|
||||
// 라벨 렌더링
|
||||
const labelPos = widget.style?.labelPosition || "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
|
||||
const renderLabel = () => {
|
||||
if (hideLabel) return null;
|
||||
|
||||
const labelStyle = widget.style || {};
|
||||
const ls = widget.style || {};
|
||||
const labelElement = (
|
||||
<label
|
||||
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
||||
className={`text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
||||
style={{
|
||||
fontSize: labelStyle.labelFontSize || "14px",
|
||||
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
|
||||
fontWeight: labelStyle.labelFontWeight || "500",
|
||||
fontFamily: labelStyle.labelFontFamily,
|
||||
textAlign: labelStyle.labelTextAlign || "left",
|
||||
backgroundColor: labelStyle.labelBackgroundColor,
|
||||
padding: labelStyle.labelPadding,
|
||||
borderRadius: labelStyle.labelBorderRadius,
|
||||
marginBottom: labelStyle.labelMarginBottom || "8px",
|
||||
fontSize: ls.labelFontSize || "14px",
|
||||
color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
|
||||
fontWeight: ls.labelFontWeight || "500",
|
||||
fontFamily: ls.labelFontFamily,
|
||||
textAlign: ls.labelTextAlign || "left",
|
||||
backgroundColor: ls.labelBackgroundColor,
|
||||
padding: ls.labelPadding,
|
||||
borderRadius: ls.labelBorderRadius,
|
||||
...(isHorizLabel
|
||||
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
|
||||
: { marginBottom: labelPos === "top" ? (ls.labelMarginBottom || "8px") : undefined,
|
||||
marginTop: labelPos === "bottom" ? (ls.labelMarginBottom || "8px") : undefined }),
|
||||
}}
|
||||
>
|
||||
{widget.label}
|
||||
|
|
@ -332,11 +338,28 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
}
|
||||
};
|
||||
|
||||
const labelElement = renderLabel();
|
||||
const widgetElement = renderByWebType();
|
||||
const validationElement = renderFieldValidation();
|
||||
|
||||
if (isHorizLabel && labelElement) {
|
||||
return (
|
||||
<div key={comp.id} className="space-y-2">
|
||||
{renderLabel()}
|
||||
{renderByWebType()}
|
||||
{renderFieldValidation()}
|
||||
<div key={comp.id}>
|
||||
<div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
|
||||
{labelElement}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
|
||||
</div>
|
||||
{validationElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={comp.id}>
|
||||
{labelPos === "top" && labelElement}
|
||||
{widgetElement}
|
||||
{labelPos === "bottom" && labelElement}
|
||||
{validationElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2191,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
// 라벨 표시 여부 계산
|
||||
const shouldShowLabel =
|
||||
!hideLabel && // hideLabel이 true면 라벨 숨김
|
||||
(component.style?.labelDisplay ?? true) &&
|
||||
!hideLabel &&
|
||||
(component.style?.labelDisplay ?? true) !== false &&
|
||||
component.style?.labelDisplay !== "false" &&
|
||||
(component.label || component.style?.labelText) &&
|
||||
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
||||
!templateTypes.includes(component.type);
|
||||
|
||||
const labelText = component.style?.labelText || component.label || "";
|
||||
|
||||
|
|
@ -2208,15 +2209,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
}
|
||||
|
||||
// 라벨 스타일 적용
|
||||
const labelStyle = {
|
||||
// 라벨 위치 및 스타일
|
||||
const labelPosition = component.style?.labelPosition || "top";
|
||||
const isHorizontalLabel = labelPosition === "left" || labelPosition === "right";
|
||||
const labelGap = component.style?.labelGap || "8px";
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
padding: component.style?.labelPadding || "0",
|
||||
borderRadius: component.style?.labelBorderRadius || "0",
|
||||
marginBottom: component.style?.labelMarginBottom || "4px",
|
||||
...(isHorizontalLabel
|
||||
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
|
||||
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -2226,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}),
|
||||
},
|
||||
...(isHorizontalLabel ? {
|
||||
size: {
|
||||
...component.size,
|
||||
width: undefined as unknown as number,
|
||||
height: undefined as unknown as number,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
: component;
|
||||
|
||||
|
|
@ -2452,18 +2468,45 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
{/* 테이블 옵션 툴바 */}
|
||||
<TableOptionsToolbar />
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
|
||||
<div
|
||||
className="h-full flex-1"
|
||||
style={{
|
||||
width: '100%',
|
||||
...(shouldShowLabel && isHorizontalLabel
|
||||
? { display: 'flex', flexDirection: labelPosition === 'left' ? 'row' : 'row-reverse', alignItems: 'center', gap: labelGap }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{/* 라벨: top 또는 left일 때 위젯보다 먼저 렌더링 */}
|
||||
{shouldShowLabel && (labelPosition === "top" || labelPosition === "left") && (
|
||||
<label
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
style={labelStyle}
|
||||
>
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
{/* 실제 위젯 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%', ...(isHorizontalLabel ? { flex: 1, minWidth: 0 } : {}) }}>
|
||||
{renderInteractiveWidget(componentForRendering)}
|
||||
</div>
|
||||
|
||||
{/* 라벨: bottom 또는 right일 때 위젯 뒤에 렌더링 */}
|
||||
{shouldShowLabel && (labelPosition === "bottom" || labelPosition === "right") && (
|
||||
<label
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
style={{
|
||||
...labelStyle,
|
||||
...(labelPosition === "bottom" ? { marginBottom: 0, marginTop: component.style?.labelMarginBottom || "4px" } : {}),
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1103,17 +1103,27 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
|
||||
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
|
||||
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
|
||||
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
const compType = (component as any).componentType || "";
|
||||
const isV2InputComponent =
|
||||
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
|
||||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
|
||||
const hasVisibleLabel = isV2InputComponent &&
|
||||
style?.labelDisplay !== false &&
|
||||
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
|
||||
(style?.labelText || (component as any).label);
|
||||
|
||||
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
|
||||
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
|
||||
const labelPos = style?.labelPosition || "top";
|
||||
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
|
||||
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
|
||||
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
const labelText = style?.labelText || (component as any).label || "";
|
||||
const labelGapValue = style?.labelGap || "8px";
|
||||
|
||||
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
||||
const compType = (component as any).componentType || "";
|
||||
|
|
@ -1190,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
|
||||
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
|
||||
|
||||
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
|
||||
const cleanedStyle = (isHorizLabel && needsExternalLabel)
|
||||
? (() => {
|
||||
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
|
||||
return rest;
|
||||
})()
|
||||
: safeStyleWithoutSize;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...safeStyleWithoutSize,
|
||||
...cleanedStyle,
|
||||
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
|
||||
left: adjustedX,
|
||||
top: position?.y || 0,
|
||||
|
|
@ -1263,10 +1281,101 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return unsubscribe;
|
||||
}, [component.id, position?.x, size?.width, type]);
|
||||
|
||||
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
|
||||
|
||||
const externalLabelComponent = needsExternalLabel ? (
|
||||
<label
|
||||
className="text-sm font-medium leading-none"
|
||||
style={{
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#212121",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
|
||||
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
const componentToRender = needsExternalLabel
|
||||
? {
|
||||
...splitAdjustedComponent,
|
||||
style: {
|
||||
...splitAdjustedComponent.style,
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
...(isHorizLabel ? {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
borderStyle: undefined,
|
||||
border: undefined,
|
||||
borderRadius: undefined,
|
||||
} : {}),
|
||||
},
|
||||
...(isHorizLabel ? {
|
||||
size: {
|
||||
...splitAdjustedComponent.size,
|
||||
width: undefined as unknown as number,
|
||||
height: undefined as unknown as number,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
: splitAdjustedComponent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||
{renderInteractiveWidget(splitAdjustedComponent)}
|
||||
{needsExternalLabel ? (
|
||||
isHorizLabel ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<label
|
||||
className="text-sm font-medium leading-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
...(labelPos === "left"
|
||||
? { right: "100%", marginRight: labelGapValue }
|
||||
: { left: "100%", marginLeft: labelGapValue }),
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#212121",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
</label>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{renderInteractiveWidget(componentToRender)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{externalLabelComponent}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{renderInteractiveWidget(componentToRender)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
renderInteractiveWidget(componentToRender)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
const origWidth = size?.width || 100;
|
||||
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
|
||||
|
||||
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
|
||||
const isV2HorizLabel = !!(
|
||||
componentStyle &&
|
||||
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
|
||||
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
|
||||
);
|
||||
const safeComponentStyle = isV2HorizLabel
|
||||
? (() => {
|
||||
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
|
||||
return rest;
|
||||
})()
|
||||
: componentStyle;
|
||||
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`,
|
||||
top: `${position.y}px`,
|
||||
...componentStyle,
|
||||
...safeComponentStyle,
|
||||
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
|
||||
height: displayHeight,
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormDatePickerProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
export const FormDatePicker: React.FC<FormDatePickerProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
includeTime = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const [timeValue, setTimeValue] = useState("00:00");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [typingValue, setTypingValue] = useState("");
|
||||
|
||||
const parseDate = (val: string): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedDate = parseDate(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setViewMode("calendar");
|
||||
if (selectedDate) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||
if (includeTime) {
|
||||
const hours = String(selectedDate.getHours()).padStart(2, "0");
|
||||
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
|
||||
setTimeValue(`${hours}:${minutes}`);
|
||||
}
|
||||
} else {
|
||||
setCurrentMonth(new Date());
|
||||
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
setTimeValue("00:00");
|
||||
}
|
||||
} else {
|
||||
setIsTyping(false);
|
||||
setTypingValue("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDisplayValue = (): string => {
|
||||
if (!selectedDate) return "";
|
||||
if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const buildDateStr = (date: Date, time?: string) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`;
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
onChange(buildDateStr(date));
|
||||
if (!includeTime) setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTime: string) => {
|
||||
setTimeValue(newTime);
|
||||
if (selectedDate) onChange(buildDateStr(selectedDate, newTime));
|
||||
};
|
||||
|
||||
const handleSetToday = () => {
|
||||
const today = new Date();
|
||||
if (includeTime) {
|
||||
const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
|
||||
onChange(buildDateStr(today, t));
|
||||
} else {
|
||||
onChange(buildDateStr(today));
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange("");
|
||||
setIsTyping(false);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleTriggerInput = (raw: string) => {
|
||||
setIsTyping(true);
|
||||
setTypingValue(raw);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||
onChange(buildDateStr(date));
|
||||
setCurrentMonth(new Date(y, m, 1));
|
||||
if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
|
||||
else setIsTyping(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const dayOfWeek = monthStart.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={(open) => { if (!open) { setIsOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"border-input bg-background flex h-10 w-full cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readOnly) && "cursor-not-allowed opacity-50",
|
||||
!selectedDate && !isTyping && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readOnly) setIsOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||
placeholder={placeholder || "날짜를 선택하세요"}
|
||||
disabled={disabled || readOnly}
|
||||
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }}
|
||||
onBlur={() => { if (!isOpen) setIsTyping(false); }}
|
||||
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
{selectedDate && !disabled && !readOnly && !isTyping && (
|
||||
<X
|
||||
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-2" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{includeTime && viewMode === "calendar" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">시간:</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InlineCellDatePickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const InlineCellDatePicker: React.FC<InlineCellDatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSave,
|
||||
onKeyDown,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const localInputRef = useRef<HTMLInputElement>(null);
|
||||
const actualInputRef = inputRef || localInputRef;
|
||||
|
||||
const parseDate = (val: string): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedDate = parseDate(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
};
|
||||
|
||||
const handleSetToday = () => {
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange("");
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
};
|
||||
|
||||
const handleInputChange = (raw: string) => {
|
||||
onChange(raw);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopoverClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
onSave();
|
||||
}
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handlePopoverClose}>
|
||||
<PopoverTrigger asChild>
|
||||
<input
|
||||
ref={actualInputRef as any}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={() => setIsOpen(true)}
|
||||
placeholder="YYYYMMDD"
|
||||
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="text-xs font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 grid grid-cols-7 gap-0.5">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-1 text-center text-[10px] font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-1" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 text-[11px]",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
|
||||
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
|
||||
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
|
||||
|
|
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
if (isOpen) {
|
||||
setTempValue(value || {});
|
||||
setSelectingType("from");
|
||||
setViewMode("calendar");
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
|
|
@ -234,13 +237,101 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
{/* 년도 선택 뷰 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<div className="text-sm font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
{/* 월 선택 뷰 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -288,6 +379,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(tempValue.from || tempValue.to) && (
|
||||
|
|
|
|||
|
|
@ -841,6 +841,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">위치</Label>
|
||||
<Select
|
||||
value={selectedComponent.style?.labelPosition || "top"}
|
||||
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">간격</Label>
|
||||
<Input
|
||||
value={
|
||||
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
|
||||
? (selectedComponent.style?.labelGap || "8px")
|
||||
: (selectedComponent.style?.labelMarginBottom || "4px")
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pos = selectedComponent.style?.labelPosition;
|
||||
if (pos === "left" || pos === "right") {
|
||||
handleUpdate("style.labelGap", e.target.value);
|
||||
} else {
|
||||
handleUpdate("style.labelMarginBottom", e.target.value);
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">크기</Label>
|
||||
|
|
@ -862,12 +900,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<Label className="text-xs">굵기</Label>
|
||||
<Select
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="400">보통</SelectItem>
|
||||
<SelectItem value="500">중간</SelectItem>
|
||||
<SelectItem value="600">굵게</SelectItem>
|
||||
<SelectItem value="700">매우 굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
|
|||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||
{selectedColumns.map((colName, index) => {
|
||||
const col = table?.columns.find(
|
||||
(c) => c.columnName === colName
|
||||
|
|
|
|||
|
|
@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||
{selectedGroupColumns.map((colName, index) => {
|
||||
const col = table?.columns.find((c) => c.columnName === colName);
|
||||
if (!col) return null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
||||
|
||||
|
|
@ -10,99 +25,341 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
const formatDateValue = (val: string) => {
|
||||
if (!val) return "";
|
||||
const isDatetime = widget.widgetType === "datetime";
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const [timeValue, setTimeValue] = useState("00:00");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [typingValue, setTypingValue] = useState("");
|
||||
|
||||
const parseDate = (val: string | undefined): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return val;
|
||||
|
||||
if (widget.widgetType === "datetime") {
|
||||
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
|
||||
} else {
|
||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
} catch {
|
||||
return val;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 유효성 검증
|
||||
const validateDate = (dateStr: string): boolean => {
|
||||
if (!dateStr) return true;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return false;
|
||||
|
||||
// 최소/최대 날짜 검증
|
||||
if (config?.minDate) {
|
||||
const minDate = new Date(config.minDate);
|
||||
if (date < minDate) return false;
|
||||
}
|
||||
|
||||
if (config?.maxDate) {
|
||||
const maxDate = new Date(config.maxDate);
|
||||
if (date > maxDate) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 입력값 처리
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (validateDate(inputValue)) {
|
||||
onChange?.(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 웹타입에 따른 input type 결정
|
||||
const getInputType = () => {
|
||||
switch (widget.widgetType) {
|
||||
case "datetime":
|
||||
return "datetime-local";
|
||||
case "date":
|
||||
default:
|
||||
return "date";
|
||||
}
|
||||
};
|
||||
|
||||
// 기본값 설정 (현재 날짜/시간)
|
||||
const getDefaultValue = () => {
|
||||
const getDefaultValue = (): string => {
|
||||
if (config?.defaultValue === "current") {
|
||||
const now = new Date();
|
||||
if (widget.widgetType === "datetime") {
|
||||
return now.toISOString().slice(0, 16);
|
||||
} else {
|
||||
if (isDatetime) return now.toISOString().slice(0, 16);
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const finalValue = value || getDefaultValue();
|
||||
const selectedDate = parseDate(finalValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setViewMode("calendar");
|
||||
if (selectedDate) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
if (isDatetime) {
|
||||
const hours = String(selectedDate.getHours()).padStart(2, "0");
|
||||
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
|
||||
setTimeValue(`${hours}:${minutes}`);
|
||||
}
|
||||
} else {
|
||||
setCurrentMonth(new Date());
|
||||
setTimeValue("00:00");
|
||||
}
|
||||
} else {
|
||||
setIsTyping(false);
|
||||
setTypingValue("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDisplayValue = (): string => {
|
||||
if (!selectedDate) return "";
|
||||
if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
let dateStr: string;
|
||||
if (isDatetime) {
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0);
|
||||
dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`;
|
||||
} else {
|
||||
dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
onChange?.(dateStr);
|
||||
if (!isDatetime) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTime: string) => {
|
||||
setTimeValue(newTime);
|
||||
if (selectedDate) {
|
||||
const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`;
|
||||
onChange?.(dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange?.("");
|
||||
setIsTyping(false);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleTriggerInput = (raw: string) => {
|
||||
setIsTyping(true);
|
||||
setTypingValue(raw);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||
let dateStr: string;
|
||||
if (isDatetime) {
|
||||
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`;
|
||||
} else {
|
||||
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
}
|
||||
onChange?.(dateStr);
|
||||
setCurrentMonth(new Date(y, m, 1));
|
||||
if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
|
||||
else setIsTyping(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetToday = () => {
|
||||
const today = new Date();
|
||||
if (isDatetime) {
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
|
||||
onChange?.(dateStr);
|
||||
} else {
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
onChange?.(dateStr);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={getInputType()}
|
||||
value={formatDateValue(finalValue)}
|
||||
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||
readonly && "cursor-not-allowed opacity-50",
|
||||
!selectedDate && !isTyping && "text-muted-foreground",
|
||||
borderClass,
|
||||
)}
|
||||
onClick={() => { if (!readonly) setIsOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||
onChange={handleChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={`h-full w-full ${borderClass}`}
|
||||
min={config?.minDate}
|
||||
max={config?.maxDate}
|
||||
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }}
|
||||
onBlur={() => { if (!isOpen) setIsTyping(false); }}
|
||||
className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
{selectedDate && !readonly && !isTyping && (
|
||||
<X
|
||||
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-2" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* datetime 타입: 시간 입력 */}
|
||||
{isDatetime && viewMode === "calendar" && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">시간:</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
DateWidget.displayName = "DateWidget";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { FolderTree, Loader2, Search, X } from "lucide-react";
|
||||
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface CategoryColumn {
|
||||
|
|
@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// 검색어로 필터링된 컬럼 목록
|
||||
const filteredColumns = useMemo(() => {
|
||||
|
|
@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
});
|
||||
}, [columns, searchQuery]);
|
||||
|
||||
// 테이블별로 그룹화된 컬럼 목록
|
||||
const groupedColumns = useMemo(() => {
|
||||
const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = [];
|
||||
const groupMap = new Map<string, CategoryColumn[]>();
|
||||
|
||||
for (const col of filteredColumns) {
|
||||
const key = col.tableName;
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, []);
|
||||
}
|
||||
groupMap.get(key)!.push(col);
|
||||
}
|
||||
|
||||
for (const [tblName, cols] of groupMap) {
|
||||
groups.push({
|
||||
tableName: tblName,
|
||||
tableLabel: cols[0]?.tableLabel || tblName,
|
||||
columns: cols,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredColumns]);
|
||||
|
||||
// 선택된 컬럼이 있는 그룹을 자동 펼침
|
||||
useEffect(() => {
|
||||
if (!selectedColumn) return;
|
||||
const tableName = selectedColumn.split(".")[0];
|
||||
if (tableName) {
|
||||
setExpandedGroups((prev) => {
|
||||
if (prev.has(tableName)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(tableName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [selectedColumn]);
|
||||
|
||||
useEffect(() => {
|
||||
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
|
||||
loadCategoryColumnsByMenu();
|
||||
|
|
@ -279,15 +318,26 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
{filteredColumns.length === 0 && searchQuery ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||
</div>
|
||||
) : null}
|
||||
{filteredColumns.map((column) => {
|
||||
{groupedColumns.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.tableName);
|
||||
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
|
||||
const hasSelectedInGroup = group.columns.some(
|
||||
(c) => selectedColumn === `${c.tableName}.${c.columnName}`,
|
||||
);
|
||||
|
||||
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
|
||||
if (groupedColumns.length <= 1) {
|
||||
return (
|
||||
<div key={group.tableName} className="space-y-1.5">
|
||||
{group.columns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
|
||||
const isSelected = selectedColumn === uniqueKey;
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
|
|
@ -312,6 +362,74 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.tableName} className="overflow-hidden rounded-lg border">
|
||||
{/* 드롭다운 헤더 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.tableName)) {
|
||||
next.delete(group.tableName);
|
||||
} else {
|
||||
next.add(group.tableName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
|
||||
{group.tableLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{group.columns.length}개 컬럼 / {totalValues}개 값
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 펼쳐진 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 border-t px-2 py-2">
|
||||
{group.columns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey;
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||
className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${
|
||||
isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="flex-1 text-xs">{column.columnLabel || column.columnName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/80",
|
||||
"fixed inset-0 z-[1050] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@
|
|||
* - range 옵션: 범위 선택 (시작~종료)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
||||
|
|
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
|
|||
return format(date, dateFnsFormat);
|
||||
}
|
||||
|
||||
// YYYYMMDD 또는 YYYY-MM-DD 문자열 → 유효한 Date 객체 반환 (유효하지 않으면 null)
|
||||
function parseManualDateInput(raw: string): Date | null {
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length !== 8) return null;
|
||||
const y = parseInt(digits.slice(0, 4), 10);
|
||||
const m = parseInt(digits.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digits.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return null;
|
||||
if (y < 1900 || y > 2100) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 날짜 선택 컴포넌트
|
||||
*/
|
||||
const SingleDatePicker = forwardRef<
|
||||
HTMLButtonElement,
|
||||
HTMLDivElement,
|
||||
{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
|
|
@ -83,81 +95,228 @@ const SingleDatePicker = forwardRef<
|
|||
ref,
|
||||
) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [typingValue, setTypingValue] = useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
|
||||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
||||
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
||||
|
||||
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
|
||||
const displayText = useMemo(() => {
|
||||
if (!value) return "";
|
||||
// Date 객체로 변환 후 포맷팅
|
||||
if (date && isValid(date)) {
|
||||
return formatDate(date, dateFormat);
|
||||
}
|
||||
if (date && isValid(date)) return formatDate(date, dateFormat);
|
||||
return value;
|
||||
}, [value, date, dateFormat]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
onChange?.(formatDate(selectedDate, dateFormat));
|
||||
setOpen(false);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setViewMode("calendar");
|
||||
if (date && isValid(date)) {
|
||||
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
|
||||
setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12);
|
||||
} else {
|
||||
setCurrentMonth(new Date());
|
||||
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
}
|
||||
},
|
||||
[dateFormat, onChange],
|
||||
);
|
||||
} else {
|
||||
setIsTyping(false);
|
||||
setTypingValue("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleDateClick = useCallback((clickedDate: Date) => {
|
||||
onChange?.(formatDate(clickedDate, dateFormat));
|
||||
setIsTyping(false);
|
||||
setOpen(false);
|
||||
}, [dateFormat, onChange]);
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
onChange?.(formatDate(new Date(), dateFormat));
|
||||
setIsTyping(false);
|
||||
setOpen(false);
|
||||
}, [dateFormat, onChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange?.("");
|
||||
setIsTyping(false);
|
||||
setOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleTriggerInput = useCallback((raw: string) => {
|
||||
setIsTyping(true);
|
||||
setTypingValue(raw);
|
||||
if (!open) setOpen(true);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const parsed = parseManualDateInput(digitsOnly);
|
||||
if (parsed) {
|
||||
onChange?.(formatDate(parsed, dateFormat));
|
||||
setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1));
|
||||
setTimeout(() => { setIsTyping(false); setOpen(false); }, 400);
|
||||
}
|
||||
}
|
||||
}, [dateFormat, onChange, open]);
|
||||
|
||||
const mStart = startOfMonth(currentMonth);
|
||||
const mEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: mStart, end: mEnd });
|
||||
const dow = mStart.getDay();
|
||||
const padding = dow === 0 ? 6 : dow - 1;
|
||||
const allDays = [...Array(padding).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
<div
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"h-full w-full justify-start text-left font-normal",
|
||||
!displayText && "text-muted-foreground",
|
||||
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
{displayText || placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleSelect}
|
||||
initialFocus
|
||||
locale={ko}
|
||||
disabled={(date) => {
|
||||
if (minDateObj && date < minDateObj) return true;
|
||||
if (maxDateObj && date > maxDateObj) return true;
|
||||
return false;
|
||||
}}
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (displayText || "")}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }}
|
||||
onBlur={() => { if (!open) setIsTyping(false); }}
|
||||
className={cn(
|
||||
"h-full w-full bg-transparent text-sm outline-none",
|
||||
"placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
||||
!displayText && !isTyping && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2 p-3 pt-0">
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{showToday && (
|
||||
<Button variant="outline" size="sm" onClick={handleToday}>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
|
||||
오늘
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
|
||||
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{allDays.map((d, idx) => {
|
||||
if (!d) return <div key={idx} className="p-2" />;
|
||||
const isCur = isSameMonth(d, currentMonth);
|
||||
const isSel = date ? isSameDay(d, date) : false;
|
||||
const isT = isTodayFn(d);
|
||||
return (
|
||||
<Button
|
||||
key={d.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCur && "text-muted-foreground opacity-50",
|
||||
isSel && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isT && !isSel && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(d)}
|
||||
disabled={!isCur}
|
||||
>
|
||||
{format(d, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker";
|
|||
/**
|
||||
* 날짜 범위 선택 컴포넌트
|
||||
*/
|
||||
/**
|
||||
* 범위 날짜 팝오버 내부 캘린더 (drill-down 지원)
|
||||
*/
|
||||
const RangeCalendarPopover: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedDate?: Date;
|
||||
onSelect: (date: Date) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
displayValue?: string;
|
||||
}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [typingValue, setTypingValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setViewMode("calendar");
|
||||
if (selectedDate && isValid(selectedDate)) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||
} else {
|
||||
setCurrentMonth(new Date());
|
||||
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
}
|
||||
} else {
|
||||
setIsTyping(false);
|
||||
setTypingValue("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleTriggerInput = (raw: string) => {
|
||||
setIsTyping(true);
|
||||
setTypingValue(raw);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const parsed = parseManualDateInput(digitsOnly);
|
||||
if (parsed) {
|
||||
setIsTyping(false);
|
||||
onSelect(parsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mStart = startOfMonth(currentMonth);
|
||||
const mEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: mStart, end: mEnd });
|
||||
const dow = mStart.getDay();
|
||||
const padding = dow === 0 ? 6 : dow - 1;
|
||||
const allDays = [...Array(padding).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(v) => { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||
!displayValue && !isTyping && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readonly) onOpenChange(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (displayValue || "")}
|
||||
placeholder={label}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }}
|
||||
onBlur={() => { if (!open) setIsTyping(false); }}
|
||||
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button key={year} variant="ghost" size="sm" className={cn("h-9 text-xs", year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary", year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border")}
|
||||
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}>{year}</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{currentMonth.getFullYear()}년</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button key={month} variant="ghost" size="sm" className={cn("h-9 text-xs", month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary", month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border")}
|
||||
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}>{month + 1}월</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{format(currentMonth, "yyyy년 MM월", { locale: ko })}</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
|
||||
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{allDays.map((d, idx) => {
|
||||
if (!d) return <div key={idx} className="p-2" />;
|
||||
const isCur = isSameMonth(d, currentMonth);
|
||||
const isSel = selectedDate ? isSameDay(d, selectedDate) : false;
|
||||
const isT = isTodayFn(d);
|
||||
return (
|
||||
<Button key={d.toISOString()} variant="ghost" size="sm" className={cn("h-8 w-8 p-0 text-xs", !isCur && "text-muted-foreground opacity-50", isSel && "bg-primary text-primary-foreground hover:bg-primary", isT && !isSel && "border-primary border")}
|
||||
onClick={() => onSelect(d)} disabled={!isCur}>{format(d, "d")}</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const RangeDatePicker = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
|
|
@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef<
|
|||
|
||||
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
|
||||
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
|
||||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
||||
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
||||
|
||||
const handleStartSelect = useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (date) {
|
||||
(date: Date) => {
|
||||
const newStart = formatDate(date, dateFormat);
|
||||
// 시작일이 종료일보다 크면 종료일도 같이 변경
|
||||
if (endDate && date > endDate) {
|
||||
onChange?.([newStart, newStart]);
|
||||
} else {
|
||||
onChange?.([newStart, value[1]]);
|
||||
}
|
||||
setOpenStart(false);
|
||||
}
|
||||
},
|
||||
[value, dateFormat, endDate, onChange],
|
||||
);
|
||||
|
||||
const handleEndSelect = useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (date) {
|
||||
(date: Date) => {
|
||||
const newEnd = formatDate(date, dateFormat);
|
||||
// 종료일이 시작일보다 작으면 시작일도 같이 변경
|
||||
if (startDate && date < startDate) {
|
||||
onChange?.([newEnd, newEnd]);
|
||||
} else {
|
||||
onChange?.([value[0], newEnd]);
|
||||
}
|
||||
setOpenEnd(false);
|
||||
}
|
||||
},
|
||||
[value, dateFormat, startDate, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
||||
{/* 시작 날짜 */}
|
||||
<Popover open={openStart} onOpenChange={setOpenStart}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value[0] || "시작일"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={handleStartSelect}
|
||||
initialFocus
|
||||
locale={ko}
|
||||
disabled={(date) => {
|
||||
if (minDateObj && date < minDateObj) return true;
|
||||
if (maxDateObj && date > maxDateObj) return true;
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
|
||||
<span className="text-muted-foreground">~</span>
|
||||
|
||||
{/* 종료 날짜 */}
|
||||
<Popover open={openEnd} onOpenChange={setOpenEnd}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value[1] || "종료일"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={endDate}
|
||||
onSelect={handleEndSelect}
|
||||
initialFocus
|
||||
locale={ko}
|
||||
disabled={(date) => {
|
||||
if (minDateObj && date < minDateObj) return true;
|
||||
if (maxDateObj && date > maxDateObj) return true;
|
||||
// 시작일보다 이전 날짜는 선택 불가
|
||||
if (startDate && date < startDate) return true;
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -462,14 +700,60 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||
// 라벨 위치 및 높이 계산
|
||||
const labelPos = style?.labelPosition || "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||
const labelGapValue = style?.labelGap || "8px";
|
||||
|
||||
const labelElement = showLabel ? (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
||||
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#64748b",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
) : null;
|
||||
|
||||
const dateContent = (
|
||||
<div className={isHorizLabel ? "min-w-0 flex-1" : "h-full w-full"} style={isHorizLabel ? { height: "100%" } : undefined}>
|
||||
{renderDatePicker()}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isHorizLabel && showLabel) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
display: "flex",
|
||||
flexDirection: labelPos === "left" ? "row" : "row-reverse",
|
||||
alignItems: "center",
|
||||
gap: labelGapValue,
|
||||
}}
|
||||
>
|
||||
{labelElement}
|
||||
{dateContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -481,27 +765,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#64748b",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="h-full w-full">
|
||||
{renderDatePicker()}
|
||||
</div>
|
||||
{labelElement}
|
||||
{dateContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -961,36 +961,83 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
}
|
||||
};
|
||||
|
||||
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
|
||||
const actualLabel = label || style?.labelText;
|
||||
const showLabel = actualLabel && style?.labelDisplay === true;
|
||||
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||
const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||
// 라벨 위치 및 높이 계산
|
||||
const labelPos = style?.labelPosition || "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||
const labelGapValue = style?.labelGap || "8px";
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
|
||||
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
|
||||
// 커스텀 스타일 감지
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
|
||||
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
|
||||
|
||||
const labelElement = showLabel ? (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
||||
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#64748b",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{actualLabel}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
) : null;
|
||||
|
||||
const inputContent = (
|
||||
<div
|
||||
className={cn(
|
||||
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
|
||||
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
|
||||
)}
|
||||
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
|
||||
>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isHorizLabel && showLabel) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
display: "flex",
|
||||
flexDirection: labelPos === "left" ? "row" : "row-reverse",
|
||||
alignItems: "center",
|
||||
gap: labelGapValue,
|
||||
}}
|
||||
>
|
||||
{labelElement}
|
||||
{inputContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -1001,38 +1048,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#64748b",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{actualLabel}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
|
||||
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
|
||||
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
|
||||
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
|
||||
)}
|
||||
style={hasCustomText ? customTextStyle : undefined}
|
||||
>
|
||||
{renderInput()}
|
||||
</div>
|
||||
{labelElement}
|
||||
{inputContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,11 +48,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
onRowClick,
|
||||
className,
|
||||
formData: parentFormData,
|
||||
groupedData,
|
||||
...restProps
|
||||
}) => {
|
||||
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
||||
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
||||
|
||||
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
||||
const effectiveComponentId = componentId || (restProps as any).component?.id;
|
||||
|
||||
|
|
@ -214,21 +212,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const isModalMode = config.renderMode === "modal";
|
||||
|
||||
// 전역 리피터 등록
|
||||
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
||||
// tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요)
|
||||
useEffect(() => {
|
||||
const targetTableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
const registrationKey = targetTableName || "__v2_repeater_same_table__";
|
||||
|
||||
if (targetTableName) {
|
||||
if (!window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances = new Set();
|
||||
}
|
||||
window.__v2RepeaterInstances.add(targetTableName);
|
||||
}
|
||||
window.__v2RepeaterInstances.add(registrationKey);
|
||||
|
||||
return () => {
|
||||
if (targetTableName && window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances.delete(targetTableName);
|
||||
if (window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances.delete(registrationKey);
|
||||
}
|
||||
};
|
||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
||||
|
|
@ -423,21 +420,73 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
fkValue,
|
||||
});
|
||||
|
||||
let rows: any[] = [];
|
||||
const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin;
|
||||
|
||||
if (useEntityJoinForLoad) {
|
||||
// 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인)
|
||||
const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue });
|
||||
const params: Record<string, any> = {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
search: searchParam,
|
||||
enableEntityJoin: true,
|
||||
autoFilter: JSON.stringify({ enabled: true }),
|
||||
};
|
||||
const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns;
|
||||
if (addJoinCols && addJoinCols.length > 0) {
|
||||
params.additionalJoinColumns = JSON.stringify(addJoinCols);
|
||||
}
|
||||
const response = await apiClient.get(
|
||||
`/table-management/tables/${config.mainTableName}/data-with-joins`,
|
||||
{ params }
|
||||
);
|
||||
const resultData = response.data?.data;
|
||||
const rawRows = Array.isArray(resultData)
|
||||
? resultData
|
||||
: resultData?.data || resultData?.rows || [];
|
||||
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거
|
||||
const seenIds = new Set<string>();
|
||||
rows = rawRows.filter((row: any) => {
|
||||
if (!row.id || seenIds.has(row.id)) return false;
|
||||
seenIds.add(row.id);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${config.mainTableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 1000,
|
||||
search: { [config.foreignKeyColumn]: fkValue },
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
|
||||
},
|
||||
autoFilter: true,
|
||||
}
|
||||
);
|
||||
rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||
}
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
|
||||
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
|
||||
|
||||
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
|
||||
// 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강
|
||||
const columnMapping = config.sourceDetailConfig?.columnMapping;
|
||||
if (useEntityJoinForLoad && columnMapping) {
|
||||
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
||||
rows.forEach((row: any) => {
|
||||
sourceDisplayColumns.forEach((col) => {
|
||||
const mappedKey = columnMapping[col.key];
|
||||
const value = mappedKey ? row[mappedKey] : row[col.key];
|
||||
row[`_display_${col.key}`] = value ?? "";
|
||||
});
|
||||
});
|
||||
console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료");
|
||||
}
|
||||
|
||||
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
|
||||
if (!useEntityJoinForLoad) {
|
||||
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
||||
const sourceTable = config.dataSource?.sourceTable;
|
||||
const fkColumn = config.dataSource?.foreignKey;
|
||||
|
|
@ -449,7 +498,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const uniqueValues = [...new Set(fkValues)];
|
||||
|
||||
if (uniqueValues.length > 0) {
|
||||
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
|
||||
const sourcePromises = uniqueValues.map((val) =>
|
||||
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||
page: 1, size: 1,
|
||||
|
|
@ -464,7 +512,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
|
||||
});
|
||||
|
||||
// 각 행에 소스 테이블의 표시 데이터 병합
|
||||
rows.forEach((row: any) => {
|
||||
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
||||
if (sourceRecord) {
|
||||
|
|
@ -481,6 +528,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
||||
const codesToResolve = new Set<string>();
|
||||
|
|
@ -965,37 +1013,97 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
[],
|
||||
);
|
||||
|
||||
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함)
|
||||
const groupedDataProcessedRef = useRef(false);
|
||||
// sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면
|
||||
// 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅
|
||||
const sourceDetailLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return;
|
||||
if (groupedDataProcessedRef.current) return;
|
||||
if (sourceDetailLoadedRef.current) return;
|
||||
if (!groupedData || groupedData.length === 0) return;
|
||||
if (!config.sourceDetailConfig) return;
|
||||
|
||||
groupedDataProcessedRef.current = true;
|
||||
const { tableName, foreignKey, parentKey } = config.sourceDetailConfig;
|
||||
if (!tableName || !foreignKey || !parentKey) return;
|
||||
|
||||
const newRows = groupedData.map((item: any, index: number) => {
|
||||
const row: any = { _id: `grouped_${Date.now()}_${index}` };
|
||||
const parentKeys = groupedData
|
||||
.map((row) => row[parentKey])
|
||||
.filter((v) => v !== undefined && v !== null && v !== "");
|
||||
|
||||
for (const col of config.columns) {
|
||||
let sourceValue = item[(col as any).sourceKey || col.key];
|
||||
if (parentKeys.length === 0) return;
|
||||
|
||||
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
|
||||
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
|
||||
sourceValue = categoryLabelMap[sourceValue];
|
||||
sourceDetailLoadedRef.current = true;
|
||||
|
||||
const loadSourceDetails = async () => {
|
||||
try {
|
||||
const uniqueKeys = [...new Set(parentKeys)] as string[];
|
||||
const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!;
|
||||
|
||||
let detailRows: any[] = [];
|
||||
|
||||
if (useEntityJoin) {
|
||||
// data-with-joins GET API 사용 (엔티티 조인 자동 적용)
|
||||
const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") });
|
||||
const params: Record<string, any> = {
|
||||
page: 1,
|
||||
size: 9999,
|
||||
search: searchParam,
|
||||
enableEntityJoin: true,
|
||||
autoFilter: JSON.stringify({ enabled: true }),
|
||||
};
|
||||
if (additionalJoinColumns && additionalJoinColumns.length > 0) {
|
||||
params.additionalJoinColumns = JSON.stringify(additionalJoinColumns);
|
||||
}
|
||||
|
||||
if (col.isSourceDisplay) {
|
||||
row[col.key] = sourceValue ?? "";
|
||||
row[`_display_${col.key}`] = sourceValue ?? "";
|
||||
} else if (col.autoFill && col.autoFill.type !== "none") {
|
||||
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||
if (autoValue !== undefined) {
|
||||
row[col.key] = autoValue;
|
||||
const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params });
|
||||
const resultData = resp.data?.data;
|
||||
const rawRows = Array.isArray(resultData)
|
||||
? resultData
|
||||
: resultData?.data || resultData?.rows || [];
|
||||
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거
|
||||
const seenIds = new Set<string>();
|
||||
detailRows = rawRows.filter((row: any) => {
|
||||
if (!row.id || seenIds.has(row.id)) return false;
|
||||
seenIds.add(row.id);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
row[col.key] = "";
|
||||
// 기존 POST API 사용
|
||||
const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||
page: 1,
|
||||
size: 9999,
|
||||
search: { [foreignKey]: uniqueKeys },
|
||||
});
|
||||
const resultData = resp.data?.data;
|
||||
detailRows = Array.isArray(resultData)
|
||||
? resultData
|
||||
: resultData?.data || resultData?.rows || [];
|
||||
}
|
||||
} else if (sourceValue !== undefined) {
|
||||
row[col.key] = sourceValue;
|
||||
|
||||
if (detailRows.length === 0) {
|
||||
console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : "");
|
||||
|
||||
// 디테일 행을 리피터 컬럼에 매핑
|
||||
const newRows = detailRows.map((detail, index) => {
|
||||
const row: any = { _id: `src_detail_${Date.now()}_${index}` };
|
||||
for (const col of config.columns) {
|
||||
if (col.isSourceDisplay) {
|
||||
// columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용)
|
||||
const mappedKey = columnMapping?.[col.key];
|
||||
const value = mappedKey ? detail[mappedKey] : detail[col.key];
|
||||
row[`_display_${col.key}`] = value ?? "";
|
||||
// 원본 값도 저장 (DB persist용 - _display_ 접두사 없이)
|
||||
if (detail[col.key] !== undefined) {
|
||||
row[col.key] = detail[col.key];
|
||||
}
|
||||
} else if (col.autoFill) {
|
||||
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||
row[col.key] = autoValue ?? "";
|
||||
} else if (col.sourceKey && detail[col.sourceKey] !== undefined) {
|
||||
row[col.key] = detail[col.sourceKey];
|
||||
} else if (detail[col.key] !== undefined) {
|
||||
row[col.key] = detail[col.key];
|
||||
} else {
|
||||
row[col.key] = "";
|
||||
}
|
||||
|
|
@ -1003,52 +1111,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
return row;
|
||||
});
|
||||
|
||||
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
|
||||
const categoryColSet = new Set(allCategoryColumns);
|
||||
const codesToResolve = new Set<string>();
|
||||
for (const row of newRows) {
|
||||
for (const col of config.columns) {
|
||||
const val = row[col.key] || row[`_display_${col.key}`];
|
||||
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
|
||||
if (!categoryLabelMap[val]) {
|
||||
codesToResolve.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codesToResolve.size > 0) {
|
||||
apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(codesToResolve),
|
||||
}).then((resp) => {
|
||||
if (resp.data?.success && resp.data.data) {
|
||||
const labelData = resp.data.data as Record<string, string>;
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||
const convertedRows = newRows.map((row) => {
|
||||
const updated = { ...row };
|
||||
for (const col of config.columns) {
|
||||
const val = updated[col.key];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
updated[col.key] = labelData[val];
|
||||
}
|
||||
const dispKey = `_display_${col.key}`;
|
||||
const dispVal = updated[dispKey];
|
||||
if (typeof dispVal === "string" && labelData[dispVal]) {
|
||||
updated[dispKey] = labelData[dispVal];
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setData(convertedRows);
|
||||
onDataChange?.(convertedRows);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setData(newRows);
|
||||
onDataChange?.(newRows);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groupedData, config.columns, generateAutoFillValueSync]);
|
||||
} catch (error) {
|
||||
console.error("[V2Repeater] sourceDetail 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSourceDetails();
|
||||
}, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]);
|
||||
|
||||
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { V2SelectProps, SelectOption } from "@/types/v2-components";
|
||||
import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components";
|
||||
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import V2FormContext from "./V2FormContext";
|
||||
|
|
@ -80,7 +80,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options
|
||||
.filter((option) => option.value !== "")
|
||||
.filter((option) => option.value != null && option.value !== "")
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -112,6 +112,12 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
}
|
||||
|
||||
// 검색 가능 또는 다중 선택 → Combobox 사용
|
||||
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
|
||||
const safeOptions = useMemo(() =>
|
||||
options.filter((o) => o.value != null && o.value !== ""),
|
||||
[options]
|
||||
);
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
|
|
@ -119,9 +125,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return selectedValues
|
||||
.map((v) => options.find((o) => o.value === v)?.label)
|
||||
.map((v) => safeOptions.find((o) => o.value === v)?.label)
|
||||
.filter(Boolean) as string[];
|
||||
}, [selectedValues, options]);
|
||||
}, [selectedValues, safeOptions]);
|
||||
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
if (multiple) {
|
||||
|
|
@ -191,7 +197,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
<Command
|
||||
filter={(itemValue, search) => {
|
||||
if (!search) return 1;
|
||||
const option = options.find((o) => o.value === itemValue);
|
||||
const option = safeOptions.find((o) => o.value === itemValue);
|
||||
const label = (option?.label || option?.value || "").toLowerCase();
|
||||
if (label.includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
|
|
@ -201,7 +207,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
{safeOptions.map((option) => {
|
||||
const displayLabel = option.label || option.value || "(빈 값)";
|
||||
return (
|
||||
<CommandItem
|
||||
|
|
@ -655,6 +661,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const labelColumn = config.labelColumn;
|
||||
const apiEndpoint = config.apiEndpoint;
|
||||
const staticOptions = config.options;
|
||||
const configFilters = config.filters;
|
||||
|
||||
// 계층 코드 연쇄 선택 관련
|
||||
const hierarchical = config.hierarchical;
|
||||
|
|
@ -663,6 +670,54 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
|
||||
const formContext = useContext(V2FormContext);
|
||||
|
||||
/**
|
||||
* 필터 조건을 API 전달용 JSON으로 변환
|
||||
* field/user 타입은 런타임 값으로 치환
|
||||
*/
|
||||
const resolvedFiltersJson = useMemo(() => {
|
||||
if (!configFilters || configFilters.length === 0) return undefined;
|
||||
|
||||
const resolved: Array<{ column: string; operator: string; value: unknown }> = [];
|
||||
|
||||
for (const f of configFilters) {
|
||||
const vt = f.valueType || "static";
|
||||
|
||||
// isNull/isNotNull은 값 불필요
|
||||
if (f.operator === "isNull" || f.operator === "isNotNull") {
|
||||
resolved.push({ column: f.column, operator: f.operator, value: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolvedValue: unknown = f.value;
|
||||
|
||||
if (vt === "field" && f.fieldRef) {
|
||||
// 다른 폼 필드 참조
|
||||
if (formContext) {
|
||||
resolvedValue = formContext.getValue(f.fieldRef);
|
||||
} else {
|
||||
const fd = (props as any).formData;
|
||||
resolvedValue = fd?.[f.fieldRef];
|
||||
}
|
||||
// 참조 필드 값이 비어있으면 이 필터 건너뜀
|
||||
if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue;
|
||||
} else if (vt === "user" && f.userField) {
|
||||
// 로그인 사용자 정보 참조 (props에서 가져옴)
|
||||
const userMap: Record<string, string | undefined> = {
|
||||
companyCode: (props as any).companyCode,
|
||||
userId: (props as any).userId,
|
||||
deptCode: (props as any).deptCode,
|
||||
userName: (props as any).userName,
|
||||
};
|
||||
resolvedValue = userMap[f.userField];
|
||||
if (!resolvedValue) continue;
|
||||
}
|
||||
|
||||
resolved.push({ column: f.column, operator: f.operator, value: resolvedValue });
|
||||
}
|
||||
|
||||
return resolved.length > 0 ? JSON.stringify(resolved) : undefined;
|
||||
}, [configFilters, formContext, props]);
|
||||
|
||||
// 부모 필드의 값 계산
|
||||
const parentValue = useMemo(() => {
|
||||
if (!hierarchical || !parentField) return null;
|
||||
|
|
@ -684,6 +739,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
}
|
||||
}, [parentValue, hierarchical, source]);
|
||||
|
||||
// 필터 조건이 변경되면 옵션 다시 로드
|
||||
useEffect(() => {
|
||||
if (resolvedFiltersJson !== undefined) {
|
||||
setOptionsLoaded(false);
|
||||
}
|
||||
}, [resolvedFiltersJson]);
|
||||
|
||||
useEffect(() => {
|
||||
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
|
||||
if (optionsLoaded && source !== "static") {
|
||||
|
|
@ -731,11 +793,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
}
|
||||
} else if (source === "db" && table) {
|
||||
// DB 테이블에서 로드
|
||||
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||
params: {
|
||||
const dbParams: Record<string, any> = {
|
||||
value: valueColumn || "id",
|
||||
label: labelColumn || "name",
|
||||
},
|
||||
};
|
||||
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
|
||||
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||
params: dbParams,
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
|
|
@ -745,8 +809,10 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
// 엔티티(참조 테이블)에서 로드
|
||||
const valueCol = entityValueColumn || "id";
|
||||
const labelCol = entityLabelColumn || "name";
|
||||
const entityParams: Record<string, any> = { value: valueCol, label: labelCol };
|
||||
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
|
||||
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||
params: { value: valueCol, label: labelCol },
|
||||
params: entityParams,
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
|
|
@ -790,11 +856,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
}
|
||||
} else if (source === "select" || source === "distinct") {
|
||||
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
||||
// tableName, columnName은 props에서 가져옴
|
||||
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
|
||||
const isValidColumnName = columnName && !columnName.startsWith("comp_");
|
||||
if (tableName && isValidColumnName) {
|
||||
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
|
||||
const distinctParams: Record<string, any> = {};
|
||||
if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
|
||||
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
|
||||
params: distinctParams,
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||
|
|
@ -807,7 +875,11 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
}
|
||||
}
|
||||
|
||||
setOptions(fetchedOptions);
|
||||
// null/undefined value 필터링 (cmdk 크래시 방지)
|
||||
const sanitized = fetchedOptions.filter(
|
||||
(o) => o.value != null && String(o.value) !== ""
|
||||
).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) }));
|
||||
setOptions(sanitized);
|
||||
setOptionsLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("옵션 로딩 실패:", error);
|
||||
|
|
@ -818,7 +890,43 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
};
|
||||
|
||||
loadOptions();
|
||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
|
||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
|
||||
|
||||
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
|
||||
const resolvedValue = useMemo(() => {
|
||||
if (!value || options.length === 0) return value;
|
||||
|
||||
const resolveOne = (v: string): string => {
|
||||
if (options.some(o => o.value === v)) return v;
|
||||
const trimmed = v.trim();
|
||||
const match = options.find(o => {
|
||||
const cleanLabel = o.label.replace(/^[\s└]+/, '').trim();
|
||||
return cleanLabel === trimmed;
|
||||
});
|
||||
return match ? match.value : v;
|
||||
};
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const resolved = value.map(resolveOne);
|
||||
return resolved.every((v, i) => v === value[i]) ? value : resolved;
|
||||
}
|
||||
|
||||
// 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx")
|
||||
if (typeof value === "string" && value.includes(",")) {
|
||||
const parts = value.split(",");
|
||||
const resolved = parts.map(p => resolveOne(p.trim()));
|
||||
const result = resolved.join(",");
|
||||
return result === value ? value : result;
|
||||
}
|
||||
|
||||
return resolveOne(value);
|
||||
}, [value, options]);
|
||||
|
||||
// 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환)
|
||||
useEffect(() => {
|
||||
if (!onChange || options.length === 0 || !value || value === resolvedValue) return;
|
||||
onChange(resolvedValue as string | string[]);
|
||||
}, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
|
||||
const autoFillTargets = useMemo(() => {
|
||||
|
|
@ -945,7 +1053,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
value={resolvedValue}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder="선택"
|
||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||
|
|
@ -961,7 +1069,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<RadioSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -972,7 +1080,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -983,7 +1091,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<TagSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -994,7 +1102,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<TagboxSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder={config.placeholder || "선택하세요"}
|
||||
maxSelect={config.maxSelect}
|
||||
|
|
@ -1007,7 +1115,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<ToggleSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -1017,7 +1125,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<SwapSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -1028,7 +1136,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
value={resolvedValue}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
|
|
@ -1037,21 +1145,23 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||
// 라벨 위치 및 높이 계산
|
||||
const labelPos = style?.labelPosition || "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||
const labelGapValue = style?.labelGap || "8px";
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
// 커스텀 스타일 감지
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (CSS 상속)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
|
|
@ -1059,6 +1169,58 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
|
||||
const labelElement = showLabel ? (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
||||
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#64748b",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
) : null;
|
||||
|
||||
const selectContent = (
|
||||
<div
|
||||
className={cn(
|
||||
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
|
||||
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
|
||||
)}
|
||||
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
|
||||
>
|
||||
{renderSelect()}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isHorizLabel && showLabel) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(isDesignMode && "pointer-events-none")}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
display: "flex",
|
||||
flexDirection: labelPos === "left" ? "row" : "row-reverse",
|
||||
alignItems: "center",
|
||||
gap: labelGapValue,
|
||||
}}
|
||||
>
|
||||
{labelElement}
|
||||
{selectContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -1069,38 +1231,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#64748b",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
|
||||
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
|
||||
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
// 커스텀 배경 설정 시, 내부 요소를 투명하게
|
||||
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
|
||||
)}
|
||||
style={hasCustomText ? customTextStyle : undefined}
|
||||
>
|
||||
{renderSelect()}
|
||||
</div>
|
||||
{labelElement}
|
||||
{selectContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
Wand2,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
ListTree,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
|
|
@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* 소스 디테일 자동 조회 설정 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enableSourceDetail"
|
||||
checked={!!config.sourceDetailConfig}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateConfig({
|
||||
sourceDetailConfig: {
|
||||
tableName: "",
|
||||
foreignKey: "",
|
||||
parentKey: "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateConfig({ sourceDetailConfig: undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
|
||||
<ListTree className="h-3 w-3" />
|
||||
소스 디테일 자동 조회
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다.
|
||||
</p>
|
||||
|
||||
{config.sourceDetailConfig && (
|
||||
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">디테일 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{config.sourceDetailConfig.tableName
|
||||
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
|
||||
: "테이블 선택..."
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList className="max-h-48">
|
||||
<CommandEmpty className="text-xs py-3 text-center">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({
|
||||
sourceDetailConfig: {
|
||||
...config.sourceDetailConfig!,
|
||||
tableName: table.tableName,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<span>{table.displayName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">디테일 FK 컬럼</Label>
|
||||
<Input
|
||||
value={config.sourceDetailConfig.foreignKey || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig({
|
||||
sourceDetailConfig: {
|
||||
...config.sourceDetailConfig!,
|
||||
foreignKey: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="예: order_no"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">마스터 키 컬럼</Label>
|
||||
<Input
|
||||
value={config.sourceDetailConfig.parentKey || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig({
|
||||
sourceDetailConfig: {
|
||||
...config.sourceDetailConfig!,
|
||||
parentKey: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="예: order_no"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-violet-600">
|
||||
마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 →
|
||||
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 기능 옵션 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">기능 옵션</Label>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@
|
|||
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { Plus, Trash2, Loader2, Filter } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { V2SelectFilter } from "@/types/v2-components";
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
|
|
@ -25,6 +26,238 @@ interface CategoryValueOption {
|
|||
valueLabel: string;
|
||||
}
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "초과 (>)" },
|
||||
{ value: "<", label: "미만 (<)" },
|
||||
{ value: ">=", label: "이상 (>=)" },
|
||||
{ value: "<=", label: "이하 (<=)" },
|
||||
{ value: "in", label: "포함 (IN)" },
|
||||
{ value: "notIn", label: "미포함 (NOT IN)" },
|
||||
{ value: "like", label: "유사 (LIKE)" },
|
||||
{ value: "isNull", label: "NULL" },
|
||||
{ value: "isNotNull", label: "NOT NULL" },
|
||||
] as const;
|
||||
|
||||
const VALUE_TYPE_OPTIONS = [
|
||||
{ value: "static", label: "고정값" },
|
||||
{ value: "field", label: "폼 필드 참조" },
|
||||
{ value: "user", label: "로그인 사용자" },
|
||||
] as const;
|
||||
|
||||
const USER_FIELD_OPTIONS = [
|
||||
{ value: "companyCode", label: "회사코드" },
|
||||
{ value: "userId", label: "사용자ID" },
|
||||
{ value: "deptCode", label: "부서코드" },
|
||||
{ value: "userName", label: "사용자명" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 필터 조건 설정 서브 컴포넌트
|
||||
*/
|
||||
const FilterConditionsSection: React.FC<{
|
||||
filters: V2SelectFilter[];
|
||||
columns: ColumnOption[];
|
||||
loadingColumns: boolean;
|
||||
targetTable: string;
|
||||
onFiltersChange: (filters: V2SelectFilter[]) => void;
|
||||
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
|
||||
|
||||
const addFilter = () => {
|
||||
onFiltersChange([
|
||||
...filters,
|
||||
{ column: "", operator: "=", valueType: "static", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
|
||||
const updated = [...filters];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
|
||||
// valueType 변경 시 관련 필드 초기화
|
||||
if (patch.valueType) {
|
||||
if (patch.valueType === "static") {
|
||||
updated[index].fieldRef = undefined;
|
||||
updated[index].userField = undefined;
|
||||
} else if (patch.valueType === "field") {
|
||||
updated[index].value = undefined;
|
||||
updated[index].userField = undefined;
|
||||
} else if (patch.valueType === "user") {
|
||||
updated[index].value = undefined;
|
||||
updated[index].fieldRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// isNull/isNotNull 연산자는 값 불필요
|
||||
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
|
||||
updated[index].value = undefined;
|
||||
updated[index].fieldRef = undefined;
|
||||
updated[index].userField = undefined;
|
||||
updated[index].valueType = "static";
|
||||
}
|
||||
|
||||
onFiltersChange(updated);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Label className="text-xs font-medium">데이터 필터 조건</Label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addFilter}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건
|
||||
</p>
|
||||
|
||||
{loadingColumns && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
컬럼 목록 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.length === 0 && (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
필터 조건이 없습니다
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="space-y-1.5 rounded-md border p-2">
|
||||
{/* 행 1: 컬럼 + 연산자 + 삭제 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 컬럼 선택 */}
|
||||
<Select
|
||||
value={filter.column || ""}
|
||||
onValueChange={(v) => updateFilter(index, { column: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={filter.operator || "="}
|
||||
onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] shrink-0 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_OPTIONS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="text-destructive h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */}
|
||||
{needsValue(filter.operator) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 값 유형 */}
|
||||
<Select
|
||||
value={filter.valueType || "static"}
|
||||
onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VALUE_TYPE_OPTIONS.map((vt) => (
|
||||
<SelectItem key={vt.value} value={vt.value}>
|
||||
{vt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 영역 */}
|
||||
{(filter.valueType || "static") === "static" && (
|
||||
<Input
|
||||
value={String(filter.value ?? "")}
|
||||
onChange={(e) => updateFilter(index, { value: e.target.value })}
|
||||
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"}
|
||||
className="h-7 flex-1 text-[11px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filter.valueType === "field" && (
|
||||
<Input
|
||||
value={filter.fieldRef || ""}
|
||||
onChange={(e) => updateFilter(index, { fieldRef: e.target.value })}
|
||||
placeholder="참조할 필드명 (columnName)"
|
||||
className="h-7 flex-1 text-[11px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filter.valueType === "user" && (
|
||||
<Select
|
||||
value={filter.userField || ""}
|
||||
onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]">
|
||||
<SelectValue placeholder="사용자 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{USER_FIELD_OPTIONS.map((uf) => (
|
||||
<SelectItem key={uf.value} value={uf.value}>
|
||||
{uf.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface V2SelectConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
|
|
@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
|
||||
// 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
|
||||
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 필터 대상 테이블 결정
|
||||
const filterTargetTable = useMemo(() => {
|
||||
const src = config.source || "static";
|
||||
if (src === "entity") return config.entityTable;
|
||||
if (src === "db") return config.table;
|
||||
if (src === "distinct" || src === "select") return tableName;
|
||||
return null;
|
||||
}, [config.source, config.entityTable, config.table, tableName]);
|
||||
|
||||
// 필터 대상 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!filterTargetTable) {
|
||||
setFilterColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFilterColumns = async () => {
|
||||
setLoadingFilterColumns(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
|
||||
const data = response.data.data || response.data;
|
||||
const columns = data.columns || data || [];
|
||||
setFilterColumns(
|
||||
columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setFilterColumns([]);
|
||||
} finally {
|
||||
setLoadingFilterColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFilterColumns();
|
||||
}, [filterTargetTable]);
|
||||
|
||||
// 카테고리 타입이면 source를 자동으로 category로 설정
|
||||
useEffect(() => {
|
||||
if (isCategoryType && config.source !== "category") {
|
||||
|
|
@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
|
||||
{effectiveSource !== "static" && filterTargetTable && (
|
||||
<>
|
||||
<Separator />
|
||||
<FilterConditionsSection
|
||||
filters={(config.filters as V2SelectFilter[]) || []}
|
||||
columns={filterColumns}
|
||||
loadingColumns={loadingFilterColumns}
|
||||
targetTable={filterTargetTable}
|
||||
onFiltersChange={(filters) => updateConfig("filters", filters)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
|
||||
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
|
||||
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
|
||||
const isMultipleSelect = (component as any).componentConfig?.multiple;
|
||||
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
|
||||
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
|
||||
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode;
|
||||
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
|
||||
|
||||
if (
|
||||
(inputType === "category" || webType === "category") &&
|
||||
|
|
@ -370,15 +371,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
try {
|
||||
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
||||
const fieldName = columnName || component.id;
|
||||
const currentValue = props.formData?.[fieldName] || "";
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
if (props.onFormDataChange) {
|
||||
props.onFormDataChange(fieldName, value);
|
||||
}
|
||||
};
|
||||
// 수평 라벨 감지
|
||||
const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
||||
const catLabelPosition = component.style?.labelPosition;
|
||||
const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true")
|
||||
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
||||
: undefined;
|
||||
const catNeedsExternalHorizLabel = !!(
|
||||
catLabelText &&
|
||||
(catLabelPosition === "left" || catLabelPosition === "right")
|
||||
);
|
||||
|
||||
// V2SelectRenderer용 컴포넌트 데이터 구성
|
||||
const selectComponent = {
|
||||
...component,
|
||||
componentConfig: {
|
||||
|
|
@ -394,6 +398,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
webType: "category",
|
||||
};
|
||||
|
||||
const catStyle = catNeedsExternalHorizLabel
|
||||
? {
|
||||
...(component as any).style,
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
borderStyle: undefined,
|
||||
border: undefined,
|
||||
borderRadius: undefined,
|
||||
}
|
||||
: (component as any).style;
|
||||
const catSize = catNeedsExternalHorizLabel
|
||||
? { ...(component as any).size, width: undefined, height: undefined }
|
||||
: (component as any).size;
|
||||
|
||||
const rendererProps = {
|
||||
component: selectComponent,
|
||||
formData: props.formData,
|
||||
|
|
@ -401,12 +423,47 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
isDesignMode: props.isDesignMode,
|
||||
isInteractive: props.isInteractive ?? !props.isDesignMode,
|
||||
tableName,
|
||||
style: (component as any).style,
|
||||
size: (component as any).size,
|
||||
style: catStyle,
|
||||
size: catSize,
|
||||
};
|
||||
|
||||
const rendererInstance = new V2SelectRenderer(rendererProps);
|
||||
return rendererInstance.render();
|
||||
const renderedCatSelect = rendererInstance.render();
|
||||
|
||||
if (catNeedsExternalHorizLabel) {
|
||||
const labelGap = component.style?.labelGap || "8px";
|
||||
const labelFontSize = component.style?.labelFontSize || "14px";
|
||||
const labelColor = component.style?.labelColor || "#64748b";
|
||||
const labelFontWeight = component.style?.labelFontWeight || "500";
|
||||
const isRequired = component.required || (component as any).required;
|
||||
const isLeft = catLabelPosition === "left";
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
...(isLeft
|
||||
? { right: "100%", marginRight: labelGap }
|
||||
: { left: "100%", marginLeft: labelGap }),
|
||||
fontSize: labelFontSize,
|
||||
color: labelColor,
|
||||
fontWeight: labelFontWeight,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{catLabelText}
|
||||
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{renderedCatSelect}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderedCatSelect;
|
||||
} catch (error) {
|
||||
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
||||
}
|
||||
|
|
@ -545,10 +602,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
let currentValue;
|
||||
if (componentType === "modal-repeater-table" ||
|
||||
componentType === "repeat-screen-modal" ||
|
||||
componentType === "selected-items-detail-input" ||
|
||||
componentType === "v2-repeater") {
|
||||
componentType === "selected-items-detail-input") {
|
||||
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||
} else if (componentType === "v2-repeater") {
|
||||
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
|
||||
currentValue = formData?.[fieldName] || [];
|
||||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
|
@ -616,18 +675,39 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
componentType === "modal-repeater-table" ||
|
||||
componentType === "v2-input";
|
||||
|
||||
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
|
||||
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
|
||||
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
||||
const effectiveLabel = labelDisplay === true
|
||||
const effectiveLabel = (labelDisplay === true || labelDisplay === "true")
|
||||
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
||||
: undefined;
|
||||
|
||||
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
|
||||
const labelPosition = component.style?.labelPosition;
|
||||
const isV2Component = componentType?.startsWith("v2-");
|
||||
const needsExternalHorizLabel = !!(
|
||||
isV2Component &&
|
||||
effectiveLabel &&
|
||||
(labelPosition === "left" || labelPosition === "right")
|
||||
);
|
||||
|
||||
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
|
||||
const mergedStyle = {
|
||||
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
|
||||
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
|
||||
width: finalStyle.width,
|
||||
height: finalStyle.height,
|
||||
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
|
||||
...(needsExternalHorizLabel ? {
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
borderStyle: undefined,
|
||||
border: undefined,
|
||||
borderRadius: undefined,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
||||
|
|
@ -646,7 +726,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
size: needsExternalHorizLabel
|
||||
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
|
||||
: (component.size || newComponent.defaultSize),
|
||||
position: component.position,
|
||||
config: mergedComponentConfig,
|
||||
componentConfig: mergedComponentConfig,
|
||||
|
|
@ -654,8 +736,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
...(mergedComponentConfig || {}),
|
||||
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||
style: mergedStyle,
|
||||
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
||||
label: effectiveLabel,
|
||||
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
|
||||
label: needsExternalHorizLabel ? undefined : effectiveLabel,
|
||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
|
||||
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
|
||||
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
||||
|
|
@ -756,16 +838,51 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
NewComponentRenderer.prototype &&
|
||||
NewComponentRenderer.prototype.render;
|
||||
|
||||
let renderedElement: React.ReactElement;
|
||||
if (isClass) {
|
||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||
return rendererInstance.render();
|
||||
renderedElement = rendererInstance.render();
|
||||
} else {
|
||||
// 함수형 컴포넌트
|
||||
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
|
||||
|
||||
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
||||
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
||||
}
|
||||
|
||||
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
|
||||
if (needsExternalHorizLabel) {
|
||||
const labelGap = component.style?.labelGap || "8px";
|
||||
const labelFontSize = component.style?.labelFontSize || "14px";
|
||||
const labelColor = component.style?.labelColor || "#64748b";
|
||||
const labelFontWeight = component.style?.labelFontWeight || "500";
|
||||
const isRequired = component.required || (component as any).required;
|
||||
const isLeft = labelPosition === "left";
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
...(isLeft
|
||||
? { right: "100%", marginRight: labelGap }
|
||||
: { left: "100%", marginLeft: labelGap }),
|
||||
fontSize: labelFontSize,
|
||||
color: labelColor,
|
||||
fontWeight: labelFontWeight,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{effectiveLabel}
|
||||
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{renderedElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderedElement;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||
|
|
|
|||
|
|
@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="z-[99999]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
|
|
|
|||
|
|
@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
style,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as ImageDisplayConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const objectFit = componentConfig.objectFit || "contain";
|
||||
const altText = componentConfig.altText || "이미지";
|
||||
const borderRadius = componentConfig.borderRadius ?? 8;
|
||||
const showBorder = componentConfig.showBorder ?? true;
|
||||
const backgroundColor = componentConfig.backgroundColor || "#f9fafb";
|
||||
const placeholder = componentConfig.placeholder || "이미지 없음";
|
||||
|
||||
const imageSrc = component.value || componentConfig.imageUrl || "";
|
||||
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
|
|
@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{(component.required || componentConfig.required) && (
|
||||
<span style={{ color: "#ef4444" }}>*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
border: showBorder ? "1px solid #d1d5db" : "none",
|
||||
borderRadius: `${borderRadius}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
backgroundColor,
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none",
|
||||
opacity: componentConfig.disabled ? 0.5 : 1,
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "default",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!componentConfig.disabled) {
|
||||
if (showBorder) {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
}
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (showBorder) {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}
|
||||
e.currentTarget.style.boxShadow = showBorder
|
||||
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
|
||||
: "none";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{component.value || componentConfig.imageUrl ? (
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={component.value || componentConfig.imageUrl}
|
||||
alt={componentConfig.altText || "이미지"}
|
||||
src={imageSrc}
|
||||
alt={altText}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: componentConfig.objectFit || "contain",
|
||||
objectFit,
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
if (e.target?.parentElement) {
|
||||
e.target.parentElement.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
|
||||
<div style="font-size: 24px;">🖼️</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||
<div>이미지 로드 실패</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "32px" }}>🖼️</div>
|
||||
<div>이미지 없음</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
<div>{placeholder}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
|
||||
/**
|
||||
* ImageDisplay 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
||||
return <ImageDisplayComponent {...props} />;
|
||||
|
|
|
|||
|
|
@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
|
|||
|
||||
export interface ImageDisplayConfigPanelProps {
|
||||
config: ImageDisplayConfig;
|
||||
onChange: (config: Partial<ImageDisplayConfig>) => void;
|
||||
onChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageDisplay 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
const update = { ...config, [key]: value };
|
||||
onChange?.(update);
|
||||
onConfigChange?.(update);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
image-display 설정
|
||||
<div className="text-sm font-medium">이미지 표시 설정</div>
|
||||
|
||||
{/* 이미지 URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="imageUrl" className="text-xs">
|
||||
기본 이미지 URL
|
||||
</Label>
|
||||
<Input
|
||||
id="imageUrl"
|
||||
value={config.imageUrl || ""}
|
||||
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
데이터 바인딩 값이 없을 때 표시할 기본 이미지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* file 관련 설정 */}
|
||||
{/* 대체 텍스트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Label htmlFor="altText" className="text-xs">
|
||||
대체 텍스트 (alt)
|
||||
</Label>
|
||||
<Input
|
||||
id="altText"
|
||||
value={config.altText || ""}
|
||||
onChange={(e) => handleChange("altText", e.target.value)}
|
||||
placeholder="이미지 설명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이미지 맞춤 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="objectFit" className="text-xs">
|
||||
이미지 맞춤 (Object Fit)
|
||||
</Label>
|
||||
<Select
|
||||
value={config.objectFit || "contain"}
|
||||
onValueChange={(value) => handleChange("objectFit", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contain">Contain (비율 유지, 전체 표시)</SelectItem>
|
||||
<SelectItem value="cover">Cover (비율 유지, 영역 채움)</SelectItem>
|
||||
<SelectItem value="fill">Fill (영역에 맞춤)</SelectItem>
|
||||
<SelectItem value="none">None (원본 크기)</SelectItem>
|
||||
<SelectItem value="scale-down">Scale Down (축소만)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 둥글기 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius" className="text-xs">
|
||||
테두리 둥글기 (px)
|
||||
</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.borderRadius ?? 8}
|
||||
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor" className="text-xs">
|
||||
배경 색상
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor || "#f9fafb"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
value={config.backgroundColor || "#f9fafb"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플레이스홀더 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
이미지 없을 때 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
placeholder="이미지 없음"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
id="showBorder"
|
||||
checked={config.showBorder ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showBorder", checked)}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
{/* 읽기 전용 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs cursor-pointer">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 필수 입력 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
<Label htmlFor="required" className="text-xs cursor-pointer">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types";
|
|||
* ImageDisplay 컴포넌트 기본 설정
|
||||
*/
|
||||
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||
placeholder: "입력하세요",
|
||||
imageUrl: "",
|
||||
altText: "이미지",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
showBorder: true,
|
||||
backgroundColor: "#f9fafb",
|
||||
placeholder: "이미지 없음",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
|
|
@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
|||
|
||||
/**
|
||||
* ImageDisplay 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const ImageDisplayConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
imageUrl: { type: "string", default: "" },
|
||||
altText: { type: "string", default: "이미지" },
|
||||
objectFit: {
|
||||
type: "enum",
|
||||
values: ["contain", "cover", "fill", "none", "scale-down"],
|
||||
default: "contain",
|
||||
},
|
||||
borderRadius: { type: "number", default: 8 },
|
||||
showBorder: { type: "boolean", default: true },
|
||||
backgroundColor: { type: "string", default: "#f9fafb" },
|
||||
placeholder: { type: "string", default: "이미지 없음" },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
default: "default",
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
default: "md",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({
|
|||
webType: "file",
|
||||
component: ImageDisplayWrapper,
|
||||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
imageUrl: "",
|
||||
altText: "이미지",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
showBorder: true,
|
||||
backgroundColor: "#f9fafb",
|
||||
placeholder: "이미지 없음",
|
||||
},
|
||||
defaultSize: { width: 200, height: 200 },
|
||||
configPanel: ImageDisplayConfigPanel,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ import { ComponentConfig } from "@/types/component";
|
|||
* ImageDisplay 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface ImageDisplayConfig extends ComponentConfig {
|
||||
// file 관련 설정
|
||||
// 이미지 관련 설정
|
||||
imageUrl?: string;
|
||||
altText?: string;
|
||||
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
|
||||
borderRadius?: number;
|
||||
showBorder?: boolean;
|
||||
backgroundColor?: string;
|
||||
placeholder?: string;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
|
|
|
|||
|
|
@ -162,6 +162,79 @@ export function RepeaterTable({
|
|||
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용)
|
||||
const editableColIndices = useMemo(
|
||||
() => visibleColumns.reduce<number[]>((acc, col, idx) => {
|
||||
if (col.editable && !col.calculated) acc.push(idx);
|
||||
return acc;
|
||||
}, []),
|
||||
[visibleColumns],
|
||||
);
|
||||
|
||||
// 방향키로 리피터 셀 간 이동
|
||||
const handleArrowNavigation = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const key = e.key;
|
||||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const cell = target.closest("[data-repeater-row]") as HTMLElement | null;
|
||||
if (!cell) return;
|
||||
|
||||
const row = Number(cell.dataset.repeaterRow);
|
||||
const col = Number(cell.dataset.repeaterCol);
|
||||
if (isNaN(row) || isNaN(col)) return;
|
||||
|
||||
// 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시
|
||||
if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") {
|
||||
const input = target as HTMLInputElement;
|
||||
const len = input.value?.length ?? 0;
|
||||
const pos = input.selectionStart ?? 0;
|
||||
// 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동
|
||||
if (key === "ArrowRight" && pos < len) return;
|
||||
if (key === "ArrowLeft" && pos > 0) return;
|
||||
}
|
||||
|
||||
let nextRow = row;
|
||||
let nextColPos = editableColIndices.indexOf(col);
|
||||
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
nextRow = Math.max(0, row - 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
nextRow = Math.min(data.length - 1, row + 1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
nextColPos = Math.max(0, nextColPos - 1);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
const nextCol = editableColIndices[nextColPos];
|
||||
if (nextRow === row && nextCol === col) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`;
|
||||
const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null;
|
||||
if (!nextCell) return;
|
||||
|
||||
const focusable = nextCell.querySelector<HTMLElement>(
|
||||
'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])',
|
||||
);
|
||||
if (focusable) {
|
||||
focusable.focus();
|
||||
if (focusable.tagName === "INPUT") {
|
||||
(focusable as HTMLInputElement).select();
|
||||
}
|
||||
}
|
||||
},
|
||||
[editableColIndices, data.length],
|
||||
);
|
||||
|
||||
// DnD 센서 설정
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -480,14 +553,20 @@ export function RepeaterTable({
|
|||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||
const value = row[column.field];
|
||||
|
||||
// 카테고리 라벨 변환 함수
|
||||
// 카테고리/셀렉트 라벨 변환 함수
|
||||
const getCategoryDisplayValue = (val: any): string => {
|
||||
if (!val || typeof val !== "string") return val || "-";
|
||||
|
||||
// select 타입 컬럼의 selectOptions에서 라벨 찾기
|
||||
if (column.selectOptions && column.selectOptions.length > 0) {
|
||||
const matchedOption = column.selectOptions.find((opt) => opt.value === val);
|
||||
if (matchedOption) return matchedOption.label;
|
||||
}
|
||||
|
||||
const fieldName = column.field.replace(/^_display_/, "");
|
||||
const isCategoryColumn = categoryColumns.includes(fieldName);
|
||||
|
||||
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
|
||||
// categoryLabelMap에 직접 매핑이 있으면 바로 변환
|
||||
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
||||
|
||||
// 카테고리 컬럼이 아니면 원래 값 반환
|
||||
|
|
@ -648,7 +727,7 @@ export function RepeaterTable({
|
|||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
|
||||
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white" onKeyDown={handleArrowNavigation}>
|
||||
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||
<table
|
||||
className="border-collapse text-xs"
|
||||
|
|
@ -840,6 +919,8 @@ export function RepeaterTable({
|
|||
width: `${columnWidths[col.field]}px`,
|
||||
maxWidth: `${columnWidths[col.field]}px`,
|
||||
}}
|
||||
data-repeater-row={rowIndex}
|
||||
data-repeater-col={colIndex}
|
||||
>
|
||||
{renderCell(row, col, rowIndex)}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = rawConfig.groupByColumn;
|
||||
const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn;
|
||||
const targetTable = rawConfig.targetTable;
|
||||
|
||||
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
||||
|
|
@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||
// 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리
|
||||
const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null;
|
||||
|
||||
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
|
|
|||
|
|
@ -93,9 +93,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
[config, component.config, component.id],
|
||||
);
|
||||
|
||||
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환)
|
||||
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
|
||||
const sourceKeyField = componentConfig.sourceKeyField || "item_id";
|
||||
// 소스 테이블의 키 필드명
|
||||
// 우선순위: 1) config에서 명시적 설정 → 2) additionalFields에서 autoFillFrom:"id" 필드 감지 → 3) 하위 호환 "item_id"
|
||||
const sourceKeyField = useMemo(() => {
|
||||
// sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨)
|
||||
return componentConfig.sourceKeyField || "item_id";
|
||||
}, [componentConfig.sourceKeyField]);
|
||||
|
||||
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
|
||||
const dataSourceId = useMemo(
|
||||
|
|
@ -472,10 +475,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
if (allGroupsEmpty) {
|
||||
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
|
||||
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
|
||||
const baseRecord: Record<string, any> = {};
|
||||
|
||||
// sourceKeyField 자동 매핑 (item_id = originalData.id)
|
||||
if (sourceKeyField && item.originalData?.id) {
|
||||
baseRecord[sourceKeyField] = item.originalData.id;
|
||||
}
|
||||
|
||||
// 나머지 autoFillFrom 필드 (sourceKeyField 제외)
|
||||
additionalFields.forEach((f) => {
|
||||
if (f.autoFillFrom && item.originalData) {
|
||||
if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) {
|
||||
const value = item.originalData[f.autoFillFrom];
|
||||
if (value !== undefined && value !== null) {
|
||||
baseRecord[f.name] = value;
|
||||
|
|
@ -530,7 +539,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
return allRecords;
|
||||
},
|
||||
[componentConfig.fieldGroups, componentConfig.additionalFields],
|
||||
[componentConfig.fieldGroups, componentConfig.additionalFields, sourceKeyField],
|
||||
);
|
||||
|
||||
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
||||
|
|
@ -559,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
if (hasParentMapping) {
|
||||
try {
|
||||
|
||||
// 수정 모드 감지 (parentKeys 구성 전에 필요)
|
||||
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
||||
const urlEditMode = urlParams?.get("mode") === "edit";
|
||||
const dataHasDbId = items.some(item => !!item.originalData?.id);
|
||||
const isEditMode = urlEditMode || dataHasDbId;
|
||||
|
||||
// 부모 키 추출 (parentDataMapping에서)
|
||||
const parentKeys: Record<string, any> = {};
|
||||
|
||||
|
|
@ -572,11 +587,19 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
}
|
||||
|
||||
componentConfig.parentDataMapping.forEach((mapping) => {
|
||||
// 1차: formData(sourceData)에서 찾기
|
||||
let value = getFieldValue(sourceData, mapping.sourceField);
|
||||
let value: any;
|
||||
|
||||
// 수정 모드: originalData의 targetField 값 우선 사용
|
||||
// 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야
|
||||
// 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능
|
||||
if (isEditMode && items.length > 0 && items[0].originalData) {
|
||||
value = items[0].originalData[mapping.targetField];
|
||||
}
|
||||
|
||||
// 신규 모드 또는 originalData에 값 없으면 기존 로직
|
||||
if (value === undefined || value === null) {
|
||||
value = getFieldValue(sourceData, mapping.sourceField);
|
||||
|
||||
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기
|
||||
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용
|
||||
if ((value === undefined || value === null) && mapping.sourceTable) {
|
||||
const registryData = dataRegistry[mapping.sourceTable];
|
||||
if (registryData && registryData.length > 0) {
|
||||
|
|
@ -584,6 +607,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
value = registryItem[mapping.sourceField];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
parentKeys[mapping.targetField] = value;
|
||||
|
|
@ -637,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
const additionalFields = componentConfig.additionalFields || [];
|
||||
const mainTable = componentConfig.targetTable!;
|
||||
|
||||
// 수정 모드 감지 (2가지 방법으로 확인)
|
||||
// 1. URL에 mode=edit 파라미터 확인
|
||||
// 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인
|
||||
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
|
||||
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
||||
const urlEditMode = urlParams?.get("mode") === "edit";
|
||||
const dataHasDbId = items.some(item => !!item.originalData?.id);
|
||||
const isEditMode = urlEditMode || dataHasDbId;
|
||||
|
||||
console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
|
||||
urlEditMode,
|
||||
dataHasDbId,
|
||||
|
|
@ -677,27 +692,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
for (const item of items) {
|
||||
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
|
||||
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
|
||||
let sourceKeyValue: string | null = null;
|
||||
|
||||
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
|
||||
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드)
|
||||
if (item.originalData && item.originalData[sourceKeyField]) {
|
||||
sourceKeyValue = item.originalData[sourceKeyField];
|
||||
}
|
||||
|
||||
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
|
||||
if (!sourceKeyValue) {
|
||||
mainGroups.forEach((group) => {
|
||||
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
||||
groupFields.forEach((field) => {
|
||||
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
|
||||
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3순위: fallback (최후의 수단)
|
||||
// 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드)
|
||||
if (!sourceKeyValue && item.originalData) {
|
||||
sourceKeyValue = item.originalData.id || null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Plus, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
|
@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
|
||||
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string; referenceTable?: string; referenceColumn?: string }>>([]);
|
||||
|
||||
// FK 자동 감지 결과
|
||||
const [autoDetectedFks, setAutoDetectedFks] = useState<AutoDetectedFk[]>([]);
|
||||
|
||||
// 🆕 원본 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
loadColumns();
|
||||
}, [config.sourceTable]);
|
||||
|
||||
// 🆕 대상 테이블 컬럼 로드
|
||||
// 🆕 대상 테이블 컬럼 로드 (referenceTable/referenceColumn 포함)
|
||||
useEffect(() => {
|
||||
if (!config.targetTable) {
|
||||
setLoadedTargetTableColumns([]);
|
||||
setAutoDetectedFks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
inputType: col.inputType, // 🔧 inputType 추가
|
||||
inputType: col.inputType,
|
||||
codeCategory: col.codeCategory,
|
||||
referenceTable: col.referenceTable,
|
||||
referenceColumn: col.referenceColumn,
|
||||
})));
|
||||
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
|
||||
}
|
||||
|
|
@ -161,6 +168,76 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
loadColumns();
|
||||
}, [config.targetTable]);
|
||||
|
||||
// FK 자동 감지 (ref로 무한 루프 방지)
|
||||
const fkAutoAppliedRef = useRef(false);
|
||||
|
||||
// targetTable 컬럼이 로드되면 entity FK 컬럼 감지
|
||||
const detectedFks = useMemo<AutoDetectedFk[]>(() => {
|
||||
if (!config.targetTable || loadedTargetTableColumns.length === 0) return [];
|
||||
|
||||
const entityFkColumns = loadedTargetTableColumns.filter(
|
||||
(col) => col.inputType === "entity" && col.referenceTable
|
||||
);
|
||||
if (entityFkColumns.length === 0) return [];
|
||||
|
||||
return entityFkColumns.map((col) => {
|
||||
let mappingType: "source" | "parent" | "unknown" = "unknown";
|
||||
if (config.sourceTable && col.referenceTable === config.sourceTable) {
|
||||
mappingType = "source";
|
||||
} else if (config.sourceTable && col.referenceTable !== config.sourceTable) {
|
||||
mappingType = "parent";
|
||||
}
|
||||
return {
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
referenceTable: col.referenceTable!,
|
||||
referenceColumn: col.referenceColumn || "id",
|
||||
mappingType,
|
||||
};
|
||||
});
|
||||
}, [config.targetTable, config.sourceTable, loadedTargetTableColumns]);
|
||||
|
||||
// 감지 결과를 state에 반영
|
||||
useEffect(() => {
|
||||
setAutoDetectedFks(detectedFks);
|
||||
}, [detectedFks]);
|
||||
|
||||
// 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋)
|
||||
useEffect(() => {
|
||||
fkAutoAppliedRef.current = false;
|
||||
}, [config.targetTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fkAutoAppliedRef.current || detectedFks.length === 0) return;
|
||||
|
||||
const sourceFk = detectedFks.find((fk) => fk.mappingType === "source");
|
||||
const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent");
|
||||
let changed = false;
|
||||
|
||||
// sourceKeyField 자동 설정
|
||||
if (sourceFk && !config.sourceKeyField) {
|
||||
console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName);
|
||||
handleChange("sourceKeyField", sourceFk.columnName);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// parentDataMapping 자동 생성 (기존에 없을 때만)
|
||||
if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) {
|
||||
const autoMappings = parentFks.map((fk) => ({
|
||||
sourceTable: fk.referenceTable,
|
||||
sourceField: "id",
|
||||
targetField: fk.columnName,
|
||||
}));
|
||||
console.log("🔗 parentDataMapping 자동 생성:", autoMappings);
|
||||
handleChange("parentDataMapping", autoMappings);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fkAutoAppliedRef.current = true;
|
||||
}
|
||||
}, [detectedFks]);
|
||||
|
||||
// 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalFieldGroups(config.fieldGroups || []);
|
||||
|
|
@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<p className="text-[10px] text-gray-500 sm:text-xs">최종 데이터를 저장할 테이블</p>
|
||||
</div>
|
||||
|
||||
{/* FK 자동 감지 결과 표시 */}
|
||||
{autoDetectedFks.length > 0 && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
|
||||
<p className="mb-2 text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
FK 자동 감지됨 ({autoDetectedFks.length}건)
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{autoDetectedFks.map((fk) => (
|
||||
<div key={fk.columnName} className="flex items-center gap-2 text-[10px] sm:text-xs">
|
||||
<span className={cn(
|
||||
"rounded px-1.5 py-0.5 font-mono text-[9px]",
|
||||
fk.mappingType === "source"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: fk.mappingType === "parent"
|
||||
? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
|
||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
)}>
|
||||
{fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">{fk.columnName}</span>
|
||||
<span className="text-muted-foreground">-></span>
|
||||
<span className="font-mono">{fk.referenceTable}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[9px] text-blue-600 dark:text-blue-400">
|
||||
엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동으로 설정됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시할 원본 데이터 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">표시할 원본 데이터 컬럼</Label>
|
||||
|
|
@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">추가 입력 필드 정의</Label>
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
{localFields.map((field, index) => {
|
||||
return (
|
||||
<Card key={index} className="border-2">
|
||||
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -2392,9 +2502,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.parentDataMapping || []).map((mapping, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
{(config.parentDataMapping || []).map((mapping, index) => {
|
||||
const isAutoDetected = autoDetectedFks.some(
|
||||
(fk) => fk.mappingType === "parent" && fk.columnName === mapping.targetField
|
||||
);
|
||||
return (
|
||||
<Card key={index} className={cn("p-3", isAutoDetected && "border-orange-200 bg-orange-50/30 dark:border-orange-800 dark:bg-orange-950/30")}>
|
||||
<div className="space-y-2">
|
||||
{isAutoDetected && (
|
||||
<span className="inline-block rounded bg-orange-100 px-1.5 py-0.5 text-[9px] font-medium text-orange-700 dark:bg-orange-900 dark:text-orange-300">
|
||||
FK 자동 감지
|
||||
</span>
|
||||
)}
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">소스 테이블</Label>
|
||||
|
|
@ -2637,7 +2756,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,23 @@ export interface ParentDataMapping {
|
|||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 감지된 FK 매핑 정보
|
||||
* table_type_columns의 entity 설정을 기반으로 자동 감지
|
||||
*/
|
||||
export interface AutoDetectedFk {
|
||||
/** 대상 테이블의 FK 컬럼명 (예: item_id, customer_id) */
|
||||
columnName: string;
|
||||
/** 컬럼 라벨 (예: 품목 ID) */
|
||||
columnLabel?: string;
|
||||
/** 참조 테이블명 (예: item_info, customer_mng) */
|
||||
referenceTable: string;
|
||||
/** 참조 컬럼명 (예: item_number, customer_code) */
|
||||
referenceColumn: string;
|
||||
/** 매핑 유형: source(원본 데이터 FK) 또는 parent(부모 화면 FK) */
|
||||
mappingType: "source" | "parent" | "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -155,6 +172,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
sourceTable?: string;
|
||||
|
||||
/**
|
||||
* 원본 데이터의 키 필드명 (대상 테이블에서 원본을 참조하는 FK 컬럼)
|
||||
* 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
|
||||
* 미설정 시 엔티티 설정에서 자동 감지
|
||||
*/
|
||||
sourceKeyField?: string;
|
||||
|
||||
/**
|
||||
* 표시할 원본 데이터 컬럼들 (name, label, width)
|
||||
* 원본 데이터 테이블의 컬럼을 표시
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { toast } from "sonner";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
||||
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 프론트엔드 그룹핑 함수
|
||||
const groupData = useCallback(
|
||||
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
|
||||
|
|
@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}
|
||||
});
|
||||
|
||||
// 탭 목록 생성
|
||||
// 탭 목록 생성 (카테고리 라벨 변환 적용)
|
||||
const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({
|
||||
id: value,
|
||||
label: value,
|
||||
label: categoryLabelMap[value] || value,
|
||||
count: tabConfig.showCount ? count : 0,
|
||||
}));
|
||||
|
||||
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
|
||||
return tabs;
|
||||
},
|
||||
[],
|
||||
[categoryLabelMap],
|
||||
);
|
||||
|
||||
// 탭으로 필터링된 데이터 반환
|
||||
|
|
@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
|
||||
break;
|
||||
|
||||
case "edit":
|
||||
// 좌측 패널에서 수정 (필요시 구현)
|
||||
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn);
|
||||
case "edit": {
|
||||
if (!selectedLeftItem) {
|
||||
toast.error("수정할 항목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const editModalScreenId = btn.modalScreenId || config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
|
||||
|
||||
if (!editModalScreenId) {
|
||||
toast.error("연결된 모달 화면이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const editEvent = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: editModalScreenId,
|
||||
title: btn.label || "수정",
|
||||
modalSize: "lg",
|
||||
editData: selectedLeftItem,
|
||||
isCreateMode: false,
|
||||
onSave: () => {
|
||||
loadLeftData();
|
||||
if (selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(editEvent);
|
||||
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem);
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete":
|
||||
// 좌측 패널에서 삭제 (필요시 구현)
|
||||
|
|
@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
break;
|
||||
}
|
||||
},
|
||||
[config.leftPanel?.addModalScreenId, loadLeftData],
|
||||
[config.leftPanel?.addModalScreenId, config.leftPanel?.editModalScreenId, loadLeftData, loadRightData, selectedLeftItem],
|
||||
);
|
||||
|
||||
// 컬럼 라벨 로드
|
||||
|
|
@ -1241,6 +1273,55 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
config.rightPanel?.tableName,
|
||||
]);
|
||||
|
||||
// 카테고리 컬럼에 대한 라벨 매핑 로드
|
||||
useEffect(() => {
|
||||
if (isDesignMode) return;
|
||||
|
||||
const loadCategoryLabels = async () => {
|
||||
const allColumns = new Set<string>();
|
||||
const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName;
|
||||
if (!tableName) return;
|
||||
|
||||
// 좌우 패널의 표시 컬럼에서 카테고리 후보 수집
|
||||
for (const col of config.leftPanel?.displayColumns || []) {
|
||||
allColumns.add(col.name);
|
||||
}
|
||||
for (const col of config.rightPanel?.displayColumns || []) {
|
||||
allColumns.add(col.name);
|
||||
}
|
||||
// 탭 소스 컬럼도 추가
|
||||
if (config.rightPanel?.tabConfig?.tabSourceColumn) {
|
||||
allColumns.add(config.rightPanel.tabConfig.tabSourceColumn);
|
||||
}
|
||||
if (config.leftPanel?.tabConfig?.tabSourceColumn) {
|
||||
allColumns.add(config.leftPanel.tabConfig.tabSourceColumn);
|
||||
}
|
||||
|
||||
const labelMap: Record<string, string> = {};
|
||||
|
||||
for (const columnName of allColumns) {
|
||||
try {
|
||||
const result = await getCategoryValues(tableName, columnName);
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
for (const item of result.data) {
|
||||
if (item.valueCode && item.valueLabel) {
|
||||
labelMap[item.valueCode] = item.valueLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 카테고리가 아닌 컬럼은 무시
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(labelMap).length > 0) {
|
||||
setCategoryLabelMap(labelMap);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryLabels();
|
||||
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
|
||||
|
||||
// 컴포넌트 언마운트 시 DataProvider 해제
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
};
|
||||
}, [screenContext, component.id]);
|
||||
|
||||
// 카테고리 코드를 라벨로 변환
|
||||
const resolveCategoryLabel = useCallback(
|
||||
(value: any): string => {
|
||||
if (value === null || value === undefined) return "";
|
||||
const strVal = String(value);
|
||||
if (categoryLabelMap[strVal]) return categoryLabelMap[strVal];
|
||||
// 콤마 구분 다중 값 처리
|
||||
if (strVal.includes(",")) {
|
||||
const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean);
|
||||
const labels = codes.map((code) => categoryLabelMap[code] || code);
|
||||
return labels.join(", ");
|
||||
}
|
||||
return strVal;
|
||||
},
|
||||
[categoryLabelMap],
|
||||
);
|
||||
|
||||
// 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려)
|
||||
const getColumnValue = useCallback(
|
||||
(item: any, col: ColumnConfig): any => {
|
||||
|
|
@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
const displayColumns = config.leftPanel?.displayColumns || [];
|
||||
const pkColumn = getLeftPrimaryKeyColumn();
|
||||
|
||||
// 값 렌더링 (배지 지원)
|
||||
// 값 렌더링 (배지 지원 + 카테고리 라벨 변환)
|
||||
const renderCellValue = (item: any, col: ColumnConfig) => {
|
||||
const value = item[col.name];
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
|
@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((v, vIdx) => (
|
||||
<Badge key={vIdx} variant="secondary" className="text-xs">
|
||||
{formatValue(v, col.format)}
|
||||
{resolveCategoryLabel(v) || formatValue(v, col.format)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
|
||||
// 배지 타입이지만 단일 값인 경우
|
||||
if (col.displayConfig?.displayType === "badge") {
|
||||
const label = resolveCategoryLabel(value);
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatValue(value, col.format)}
|
||||
{label !== String(value) ? label : formatValue(value, col.format)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 텍스트
|
||||
// 카테고리 라벨 변환 시도 후 기본 텍스트
|
||||
const label = resolveCategoryLabel(value);
|
||||
if (label !== String(value)) return label;
|
||||
return formatValue(value, col.format);
|
||||
};
|
||||
|
||||
|
|
@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{displayColumns.map((col, colIdx) => (
|
||||
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell>
|
||||
))}
|
||||
{displayColumns.map((col, colIdx) => {
|
||||
const rawVal = getColumnValue(item, col);
|
||||
const resolved = resolveCategoryLabel(rawVal);
|
||||
const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format);
|
||||
return <TableCell key={colIdx}>{display || "-"}</TableCell>;
|
||||
})}
|
||||
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center gap-1">
|
||||
|
|
@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
|
||||
config.leftPanel.actionButtons.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{config.leftPanel.actionButtons.map((btn, idx) => (
|
||||
{config.leftPanel.actionButtons
|
||||
.filter((btn) => {
|
||||
if (btn.showCondition === "selected") return !!selectedLeftItem;
|
||||
return true;
|
||||
})
|
||||
.map((btn, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types";
|
|||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react";
|
||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
/**
|
||||
* 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴)
|
||||
*/
|
||||
function SortableColumnRow({
|
||||
id,
|
||||
col,
|
||||
index,
|
||||
isEntityJoin,
|
||||
onLabelChange,
|
||||
onWidthChange,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
col: ColumnConfig;
|
||||
index: number;
|
||||
isEntityJoin?: boolean;
|
||||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
)}
|
||||
<Input
|
||||
value={col.displayName || col.columnName}
|
||||
onChange={(e) => onLabelChange(e.target.value)}
|
||||
placeholder="표시명"
|
||||
className="h-6 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={col.width || ""}
|
||||
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||
placeholder="너비"
|
||||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TableListConfigPanelProps {
|
||||
config: TableListConfig;
|
||||
|
|
@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
|
||||
if (existingColumn) return;
|
||||
|
||||
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
|
||||
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
|
||||
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
|
||||
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
|
||||
|
||||
// 라벨명 우선 사용, 없으면 컬럼명 사용
|
||||
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
|
||||
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
|
||||
|
||||
const newColumn: ColumnConfig = {
|
||||
columnName,
|
||||
|
|
@ -1213,6 +1276,62 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 컬럼 순서 변경 (DnD) */}
|
||||
{config.columns && config.columns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const columns = [...(config.columns || [])];
|
||||
const oldIndex = columns.findIndex((c) => c.columnName === active.id);
|
||||
const newIndex = columns.findIndex((c) => c.columnName === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const reordered = arrayMove(columns, oldIndex, newIndex);
|
||||
reordered.forEach((col, idx) => { col.order = idx; });
|
||||
handleChange("columns", reordered);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={(config.columns || []).map((c) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{(config.columns || []).map((column, idx) => {
|
||||
const resolvedLabel =
|
||||
column.displayName && column.displayName !== column.columnName
|
||||
? column.displayName
|
||||
: availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName;
|
||||
|
||||
const colWithLabel = { ...column, displayName: resolvedLabel };
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={column.columnName}
|
||||
id={column.columnName}
|
||||
col={colWithLabel}
|
||||
index={idx}
|
||||
isEntityJoin={!!column.isEntityJoin}
|
||||
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
|
||||
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
|
||||
onRemove={() => removeColumn(column.columnName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 필터링 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
|
@ -1240,3 +1359,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -514,29 +514,38 @@ export function TableSectionRenderer({
|
|||
loadColumnLabels();
|
||||
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
|
||||
|
||||
// 카테고리 타입 컬럼의 옵션 로드
|
||||
// 카테고리 타입 컬럼 + referenceDisplay 소스 카테고리 컬럼의 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryOptions = async () => {
|
||||
const sourceTableName = tableConfig.source.tableName;
|
||||
if (!sourceTableName) return;
|
||||
if (!tableConfig.columns) return;
|
||||
|
||||
// 카테고리 타입인 컬럼만 필터링
|
||||
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
|
||||
if (categoryColumns.length === 0) return;
|
||||
|
||||
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
||||
const loadedSourceColumns = new Set<string>();
|
||||
|
||||
for (const col of categoryColumns) {
|
||||
// 소스 필드 또는 필드명으로 카테고리 값 조회
|
||||
const actualColumnName = col.sourceField || col.field;
|
||||
if (!actualColumnName) continue;
|
||||
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||
|
||||
for (const col of tableConfig.columns) {
|
||||
let sourceColumnName: string | undefined;
|
||||
|
||||
if (col.type === "category") {
|
||||
sourceColumnName = col.sourceField || col.field;
|
||||
} else {
|
||||
// referenceDisplay로 소스 카테고리 컬럼을 참조하는 컬럼도 포함
|
||||
const refSource = (col as any).saveConfig?.referenceDisplay?.sourceColumn;
|
||||
if (refSource && sourceCategoryColumns.includes(refSource)) {
|
||||
sourceColumnName = refSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceColumnName || loadedSourceColumns.has(`${col.field}:${sourceColumnName}`)) continue;
|
||||
loadedSourceColumns.add(`${col.field}:${sourceColumnName}`);
|
||||
|
||||
try {
|
||||
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
|
||||
const result = await getCategoryValues(sourceTableName, sourceColumnName, false);
|
||||
|
||||
if (result && result.success && Array.isArray(result.data)) {
|
||||
if (result?.success && Array.isArray(result.data)) {
|
||||
const options = result.data.map((item: any) => ({
|
||||
value: item.valueCode || item.value_code || item.value || "",
|
||||
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
|
||||
|
|
@ -548,11 +557,13 @@ export function TableSectionRenderer({
|
|||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newOptionsMap).length > 0) {
|
||||
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryOptions();
|
||||
}, [tableConfig.source.tableName, tableConfig.columns]);
|
||||
}, [tableConfig.source.tableName, tableConfig.columns, sourceCategoryColumns]);
|
||||
|
||||
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -630,7 +641,42 @@ export function TableSectionRenderer({
|
|||
const loadDynamicOptions = async () => {
|
||||
setDynamicOptionsLoading(true);
|
||||
try {
|
||||
// DISTINCT 값을 가져오기 위한 API 호출
|
||||
// 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결)
|
||||
let categoryLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||
const catResult = await getCategoryValues(tableName, valueColumn, false);
|
||||
if (catResult?.success && Array.isArray(catResult.data)) {
|
||||
for (const item of catResult.data) {
|
||||
const code = item.valueCode || item.value_code || item.value || "";
|
||||
const label = item.valueLabel || item.displayLabel || item.display_label || item.label || code;
|
||||
if (code) categoryLabelMap[code] = label;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 카테고리 값이 없으면 무시
|
||||
}
|
||||
|
||||
const hasCategoryValues = Object.keys(categoryLabelMap).length > 0;
|
||||
|
||||
if (hasCategoryValues) {
|
||||
// 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용
|
||||
const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({
|
||||
id: `dynamic_${index}`,
|
||||
value: code,
|
||||
label,
|
||||
}));
|
||||
|
||||
console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", {
|
||||
tableName,
|
||||
valueColumn,
|
||||
optionCount: options.length,
|
||||
});
|
||||
|
||||
setDynamicOptions(options);
|
||||
dynamicOptionsLoadedRef.current = true;
|
||||
} else {
|
||||
// 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리)
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||
search: filterCondition ? { _raw: filterCondition } : {},
|
||||
size: 1000,
|
||||
|
|
@ -640,33 +686,37 @@ export function TableSectionRenderer({
|
|||
if (response.data.success && response.data.data?.data) {
|
||||
const rows = response.data.data.data;
|
||||
|
||||
// 중복 제거하여 고유 값 추출
|
||||
const uniqueValues = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const value = row[valueColumn];
|
||||
if (value && !uniqueValues.has(value)) {
|
||||
const label = labelColumn ? row[labelColumn] || value : value;
|
||||
uniqueValues.set(value, label);
|
||||
const rawValue = row[valueColumn];
|
||||
if (!rawValue) continue;
|
||||
|
||||
// 쉼표 구분 다중값을 개별로 분리
|
||||
const values = String(rawValue).split(",").map((v: string) => v.trim()).filter(Boolean);
|
||||
for (const v of values) {
|
||||
if (!uniqueValues.has(v)) {
|
||||
const label = labelColumn ? row[labelColumn] || v : v;
|
||||
uniqueValues.set(v, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 옵션 배열로 변환
|
||||
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
|
||||
id: `dynamic_${index}`,
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", {
|
||||
console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", {
|
||||
tableName,
|
||||
valueColumn,
|
||||
optionCount: options.length,
|
||||
options,
|
||||
});
|
||||
|
||||
setDynamicOptions(options);
|
||||
dynamicOptionsLoadedRef.current = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
|
||||
} finally {
|
||||
|
|
@ -1019,34 +1069,24 @@ export function TableSectionRenderer({
|
|||
);
|
||||
|
||||
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
||||
// 조건부 테이블은 별도 useEffect에서 applyConditionalGrouping으로 처리
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 스킵
|
||||
if (initialDataLoadedRef.current) return;
|
||||
if (isConditionalMode) return;
|
||||
|
||||
const tableSectionKey = `__tableSection_${sectionId}`;
|
||||
const initialData = formData[tableSectionKey];
|
||||
|
||||
console.log("[TableSectionRenderer] 초기 데이터 확인:", {
|
||||
sectionId,
|
||||
tableSectionKey,
|
||||
hasInitialData: !!initialData,
|
||||
initialDataLength: Array.isArray(initialData) ? initialData.length : 0,
|
||||
formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")),
|
||||
});
|
||||
|
||||
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
|
||||
console.warn("[TableSectionRenderer] 비조건부 초기 데이터 로드:", {
|
||||
sectionId,
|
||||
itemCount: initialData.length,
|
||||
firstItem: initialData[0],
|
||||
});
|
||||
setTableData(initialData);
|
||||
initialDataLoadedRef.current = true;
|
||||
|
||||
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
|
||||
loadReferenceColumnValues(initialData);
|
||||
}
|
||||
}, [sectionId, formData, loadReferenceColumnValues]);
|
||||
}, [sectionId, formData, isConditionalMode, loadReferenceColumnValues]);
|
||||
|
||||
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
|
||||
const columns: RepeaterColumnConfig[] = useMemo(() => {
|
||||
|
|
@ -1068,10 +1108,23 @@ export function TableSectionRenderer({
|
|||
});
|
||||
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
||||
|
||||
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
|
||||
// categoryOptionsMap + dynamicOptions에서 RepeaterTable용 카테고리 정보 파생
|
||||
const tableCategoryColumns = useMemo(() => {
|
||||
return Object.keys(categoryOptionsMap);
|
||||
}, [categoryOptionsMap]);
|
||||
const cols = new Set(Object.keys(categoryOptionsMap));
|
||||
// 조건부 테이블의 conditionColumn과 매핑된 컬럼도 카테고리 컬럼으로 추가
|
||||
if (isConditionalMode && conditionalConfig?.conditionColumn && dynamicOptions.length > 0) {
|
||||
// 조건 컬럼 자체
|
||||
cols.add(conditionalConfig.conditionColumn);
|
||||
// referenceDisplay로 조건 컬럼의 소스를 참조하는 컬럼도 추가
|
||||
for (const col of tableConfig.columns || []) {
|
||||
const refDisplay = (col as any).saveConfig?.referenceDisplay;
|
||||
if (refDisplay?.sourceColumn === conditionalConfig.conditionColumn) {
|
||||
cols.add(col.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(cols);
|
||||
}, [categoryOptionsMap, isConditionalMode, conditionalConfig?.conditionColumn, dynamicOptions, tableConfig.columns]);
|
||||
|
||||
const tableCategoryLabelMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
|
|
@ -1082,8 +1135,14 @@ export function TableSectionRenderer({
|
|||
}
|
||||
}
|
||||
}
|
||||
// 조건부 테이블 동적 옵션의 카테고리 코드→라벨 매핑도 추가
|
||||
for (const opt of dynamicOptions) {
|
||||
if (opt.value && opt.label && opt.value !== opt.label) {
|
||||
map[opt.value] = opt.label;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [categoryOptionsMap]);
|
||||
}, [categoryOptionsMap, dynamicOptions]);
|
||||
|
||||
// 원본 계산 규칙 (조건부 계산 포함)
|
||||
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||
|
|
@ -1606,10 +1665,9 @@ export function TableSectionRenderer({
|
|||
const multiSelect = uiConfig?.multiSelect ?? true;
|
||||
|
||||
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
|
||||
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
|
||||
const legacyAddButtonType = uiConfig?.addButtonType;
|
||||
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
|
||||
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
|
||||
// showSearchButton/showAddRowButton 신규 필드 우선, 레거시 addButtonType은 신규 필드 없을 때만 참고
|
||||
const showSearchButton = uiConfig?.showSearchButton ?? true;
|
||||
const showAddRowButton = uiConfig?.showAddRowButton ?? false;
|
||||
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
|
||||
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
|
||||
|
||||
|
|
@ -1641,8 +1699,9 @@ export function TableSectionRenderer({
|
|||
const filter = { ...baseFilterCondition };
|
||||
|
||||
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
|
||||
// __like 연산자로 ILIKE 포함 검색 (쉼표 구분 다중값 매칭 지원)
|
||||
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
|
||||
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition;
|
||||
filter[`${conditionalConfig.sourceFilter.filterColumn}__like`] = modalCondition;
|
||||
}
|
||||
|
||||
return filter;
|
||||
|
|
@ -1771,7 +1830,29 @@ export function TableSectionRenderer({
|
|||
async (items: any[]) => {
|
||||
if (!modalCondition) return;
|
||||
|
||||
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
|
||||
// autoFillColumns 매핑 빌드: targetField → sourceColumn
|
||||
const autoFillMap: Record<string, string> = {};
|
||||
for (const col of tableConfig.columns) {
|
||||
const dso = (col as any).dynamicSelectOptions;
|
||||
if (dso?.sourceField) {
|
||||
autoFillMap[col.field] = dso.sourceField;
|
||||
}
|
||||
if (dso?.rowSelectionMode?.autoFillColumns) {
|
||||
for (const af of dso.rowSelectionMode.autoFillColumns) {
|
||||
autoFillMap[af.targetField] = af.sourceColumn;
|
||||
}
|
||||
}
|
||||
}
|
||||
// referenceDisplay에서도 매핑 추가
|
||||
for (const col of tableConfig.columns) {
|
||||
if (!autoFillMap[col.field]) {
|
||||
const refDisplay = (col as any).saveConfig?.referenceDisplay;
|
||||
if (refDisplay?.sourceColumn) {
|
||||
autoFillMap[col.field] = refDisplay.sourceColumn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mappedItems = await Promise.all(
|
||||
items.map(async (sourceItem) => {
|
||||
const newItem: any = {};
|
||||
|
|
@ -1779,6 +1860,15 @@ export function TableSectionRenderer({
|
|||
for (const col of tableConfig.columns) {
|
||||
const mapping = col.valueMapping;
|
||||
|
||||
// autoFill 또는 referenceDisplay 매핑이 있으면 우선 사용
|
||||
const autoFillSource = autoFillMap[col.field];
|
||||
if (!mapping && autoFillSource) {
|
||||
if (sourceItem[autoFillSource] !== undefined) {
|
||||
newItem[col.field] = sourceItem[autoFillSource];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 소스 필드에서 값 복사 (기본)
|
||||
if (!mapping) {
|
||||
const sourceField = col.sourceField || col.field;
|
||||
|
|
@ -1896,23 +1986,20 @@ export function TableSectionRenderer({
|
|||
[addEmptyRowToCondition],
|
||||
);
|
||||
|
||||
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
|
||||
useEffect(() => {
|
||||
if (!isConditionalMode) return;
|
||||
if (initialDataLoadedRef.current) return;
|
||||
|
||||
const tableSectionKey = `_tableSection_${sectionId}`;
|
||||
const initialData = formData[tableSectionKey];
|
||||
|
||||
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||
// 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼
|
||||
const applyConditionalGrouping = useCallback((data: any[]) => {
|
||||
const conditionColumn = conditionalConfig?.conditionColumn;
|
||||
console.warn(`[applyConditionalGrouping] 호출됨:`, {
|
||||
conditionColumn,
|
||||
dataLength: data.length,
|
||||
sampleConditions: data.slice(0, 3).map(r => r[conditionColumn || ""]),
|
||||
});
|
||||
if (!conditionColumn || data.length === 0) return;
|
||||
|
||||
if (conditionColumn) {
|
||||
// 조건별로 데이터 그룹핑
|
||||
const grouped: ConditionalTableData = {};
|
||||
const conditions = new Set<string>();
|
||||
|
||||
for (const row of initialData) {
|
||||
for (const row of data) {
|
||||
const conditionValue = row[conditionColumn] || "";
|
||||
if (conditionValue) {
|
||||
if (!grouped[conditionValue]) {
|
||||
|
|
@ -1926,15 +2013,119 @@ export function TableSectionRenderer({
|
|||
setConditionalTableData(grouped);
|
||||
setSelectedConditions(Array.from(conditions));
|
||||
|
||||
// 첫 번째 조건을 활성 탭으로 설정
|
||||
if (conditions.size > 0) {
|
||||
setActiveConditionTab(Array.from(conditions)[0]);
|
||||
}
|
||||
|
||||
initialDataLoadedRef.current = true;
|
||||
}, [conditionalConfig?.conditionColumn]);
|
||||
|
||||
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
|
||||
useEffect(() => {
|
||||
if (!isConditionalMode) return;
|
||||
if (initialDataLoadedRef.current) return;
|
||||
|
||||
const initialData =
|
||||
formData[`_tableSection_${sectionId}`] ||
|
||||
formData[`__tableSection_${sectionId}`];
|
||||
|
||||
console.warn(`[TableSectionRenderer] 초기 데이터 로드 체크:`, {
|
||||
sectionId,
|
||||
hasUnderscoreData: !!formData[`_tableSection_${sectionId}`],
|
||||
hasDoubleUnderscoreData: !!formData[`__tableSection_${sectionId}`],
|
||||
dataLength: Array.isArray(initialData) ? initialData.length : "not array",
|
||||
initialDataLoaded: initialDataLoadedRef.current,
|
||||
});
|
||||
|
||||
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||
applyConditionalGrouping(initialData);
|
||||
}
|
||||
}, [isConditionalMode, sectionId, formData, applyConditionalGrouping]);
|
||||
|
||||
// 조건부 테이블: formData에 데이터가 없으면 editConfig 기반으로 직접 API 로드
|
||||
const selfLoadAttemptedRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isConditionalMode) return;
|
||||
if (initialDataLoadedRef.current) return;
|
||||
if (selfLoadAttemptedRef.current) return;
|
||||
|
||||
const editConfig = (tableConfig as any).editConfig;
|
||||
const saveConfig = tableConfig.saveConfig;
|
||||
const linkColumn = editConfig?.linkColumn;
|
||||
const targetTable = saveConfig?.targetTable;
|
||||
|
||||
console.warn(`[TableSectionRenderer] 자체 로드 체크:`, {
|
||||
sectionId,
|
||||
hasEditConfig: !!editConfig,
|
||||
linkColumn,
|
||||
targetTable,
|
||||
masterField: linkColumn?.masterField,
|
||||
masterValue: linkColumn?.masterField ? formData[linkColumn.masterField] : "N/A",
|
||||
formDataKeys: Object.keys(formData).slice(0, 15),
|
||||
initialDataLoaded: initialDataLoadedRef.current,
|
||||
selfLoadAttempted: selfLoadAttemptedRef.current,
|
||||
existingTableData_: !!formData[`_tableSection_${sectionId}`],
|
||||
existingTableData__: !!formData[`__tableSection_${sectionId}`],
|
||||
});
|
||||
|
||||
if (!linkColumn?.masterField || !linkColumn?.detailField || !targetTable) {
|
||||
console.warn(`[TableSectionRenderer] 자체 로드 스킵: linkColumn/targetTable 미설정`);
|
||||
return;
|
||||
}
|
||||
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]);
|
||||
|
||||
const masterValue = formData[linkColumn.masterField];
|
||||
if (!masterValue) {
|
||||
console.warn(`[TableSectionRenderer] 자체 로드 대기: masterField=${linkColumn.masterField} 값 없음`);
|
||||
return;
|
||||
}
|
||||
|
||||
// formData에 테이블 섹션 데이터가 이미 있으면 해당 데이터 사용
|
||||
const existingData =
|
||||
formData[`_tableSection_${sectionId}`] ||
|
||||
formData[`__tableSection_${sectionId}`];
|
||||
if (Array.isArray(existingData) && existingData.length > 0) {
|
||||
console.warn(`[TableSectionRenderer] 기존 데이터 발견, applyConditionalGrouping 호출: ${existingData.length}건`);
|
||||
applyConditionalGrouping(existingData);
|
||||
return;
|
||||
}
|
||||
|
||||
selfLoadAttemptedRef.current = true;
|
||||
console.warn(`[TableSectionRenderer] 자체 API 로드 시작: ${targetTable}, ${linkColumn.detailField}=${masterValue}`);
|
||||
|
||||
const loadDetailData = async () => {
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
|
||||
search: {
|
||||
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
|
||||
},
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: { enabled: true },
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
let items: any[] = [];
|
||||
const data = response.data.data;
|
||||
if (Array.isArray(data)) items = data;
|
||||
else if (data?.items && Array.isArray(data.items)) items = data.items;
|
||||
else if (data?.rows && Array.isArray(data.rows)) items = data.rows;
|
||||
else if (data?.data && Array.isArray(data.data)) items = data.data;
|
||||
|
||||
console.warn(`[TableSectionRenderer] 자체 데이터 로드 완료: ${items.length}건`);
|
||||
|
||||
if (items.length > 0) {
|
||||
applyConditionalGrouping(items);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[TableSectionRenderer] API 응답 실패:`, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TableSectionRenderer] 자체 데이터 로드 실패:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
loadDetailData();
|
||||
}, [isConditionalMode, sectionId, formData, tableConfig, applyConditionalGrouping]);
|
||||
|
||||
// 조건부 테이블: 전체 항목 수 계산
|
||||
const totalConditionalItems = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client";
|
|||
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
|
||||
import {
|
||||
UniversalFormModalComponentProps,
|
||||
|
|
@ -223,23 +224,38 @@ export function UniversalFormModalComponent({
|
|||
// 설정 병합
|
||||
const config: UniversalFormModalConfig = useMemo(() => {
|
||||
const componentConfig = component?.config || {};
|
||||
|
||||
// V2 레이아웃에서 overrides 전체가 config로 전달되는 경우
|
||||
// 실제 설정이 propConfig.componentConfig에 이중 중첩되어 있을 수 있음
|
||||
const nestedPropConfig = propConfig?.componentConfig;
|
||||
const hasFlatPropConfig = propConfig?.modal !== undefined || propConfig?.sections !== undefined;
|
||||
const effectivePropConfig = hasFlatPropConfig
|
||||
? propConfig
|
||||
: (nestedPropConfig?.modal ? nestedPropConfig : propConfig);
|
||||
|
||||
const nestedCompConfig = componentConfig?.componentConfig;
|
||||
const hasFlatCompConfig = componentConfig?.modal !== undefined || componentConfig?.sections !== undefined;
|
||||
const effectiveCompConfig = hasFlatCompConfig
|
||||
? componentConfig
|
||||
: (nestedCompConfig?.modal ? nestedCompConfig : componentConfig);
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...propConfig,
|
||||
...componentConfig,
|
||||
...effectivePropConfig,
|
||||
...effectiveCompConfig,
|
||||
modal: {
|
||||
...defaultConfig.modal,
|
||||
...propConfig?.modal,
|
||||
...componentConfig.modal,
|
||||
...effectivePropConfig?.modal,
|
||||
...effectiveCompConfig?.modal,
|
||||
},
|
||||
saveConfig: {
|
||||
...defaultConfig.saveConfig,
|
||||
...propConfig?.saveConfig,
|
||||
...componentConfig.saveConfig,
|
||||
...effectivePropConfig?.saveConfig,
|
||||
...effectiveCompConfig?.saveConfig,
|
||||
afterSave: {
|
||||
...defaultConfig.saveConfig.afterSave,
|
||||
...propConfig?.saveConfig?.afterSave,
|
||||
...componentConfig.saveConfig?.afterSave,
|
||||
...effectivePropConfig?.saveConfig?.afterSave,
|
||||
...effectiveCompConfig?.saveConfig?.afterSave,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -294,6 +310,7 @@ export function UniversalFormModalComponent({
|
|||
const hasInitialized = useRef(false);
|
||||
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
||||
const lastInitializedId = useRef<string | undefined>(undefined);
|
||||
const tableSectionLoadedRef = useRef(false);
|
||||
|
||||
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
|
||||
useEffect(() => {
|
||||
|
|
@ -315,7 +332,7 @@ export function UniversalFormModalComponent({
|
|||
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
||||
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
|
||||
if (!createModeDataHash || capturedInitialData.current) {
|
||||
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
|
||||
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨", { currentIdString });
|
||||
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
|
||||
// (컴포넌트 remount로 인해 state가 초기화된 경우)
|
||||
return;
|
||||
|
|
@ -349,21 +366,13 @@ export function UniversalFormModalComponent({
|
|||
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
||||
}
|
||||
|
||||
// console.log("[UniversalFormModal] initializeForm 호출 예정");
|
||||
// console.log("[UniversalFormModal] initializeForm 호출 예정", { currentIdString });
|
||||
hasInitialized.current = true;
|
||||
tableSectionLoadedRef.current = false;
|
||||
initializeForm();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialData]); // initialData 전체 변경 시 재초기화
|
||||
|
||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
||||
|
||||
// console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
|
||||
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
// 컴포넌트 unmount 시 채번 플래그 초기화
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -727,9 +736,13 @@ export function UniversalFormModalComponent({
|
|||
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
|
||||
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기
|
||||
if (effectiveInitialData) {
|
||||
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", {
|
||||
sectionsCount: config.sections.length,
|
||||
effectiveInitialDataKeys: Object.keys(effectiveInitialData),
|
||||
// console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { sectionsCount: config.sections.length });
|
||||
|
||||
console.warn("[initializeForm] 테이블 섹션 순회 시작:", {
|
||||
sectionCount: config.sections.length,
|
||||
tableSections: config.sections.filter(s => s.type === "table").map(s => s.id),
|
||||
hasInitialData: !!effectiveInitialData,
|
||||
initialDataKeys: effectiveInitialData ? Object.keys(effectiveInitialData).slice(0, 10) : [],
|
||||
});
|
||||
|
||||
for (const section of config.sections) {
|
||||
|
|
@ -738,16 +751,14 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
const tableConfig = section.tableConfig;
|
||||
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
|
||||
const editConfig = (tableConfig as any).editConfig;
|
||||
const saveConfig = tableConfig.saveConfig;
|
||||
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
|
||||
hasEditConfig: !!editConfig,
|
||||
loadOnEdit: editConfig?.loadOnEdit,
|
||||
hasSaveConfig: !!saveConfig,
|
||||
console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, {
|
||||
editConfig,
|
||||
targetTable: saveConfig?.targetTable,
|
||||
linkColumn: editConfig?.linkColumn,
|
||||
masterField: editConfig?.linkColumn?.masterField,
|
||||
masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField],
|
||||
});
|
||||
|
||||
// 수정 모드 로드 설정 확인 (기본값: true)
|
||||
|
|
@ -1072,6 +1083,25 @@ export function UniversalFormModalComponent({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||
|
||||
// config 변경 시 테이블 섹션 데이터 로드 보완
|
||||
// initializeForm은 initialData useEffect에서 호출되지만, config(화면 설정)이
|
||||
// 비동기 로드로 늦게 도착하면 테이블 섹션 로드를 놓칠 수 있음
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current) return;
|
||||
|
||||
const hasTableSection = config.sections.some(s => s.type === "table" && s.tableConfig?.saveConfig?.targetTable);
|
||||
if (!hasTableSection) return;
|
||||
|
||||
const editData = capturedInitialData.current || initialData;
|
||||
if (!editData || Object.keys(editData).length === 0) return;
|
||||
|
||||
if (tableSectionLoadedRef.current) return;
|
||||
|
||||
tableSectionLoadedRef.current = true;
|
||||
initializeForm();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.sections, initializeForm]);
|
||||
|
||||
// 반복 섹션 아이템 생성
|
||||
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
|
||||
const item: RepeatSectionItem = {
|
||||
|
|
@ -1835,11 +1865,11 @@ export function UniversalFormModalComponent({
|
|||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
<FormDatePicker
|
||||
id={fieldKey}
|
||||
type="date"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChangeHandler(e.target.value)}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "날짜를 선택하세요"}
|
||||
disabled={isDisabled}
|
||||
readOnly={field.readOnly}
|
||||
/>
|
||||
|
|
@ -1847,13 +1877,14 @@ export function UniversalFormModalComponent({
|
|||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
<FormDatePicker
|
||||
id={fieldKey}
|
||||
type="datetime-local"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChangeHandler(e.target.value)}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
|
||||
disabled={isDisabled}
|
||||
readOnly={field.readOnly}
|
||||
includeTime
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,14 +47,22 @@ export function UniversalFormModalConfigPanel({
|
|||
onChange,
|
||||
allComponents = [],
|
||||
}: UniversalFormModalConfigPanelProps) {
|
||||
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
|
||||
// V2 레이아웃에서 overrides 전체가 componentConfig로 전달되는 경우
|
||||
// 실제 설정이 rawConfig.componentConfig에 이중 중첩되어 있을 수 있음
|
||||
// 평탄화된 구조(save 후)가 있으면 우선, 아니면 중첩 구조에서 추출
|
||||
const nestedConfig = rawConfig?.componentConfig;
|
||||
const hasFlatConfig = rawConfig?.modal !== undefined || rawConfig?.sections !== undefined;
|
||||
const effectiveConfig = hasFlatConfig
|
||||
? rawConfig
|
||||
: (nestedConfig?.modal ? nestedConfig : rawConfig);
|
||||
|
||||
const config: UniversalFormModalConfig = {
|
||||
...defaultConfig,
|
||||
...rawConfig,
|
||||
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
|
||||
sections: rawConfig?.sections ?? defaultConfig.sections,
|
||||
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
|
||||
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
|
||||
...effectiveConfig,
|
||||
modal: { ...defaultConfig.modal, ...effectiveConfig?.modal },
|
||||
sections: effectiveConfig?.sections ?? defaultConfig.sections,
|
||||
saveConfig: { ...defaultConfig.saveConfig, ...effectiveConfig?.saveConfig },
|
||||
editMode: { ...defaultConfig.editMode, ...effectiveConfig?.editMode },
|
||||
};
|
||||
|
||||
// 테이블 목록
|
||||
|
|
|
|||
|
|
@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({
|
|||
};
|
||||
|
||||
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
|
||||
updateTableConfig({
|
||||
uiConfig: { ...tableConfig.uiConfig, ...updates },
|
||||
});
|
||||
const newUiConfig = { ...tableConfig.uiConfig, ...updates };
|
||||
// 새 버튼 설정이 사용되면 레거시 addButtonType 제거
|
||||
if ("showSearchButton" in updates || "showAddRowButton" in updates) {
|
||||
delete (newUiConfig as any).addButtonType;
|
||||
}
|
||||
updateTableConfig({ uiConfig: newUiConfig });
|
||||
};
|
||||
|
||||
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ interface ItemSearchModalProps {
|
|||
onClose: () => void;
|
||||
onSelect: (items: ItemInfo[]) => void;
|
||||
companyCode?: string;
|
||||
existingItemIds?: Set<string>;
|
||||
}
|
||||
|
||||
function ItemSearchModal({
|
||||
|
|
@ -93,6 +94,7 @@ function ItemSearchModal({
|
|||
onClose,
|
||||
onSelect,
|
||||
companyCode,
|
||||
existingItemIds,
|
||||
}: ItemSearchModalProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||
|
|
@ -182,7 +184,7 @@ function ItemSearchModal({
|
|||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="w-8 px-2 py-2 text-center">
|
||||
<Checkbox
|
||||
|
|
@ -200,10 +202,13 @@ function ItemSearchModal({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
{items.map((item) => {
|
||||
const alreadyAdded = existingItemIds?.has(item.id) || false;
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (alreadyAdded) return;
|
||||
setSelectedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
|
|
@ -212,14 +217,19 @@ function ItemSearchModal({
|
|||
});
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer border-t transition-colors",
|
||||
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
|
||||
"border-t transition-colors",
|
||||
alreadyAdded
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "cursor-pointer",
|
||||
!alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "",
|
||||
)}
|
||||
>
|
||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
disabled={alreadyAdded}
|
||||
onCheckedChange={(checked) => {
|
||||
if (alreadyAdded) return;
|
||||
setSelectedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(item.id);
|
||||
|
|
@ -231,12 +241,14 @@ function ItemSearchModal({
|
|||
</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
{item.item_number}
|
||||
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">(추가됨)</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.item_name}</td>
|
||||
<td className="px-3 py-2">{item.type}</td>
|
||||
<td className="px-3 py-2">{item.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
|
@ -739,37 +751,40 @@ export function BomItemEditorComponent({
|
|||
[originalNotifyChange, markChanged],
|
||||
);
|
||||
|
||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
|
||||
useEffect(() => {
|
||||
if (isDesignMode || !bomId) return;
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
|
||||
bomId,
|
||||
treeDataLength: treeData.length,
|
||||
hasRef: !!handleSaveAllRef.current,
|
||||
});
|
||||
if (treeData.length > 0 && handleSaveAllRef.current) {
|
||||
if (handleSaveAllRef.current) {
|
||||
const savePromise = handleSaveAllRef.current();
|
||||
if (detail?.pendingPromises) {
|
||||
detail.pendingPromises.push(savePromise);
|
||||
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeFormSave", handler);
|
||||
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
|
||||
return () => window.removeEventListener("beforeFormSave", handler);
|
||||
}, [isDesignMode, bomId, treeData.length]);
|
||||
|
||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||
}, [isDesignMode, bomId]);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
if (!bomId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// 저장 시점에도 최신 version_id 조회
|
||||
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
||||
// version_id 확보: 없으면 서버에서 자동 초기화
|
||||
let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
||||
if (!saveVersionId) {
|
||||
try {
|
||||
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
|
||||
if (initRes.data?.success && initRes.data.data?.versionId) {
|
||||
saveVersionId = initRes.data.data.versionId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[BomItemEditor] 버전 초기화 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
|
||||
const result: any[] = [];
|
||||
|
|
@ -797,7 +812,7 @@ export function BomItemEditorComponent({
|
|||
: null;
|
||||
|
||||
if (node._isNew) {
|
||||
const payload: Record<string, any> = {
|
||||
const raw: Record<string, any> = {
|
||||
...node.data,
|
||||
[fkColumn]: bomId,
|
||||
[parentKeyColumn]: realParentId,
|
||||
|
|
@ -806,10 +821,16 @@ export function BomItemEditorComponent({
|
|||
company_code: companyCode || undefined,
|
||||
version_id: saveVersionId || undefined,
|
||||
};
|
||||
delete payload.id;
|
||||
delete payload.tempId;
|
||||
delete payload._isNew;
|
||||
delete payload._isDeleted;
|
||||
// bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거)
|
||||
const payload: Record<string, any> = {};
|
||||
const validKeys = new Set([
|
||||
fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id",
|
||||
"quantity", "unit", "loss_rate", "remark", "process_type",
|
||||
"base_qty", "revision", "version_id", "company_code", "writer",
|
||||
]);
|
||||
Object.keys(raw).forEach((k) => {
|
||||
if (validKeys.has(k)) payload[k] = raw[k];
|
||||
});
|
||||
|
||||
const resp = await apiClient.post(
|
||||
`/table-management/tables/${mainTableName}/add`,
|
||||
|
|
@ -820,17 +841,14 @@ export function BomItemEditorComponent({
|
|||
savedCount++;
|
||||
} else if (node.id) {
|
||||
const updatedData: Record<string, any> = {
|
||||
...node.data,
|
||||
id: node.id,
|
||||
[fkColumn]: bomId,
|
||||
[parentKeyColumn]: realParentId,
|
||||
seq_no: String(seqNo),
|
||||
level: String(level),
|
||||
};
|
||||
delete updatedData.tempId;
|
||||
delete updatedData._isNew;
|
||||
delete updatedData._isDeleted;
|
||||
Object.keys(updatedData).forEach((k) => {
|
||||
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
|
||||
["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => {
|
||||
if (node.data[k] !== undefined) updatedData[k] = node.data[k];
|
||||
});
|
||||
|
||||
await apiClient.put(
|
||||
|
|
@ -919,6 +937,39 @@ export function BomItemEditorComponent({
|
|||
setItemSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
// 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용)
|
||||
const existingItemIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
const fkField = cfg.dataSource?.foreignKey || "child_item_id";
|
||||
|
||||
if (addTargetParentId === null) {
|
||||
// 루트 레벨 추가: 루트 노드의 형제들만 체크
|
||||
for (const n of treeData) {
|
||||
const fk = n.data[fkField];
|
||||
if (fk) ids.add(fk);
|
||||
}
|
||||
} else {
|
||||
// 하위 추가: 해당 부모의 직속 자식들만 체크
|
||||
const findParent = (nodes: BomItemNode[]): BomItemNode | null => {
|
||||
for (const n of nodes) {
|
||||
if (n.tempId === addTargetParentId) return n;
|
||||
const found = findParent(n.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const parent = findParent(treeData);
|
||||
if (parent) {
|
||||
for (const child of parent.children) {
|
||||
const fk = child.data[fkField];
|
||||
if (fk) ids.add(fk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [treeData, cfg, addTargetParentId]);
|
||||
|
||||
// 루트 품목 추가 시작
|
||||
const handleAddRoot = useCallback(() => {
|
||||
setAddTargetParentId(null);
|
||||
|
|
@ -1338,6 +1389,7 @@ export function BomItemEditorComponent({
|
|||
onClose={() => setItemSearchOpen(false)}
|
||||
onSelect={handleItemSelect}
|
||||
companyCode={companyCode}
|
||||
existingItemIds={existingItemIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
|
|
@ -35,6 +42,20 @@ export function BomDetailEditModal({
|
|||
}: BomDetailEditModalProps) {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !isRootNode) {
|
||||
apiClient.get("/table-categories/bom_detail/process_type/values")
|
||||
.then((res) => {
|
||||
const values = res.data?.data || [];
|
||||
if (values.length > 0) {
|
||||
setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label })));
|
||||
}
|
||||
})
|
||||
.catch(() => { /* 카테고리 없으면 빈 배열 유지 */ });
|
||||
}
|
||||
}, [open, isRootNode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (node && open) {
|
||||
|
|
@ -47,9 +68,7 @@ export function BomDetailEditModal({
|
|||
} else {
|
||||
setFormData({
|
||||
quantity: node.quantity || "",
|
||||
unit: node.unit || node.detail_unit || "",
|
||||
process_type: node.process_type || "",
|
||||
base_qty: node.base_qty || "",
|
||||
loss_rate: node.loss_rate || "",
|
||||
remark: node.remark || "",
|
||||
});
|
||||
|
|
@ -67,11 +86,15 @@ export function BomDetailEditModal({
|
|||
try {
|
||||
const targetTable = isRootNode ? "bom" : tableName;
|
||||
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
|
||||
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
|
||||
await apiClient.put(`/table-management/tables/${targetTable}/edit`, {
|
||||
originalData: { id: realId },
|
||||
updatedData: { id: realId, ...formData },
|
||||
});
|
||||
onSaved?.();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("[BomDetailEdit] 저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -126,11 +149,19 @@ export function BomDetailEditModal({
|
|||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">단위</Label>
|
||||
{isRootNode ? (
|
||||
<Input
|
||||
value={formData.unit}
|
||||
onChange={(e) => handleChange("unit", e.target.value)}
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={node?.child_unit || node?.unit || "-"}
|
||||
disabled
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -139,12 +170,28 @@ export function BomDetailEditModal({
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">공정</Label>
|
||||
{processOptions.length > 0 ? (
|
||||
<Select
|
||||
value={formData.process_type || ""}
|
||||
onValueChange={(v) => handleChange("process_type", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="공정 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={formData.process_type}
|
||||
onChange={(e) => handleChange("process_type", e.target.value)}
|
||||
placeholder="예: 조립공정"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">로스율 (%)</Label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,609 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { importFromExcel } from "@/lib/utils/excelExport";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomExcelUploadModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
/** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */
|
||||
bomId?: string;
|
||||
bomName?: string;
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
rowIndex: number;
|
||||
level: number;
|
||||
item_number: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
process_type: string;
|
||||
remark: string;
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
isHeader?: boolean;
|
||||
}
|
||||
|
||||
type UploadStep = "upload" | "preview" | "result";
|
||||
|
||||
const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"];
|
||||
|
||||
const HEADER_MAP: Record<string, string> = {
|
||||
"레벨": "level",
|
||||
"level": "level",
|
||||
"품번": "item_number",
|
||||
"품목코드": "item_number",
|
||||
"item_number": "item_number",
|
||||
"item_code": "item_number",
|
||||
"품명": "item_name",
|
||||
"품목명": "item_name",
|
||||
"item_name": "item_name",
|
||||
"소요량": "quantity",
|
||||
"수량": "quantity",
|
||||
"quantity": "quantity",
|
||||
"qty": "quantity",
|
||||
"단위": "unit",
|
||||
"unit": "unit",
|
||||
"공정구분": "process_type",
|
||||
"공정": "process_type",
|
||||
"process_type": "process_type",
|
||||
"비고": "remark",
|
||||
"remark": "remark",
|
||||
};
|
||||
|
||||
export function BomExcelUploadModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
bomId,
|
||||
bomName,
|
||||
}: BomExcelUploadModalProps) {
|
||||
const isVersionMode = !!bomId;
|
||||
|
||||
const [step, setStep] = useState<UploadStep>("upload");
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<any>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [versionName, setVersionName] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep("upload");
|
||||
setParsedRows([]);
|
||||
setFileName("");
|
||||
setUploadResult(null);
|
||||
setUploading(false);
|
||||
setVersionName("");
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}, [reset, onOpenChange]);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setFileName(file.name);
|
||||
|
||||
try {
|
||||
const rawData = await importFromExcel(file);
|
||||
if (!rawData || rawData.length === 0) {
|
||||
toast.error("엑셀 파일에 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstRow = rawData[0];
|
||||
const excelHeaders = Object.keys(firstRow);
|
||||
const fieldMap: Record<string, string> = {};
|
||||
|
||||
for (const header of excelHeaders) {
|
||||
const normalized = header.trim().toLowerCase();
|
||||
const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()];
|
||||
if (mapped) {
|
||||
fieldMap[header] = mapped;
|
||||
}
|
||||
}
|
||||
|
||||
const hasItemNumber = excelHeaders.some(h => {
|
||||
const n = h.trim().toLowerCase();
|
||||
return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number";
|
||||
});
|
||||
if (!hasItemNumber) {
|
||||
toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed: ParsedRow[] = [];
|
||||
for (let index = 0; index < rawData.length; index++) {
|
||||
const row = rawData[index];
|
||||
const getField = (fieldName: string): any => {
|
||||
for (const [excelKey, mappedField] of Object.entries(fieldMap)) {
|
||||
if (mappedField === fieldName) return row[excelKey];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const levelRaw = getField("level");
|
||||
const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10);
|
||||
const itemNumber = String(getField("item_number") || "").trim();
|
||||
const itemName = String(getField("item_name") || "").trim();
|
||||
const quantityRaw = getField("quantity");
|
||||
const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1"));
|
||||
const unit = String(getField("unit") || "").trim();
|
||||
const processType = String(getField("process_type") || "").trim();
|
||||
const remark = String(getField("remark") || "").trim();
|
||||
|
||||
let valid = true;
|
||||
let error = "";
|
||||
const isHeader = level === 0;
|
||||
|
||||
if (!itemNumber) {
|
||||
valid = false;
|
||||
error = "품번 필수";
|
||||
} else if (isNaN(level) || level < 0) {
|
||||
valid = false;
|
||||
error = "레벨 오류";
|
||||
} else if (index > 0) {
|
||||
const prevLevel = parsed[index - 1]?.level ?? 0;
|
||||
if (level > prevLevel + 1) {
|
||||
valid = false;
|
||||
error = `레벨 점프 (이전: ${prevLevel})`;
|
||||
}
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
rowIndex: index + 1,
|
||||
isHeader,
|
||||
level,
|
||||
item_number: itemNumber,
|
||||
item_name: itemName,
|
||||
quantity: isNaN(quantity) ? 1 : quantity,
|
||||
unit,
|
||||
process_type: processType,
|
||||
remark,
|
||||
valid,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
const filtered = parsed.filter(r => r.item_number !== "");
|
||||
|
||||
// 새 BOM 생성 모드: 레벨 0 필수
|
||||
if (!isVersionMode) {
|
||||
const hasHeader = filtered.some(r => r.level === 0);
|
||||
if (!hasHeader) {
|
||||
toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setParsedRows(filtered);
|
||||
setStep("preview");
|
||||
} catch (err: any) {
|
||||
toast.error(`파일 파싱 실패: ${err.message}`);
|
||||
}
|
||||
}, [isVersionMode]);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
const invalidRows = parsedRows.filter(r => !r.valid);
|
||||
if (invalidRows.length > 0) {
|
||||
toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const rowPayload = parsedRows.map(r => ({
|
||||
level: r.level,
|
||||
item_number: r.item_number,
|
||||
item_name: r.item_name,
|
||||
quantity: r.quantity,
|
||||
unit: r.unit,
|
||||
process_type: r.process_type,
|
||||
remark: r.remark,
|
||||
}));
|
||||
|
||||
let res;
|
||||
if (isVersionMode) {
|
||||
res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, {
|
||||
rows: rowPayload,
|
||||
versionName: versionName.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
res = await apiClient.post("/bom/excel-upload", { rows: rowPayload });
|
||||
}
|
||||
|
||||
if (res.data?.success) {
|
||||
setUploadResult(res.data.data);
|
||||
setStep("result");
|
||||
const msg = isVersionMode
|
||||
? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}건`
|
||||
: `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}건`;
|
||||
toast.success(msg);
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const errData = res.data?.data;
|
||||
if (errData?.unmatchedItems?.length > 0) {
|
||||
toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`);
|
||||
setParsedRows(prev => prev.map(r => {
|
||||
if (errData.unmatchedItems.includes(r.item_number)) {
|
||||
return { ...r, valid: false, error: "품번 미등록" };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
} else {
|
||||
toast.error(res.data?.message || "업로드 실패");
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [parsedRows, isVersionMode, bomId, versionName, onSuccess]);
|
||||
|
||||
const handleDownloadTemplate = useCallback(async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const XLSX = await import("xlsx");
|
||||
let data: Record<string, any>[] = [];
|
||||
|
||||
if (isVersionMode && bomId) {
|
||||
// 기존 BOM 데이터를 템플릿으로 다운로드
|
||||
try {
|
||||
const res = await apiClient.get(`/bom/${bomId}/excel-download`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
data = res.data.data.map((row: any) => ({
|
||||
"레벨": row.level,
|
||||
"품번": row.item_number,
|
||||
"품명": row.item_name,
|
||||
"소요량": row.quantity,
|
||||
"단위": row.unit,
|
||||
"공정구분": row.process_type,
|
||||
"비고": row.remark,
|
||||
}));
|
||||
}
|
||||
} catch { /* 데이터 없으면 빈 템플릿 */ }
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
if (isVersionMode) {
|
||||
data = [
|
||||
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
|
||||
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
|
||||
];
|
||||
} else {
|
||||
data = [
|
||||
{ "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" },
|
||||
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
|
||||
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "BOM");
|
||||
ws["!cols"] = [
|
||||
{ wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 },
|
||||
{ wch: 8 }, { wch: 12 }, { wch: 20 },
|
||||
];
|
||||
|
||||
const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
|
||||
XLSX.writeFile(wb, filename);
|
||||
toast.success("템플릿 다운로드 완료");
|
||||
} catch (err: any) {
|
||||
toast.error(`다운로드 실패: ${err.message}`);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}, [isVersionMode, bomId, bomName]);
|
||||
|
||||
const headerRow = parsedRows.find(r => r.isHeader);
|
||||
const detailRows = parsedRows.filter(r => !r.isHeader);
|
||||
const validCount = parsedRows.filter(r => r.valid).length;
|
||||
const invalidCount = parsedRows.filter(r => !r.valid).length;
|
||||
|
||||
const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드";
|
||||
const description = isVersionMode
|
||||
? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.`
|
||||
: "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step 1: 파일 업로드 */}
|
||||
{step === "upload" && (
|
||||
<div className="space-y-4">
|
||||
{/* 새 버전 모드: 버전명 입력 */}
|
||||
{isVersionMode && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">버전명 (미입력 시 자동 채번)</Label>
|
||||
<Input
|
||||
value={versionName}
|
||||
onChange={(e) => setVersionName(e.target.value)}
|
||||
placeholder="예: 2.0"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer",
|
||||
"hover:border-primary/50 hover:bg-muted/50 transition-colors",
|
||||
"border-muted-foreground/25",
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">엑셀 파일을 선택하세요</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">.xlsx, .xls, .csv 형식 지원</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<p className="text-xs font-medium mb-2">엑셀 컬럼 형식</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{EXPECTED_HEADERS.map((h, i) => (
|
||||
<span
|
||||
key={h}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded-full",
|
||||
i < 2 ? "bg-primary/10 text-primary font-medium" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{h}{i < 2 ? " *" : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||
{isVersionMode
|
||||
? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다."
|
||||
: "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadTemplate}
|
||||
disabled={downloading}
|
||||
className="w-full"
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isVersionMode && bomName ? `현재 BOM 데이터로 템플릿 다운로드` : "빈 템플릿 다운로드"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 미리보기 */}
|
||||
{step === "preview" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{fileName}</span>
|
||||
{!isVersionMode && headerRow && (
|
||||
<span className="text-xs font-medium">마스터: {headerRow.item_number}</span>
|
||||
)}
|
||||
<span className="text-xs">
|
||||
하위품목 <span className="font-medium">{detailRows.length}</span>건
|
||||
</span>
|
||||
{invalidCount > 0 && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" /> {invalidCount}건 오류
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={reset} className="h-7 text-xs">
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
다시 선택
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto border rounded-md">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-medium w-8">#</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium w-12">구분</th>
|
||||
<th className="px-2 py-1.5 text-center font-medium w-12">레벨</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">품번</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">품명</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium w-16">소요량</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium w-14">단위</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium w-20">공정</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedRows.map((row) => (
|
||||
<tr
|
||||
key={row.rowIndex}
|
||||
className={cn(
|
||||
"border-t hover:bg-muted/30",
|
||||
row.isHeader && "bg-blue-50/50",
|
||||
!row.valid && "bg-destructive/5",
|
||||
)}
|
||||
>
|
||||
<td className="px-2 py-1 text-muted-foreground">{row.rowIndex}</td>
|
||||
<td className="px-2 py-1">
|
||||
{row.isHeader ? (
|
||||
<span className="text-[10px] text-blue-600 font-medium bg-blue-50 px-1.5 py-0.5 rounded">
|
||||
{isVersionMode ? "건너뜀" : "마스터"}
|
||||
</span>
|
||||
) : row.valid ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<span className="flex items-center gap-1" title={row.error}>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded px-1.5 py-0.5 text-[10px] font-mono",
|
||||
row.isHeader ? "bg-blue-100 text-blue-700 font-medium" : "bg-muted",
|
||||
)}
|
||||
style={{ marginLeft: `${row.level * 8}px` }}
|
||||
>
|
||||
{row.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn("px-2 py-1 font-mono", row.isHeader && "font-semibold")}>{row.item_number}</td>
|
||||
<td className={cn("px-2 py-1", row.isHeader && "font-semibold")}>{row.item_name}</td>
|
||||
<td className="px-2 py-1 text-right font-mono">{row.quantity}</td>
|
||||
<td className="px-2 py-1">{row.unit}</td>
|
||||
<td className="px-2 py-1">{row.process_type}</td>
|
||||
<td className="px-2 py-1 text-muted-foreground truncate max-w-[100px]">{row.remark}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{invalidCount > 0 && (
|
||||
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
|
||||
<div className="font-medium mb-1">유효하지 않은 행 ({invalidCount}건)</div>
|
||||
<ul className="space-y-0.5 ml-3 list-disc">
|
||||
{parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
|
||||
<li key={r.rowIndex}>{r.rowIndex}행: {r.error}</li>
|
||||
))}
|
||||
{invalidCount > 5 && <li>...외 {invalidCount - 5}건</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isVersionMode
|
||||
? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다."
|
||||
: "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다."
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 결과 */}
|
||||
{step === "result" && uploadResult && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mb-3">
|
||||
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
하위품목 {uploadResult.insertedCount}건이 등록되었습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn("grid gap-3 max-w-xs mx-auto", isVersionMode ? "grid-cols-1" : "grid-cols-2")}>
|
||||
{!isVersionMode && (
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">1</div>
|
||||
<div className="text-xs text-muted-foreground">BOM 마스터</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{uploadResult.insertedCount}</div>
|
||||
<div className="text-xs text-muted-foreground">하위품목</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{step === "upload" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || invalidCount > 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{uploading ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> 업로드 중...</>
|
||||
) : (
|
||||
<><Upload className="mr-2 h-4 w-4" />
|
||||
{isVersionMode ? `새 버전 생성 (${detailRows.length}건)` : `BOM 생성 (${detailRows.length}건)`}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{step === "result" && (
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
History,
|
||||
GitBranch,
|
||||
Check,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { BomDetailEditModal } from "./BomDetailEditModal";
|
||||
import { BomHistoryModal } from "./BomHistoryModal";
|
||||
import { BomVersionModal } from "./BomVersionModal";
|
||||
import { BomExcelUploadModal } from "./BomExcelUploadModal";
|
||||
|
||||
interface BomTreeNode {
|
||||
id: string;
|
||||
|
|
@ -77,6 +79,7 @@ export function BomTreeComponent({
|
|||
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||
|
||||
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
|
||||
|
|
@ -138,6 +141,23 @@ export function BomTreeComponent({
|
|||
const showHistory = features.showHistory !== false;
|
||||
const showVersion = features.showVersion !== false;
|
||||
|
||||
// 카테고리 라벨 캐시 (process_type 등)
|
||||
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
|
||||
useEffect(() => {
|
||||
const loadLabels = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
|
||||
const vals = res.data?.data || [];
|
||||
if (vals.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
|
||||
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
};
|
||||
loadLabels();
|
||||
}, [detailTable]);
|
||||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
|
||||
|
|
@ -168,7 +188,18 @@ export function BomTreeComponent({
|
|||
setLoading(true);
|
||||
try {
|
||||
const searchFilter: Record<string, any> = { [foreignKey]: bomId };
|
||||
const versionId = headerData?.current_version_id;
|
||||
let versionId = headerData?.current_version_id;
|
||||
|
||||
// version_id가 없으면 서버에서 자동 초기화
|
||||
if (!versionId) {
|
||||
try {
|
||||
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
|
||||
if (initRes.data?.success && initRes.data.data?.versionId) {
|
||||
versionId = initRes.data.data.versionId;
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
if (versionId) {
|
||||
searchFilter.version_id = versionId;
|
||||
}
|
||||
|
|
@ -263,6 +294,7 @@ export function BomTreeComponent({
|
|||
item_name: raw.item_name || "",
|
||||
item_code: raw.item_number || raw.item_code || "",
|
||||
item_type: raw.item_type || raw.division || "",
|
||||
unit: raw.unit || raw.item_unit || "",
|
||||
} as BomHeaderInfo;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -348,6 +380,18 @@ export function BomTreeComponent({
|
|||
detail.editData[key] = (headerInfo as any)[key];
|
||||
}
|
||||
});
|
||||
|
||||
// entity join된 필드를 dot notation으로도 매핑 (item_info.xxx 형식)
|
||||
const h = headerInfo as Record<string, any>;
|
||||
if (h.item_name) detail.editData["item_info.item_name"] = h.item_name;
|
||||
if (h.item_type) detail.editData["item_info.division"] = h.item_type;
|
||||
if (h.item_code || h.item_number) detail.editData["item_info.item_number"] = h.item_code || h.item_number;
|
||||
if (h.unit) detail.editData["item_info.unit"] = h.unit;
|
||||
// entity join alias 형식도 매핑
|
||||
if (h.item_name) detail.editData["item_id_item_name"] = h.item_name;
|
||||
if (h.item_type) detail.editData["item_id_division"] = h.item_type;
|
||||
if (h.item_code || h.item_number) detail.editData["item_id_item_number"] = h.item_code || h.item_number;
|
||||
if (h.unit) detail.editData["item_id_unit"] = h.unit;
|
||||
};
|
||||
// capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행
|
||||
window.addEventListener("openEditModal", handler, true);
|
||||
|
|
@ -461,6 +505,11 @@ export function BomTreeComponent({
|
|||
return <span className="font-medium text-gray-900">{value || "-"}</span>;
|
||||
}
|
||||
|
||||
if (col.key === "status") {
|
||||
const statusMap: Record<string, string> = { active: "사용", inactive: "미사용", developing: "개발중" };
|
||||
return <span>{statusMap[String(value)] || value || "-"}</span>;
|
||||
}
|
||||
|
||||
if (col.key === "quantity" || col.key === "base_qty") {
|
||||
return (
|
||||
<span className="font-medium tabular-nums text-gray-800">
|
||||
|
|
@ -469,6 +518,11 @@ export function BomTreeComponent({
|
|||
);
|
||||
}
|
||||
|
||||
if (col.key === "process_type" && value) {
|
||||
const label = categoryLabels.process_type?.[String(value)] || String(value);
|
||||
return <span>{label}</span>;
|
||||
}
|
||||
|
||||
if (col.key === "loss_rate") {
|
||||
const num = Number(value);
|
||||
if (!num) return <span className="text-gray-300">-</span>;
|
||||
|
|
@ -786,6 +840,15 @@ export function BomTreeComponent({
|
|||
버전
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExcelUploadOpen(true)}
|
||||
className="h-6 gap-1 px-2 text-[10px]"
|
||||
>
|
||||
<FileSpreadsheet className="h-3 w-3" />
|
||||
엑셀
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-gray-200" />
|
||||
<div className="flex overflow-hidden rounded-md border">
|
||||
<button
|
||||
|
|
@ -1098,6 +1161,18 @@ export function BomTreeComponent({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedBomId && (
|
||||
<BomExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
bomId={selectedBomId}
|
||||
bomName={headerInfo?.item_name || ""}
|
||||
onSuccess={() => {
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [newVersionName, setNewVersionName] = useState("");
|
||||
const [showNewInput, setShowNewInput] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bomId) loadVersions();
|
||||
|
|
@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!bomId) return;
|
||||
const trimmed = newVersionName.trim();
|
||||
if (!trimmed) {
|
||||
alert("버전명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
|
||||
if (res.data?.success) loadVersions();
|
||||
} catch (error) {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions`, {
|
||||
tableName, detailTable, versionName: trimmed,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
setNewVersionName("");
|
||||
setShowNewInput(false);
|
||||
loadVersions();
|
||||
} else {
|
||||
alert(res.data?.message || "버전 생성 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const msg = error.response?.data?.message || "버전 생성 실패";
|
||||
alert(msg);
|
||||
console.error("[BomVersion] 생성 실패:", error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
|
|
@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{showNewInput && (
|
||||
<div className="flex items-center gap-2 border-t pt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newVersionName}
|
||||
onChange={(e) => setNewVersionName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()}
|
||||
placeholder="버전명 입력 (예: 2.0, B, 개선판)"
|
||||
className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creating}
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "생성"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setShowNewInput(false); setNewVersionName(""); }}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{!showNewInput && (
|
||||
<Button
|
||||
onClick={() => setShowNewInput(true)}
|
||||
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
신규 버전 생성
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
|
|
|
|||
|
|
@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="z-[99999]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
|
|
|
|||
|
|
@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="flex h-[75vh] flex-col space-y-3">
|
||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
||||
{!isDesignMode && (
|
||||
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
|
||||
{!isDesignMode && !config.readonly && !config.disabled && (
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} hover:border-gray-400 ${uploading ? "opacity-75" : ""} `}
|
||||
onClick={() => {
|
||||
if (!config.disabled && !isDesignMode) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
accept={config.accept}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={config.disabled}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
|
|
@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
|
||||
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
|
||||
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||
{/* 확대/축소 컨트롤 */}
|
||||
{selectedFile && previewImageUrl && (
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
||||
|
|
@ -369,10 +366,10 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
{selectedFile.realFileName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* 우측: 파일 목록 (고정 너비) */}
|
||||
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
|
||||
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
||||
|
|
@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
||||
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • </>}{file.fileExt.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
|
|
@ -434,6 +431,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
{config.allowDownload !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -446,7 +444,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
{!isDesignMode && (
|
||||
)}
|
||||
{!isDesignMode && config.allowDelete !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleViewerClose}
|
||||
onDownload={onFileDownload}
|
||||
onDelete={!isDesignMode ? onFileDelete : undefined}
|
||||
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
|
||||
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
const [forceUpdate, setForceUpdate] = useState(0);
|
||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
|
||||
const filesLoadedFromObjidRef = useRef(false);
|
||||
|
||||
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||
|
|
@ -150,6 +152,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
if (isRecordMode || !recordId) {
|
||||
setUploadedFiles([]);
|
||||
setRepresentativeImageUrl(null);
|
||||
filesLoadedFromObjidRef.current = false;
|
||||
}
|
||||
} else if (prevIsRecordModeRef.current === null) {
|
||||
// 초기 마운트 시 모드 저장
|
||||
|
|
@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
||||
|
||||
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
||||
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
|
||||
// 콤마로 구분된 다중 objid도 처리 (예: "123,456")
|
||||
const imageObjidFromFormData = formData?.[columnName];
|
||||
|
||||
useEffect(() => {
|
||||
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
|
||||
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
|
||||
const objidStr = String(imageObjidFromFormData);
|
||||
if (!imageObjidFromFormData) return;
|
||||
|
||||
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
|
||||
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
|
||||
if (alreadyLoaded) {
|
||||
return;
|
||||
}
|
||||
const rawValue = String(imageObjidFromFormData);
|
||||
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
||||
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
|
||||
|
||||
if (objids.length === 0) return;
|
||||
|
||||
// 모든 objid가 이미 로드되어 있으면 스킵
|
||||
const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id));
|
||||
if (allLoaded) return;
|
||||
|
||||
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
|
||||
(async () => {
|
||||
try {
|
||||
const fileInfoResponse = await getFileInfoByObjid(objidStr);
|
||||
const loadedFiles: FileInfo[] = [];
|
||||
|
||||
for (const objid of objids) {
|
||||
// 이미 로드된 파일은 스킵
|
||||
if (uploadedFiles.some(f => String(f.objid) === objid)) continue;
|
||||
|
||||
const fileInfoResponse = await getFileInfoByObjid(objid);
|
||||
|
||||
if (fileInfoResponse.success && fileInfoResponse.data) {
|
||||
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
|
||||
|
||||
const fileInfo = {
|
||||
objid: objidStr,
|
||||
realFileName: realFileName,
|
||||
fileExt: fileExt,
|
||||
fileSize: fileSize,
|
||||
filePath: getFilePreviewUrl(objidStr),
|
||||
regdate: regdate,
|
||||
loadedFiles.push({
|
||||
objid,
|
||||
realFileName,
|
||||
fileExt,
|
||||
fileSize,
|
||||
filePath: getFilePreviewUrl(objid),
|
||||
regdate,
|
||||
isImage: true,
|
||||
isRepresentative: isRepresentative,
|
||||
};
|
||||
|
||||
setUploadedFiles([fileInfo]);
|
||||
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
||||
isRepresentative,
|
||||
} as FileInfo);
|
||||
} else {
|
||||
// 파일 정보 조회 실패 시 최소 정보로 추가
|
||||
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
|
||||
const minimalFileInfo = {
|
||||
objid: objidStr,
|
||||
realFileName: `image_${objidStr}.jpg`,
|
||||
loadedFiles.push({
|
||||
objid,
|
||||
realFileName: `file_${objid}`,
|
||||
fileExt: '.jpg',
|
||||
fileSize: 0,
|
||||
filePath: getFilePreviewUrl(objidStr),
|
||||
filePath: getFilePreviewUrl(objid),
|
||||
regdate: new Date().toISOString(),
|
||||
isImage: true,
|
||||
};
|
||||
} as FileInfo);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles([minimalFileInfo]);
|
||||
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
||||
if (loadedFiles.length > 0) {
|
||||
setUploadedFiles(loadedFiles);
|
||||
filesLoadedFromObjidRef.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
|
||||
}, [imageObjidFromFormData, columnName, component.id]);
|
||||
|
||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||
|
|
@ -365,6 +373,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
...file,
|
||||
}));
|
||||
|
||||
// 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음
|
||||
if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
||||
let finalFiles = formattedFiles;
|
||||
|
|
@ -427,14 +439,19 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
return; // DB 로드 성공 시 localStorage 무시
|
||||
}
|
||||
|
||||
// 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
||||
// objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음
|
||||
if (filesLoadedFromObjidRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
||||
if (!isRecordMode || !recordId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
||||
|
||||
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
|
||||
// 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용)
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const uniqueKeyForFallback = getUniqueKey();
|
||||
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
||||
|
|
@ -442,6 +459,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||
|
||||
// 빈 데이터로 기존 파일을 덮어쓰지 않음
|
||||
if (currentFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 최신 파일과 현재 파일 비교
|
||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||
|
|
@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleViewerClose}
|
||||
onDownload={handleFileDownload}
|
||||
onDelete={!isDesignMode ? handleFileDelete : undefined}
|
||||
onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined}
|
||||
onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined}
|
||||
/>
|
||||
|
||||
{/* 파일 관리 모달 */}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ export function ProcessWorkStandardComponent({
|
|||
items,
|
||||
routings,
|
||||
workItems,
|
||||
selectedWorkItemDetails,
|
||||
selectedWorkItemId,
|
||||
selectedWorkItemIdByPhase,
|
||||
selectedDetailsByPhase,
|
||||
selection,
|
||||
loading,
|
||||
fetchItems,
|
||||
|
|
@ -105,8 +105,8 @@ export function ProcessWorkStandardComponent({
|
|||
);
|
||||
|
||||
const handleSelectWorkItem = useCallback(
|
||||
(workItemId: string) => {
|
||||
fetchWorkItemDetails(workItemId);
|
||||
(workItemId: string, phaseKey: string) => {
|
||||
fetchWorkItemDetails(workItemId, phaseKey);
|
||||
},
|
||||
[fetchWorkItemDetails]
|
||||
);
|
||||
|
|
@ -191,8 +191,8 @@ export function ProcessWorkStandardComponent({
|
|||
key={phase.key}
|
||||
phase={phase}
|
||||
items={workItemsByPhase[phase.key] || []}
|
||||
selectedWorkItemId={selectedWorkItemId}
|
||||
selectedWorkItemDetails={selectedWorkItemDetails}
|
||||
selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
|
||||
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
||||
detailTypes={config.detailTypes}
|
||||
readonly={config.readonly}
|
||||
onSelectWorkItem={handleSelectWorkItem}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,14 @@ export function DetailFormModal({
|
|||
if (type === "input" && !formData.content?.trim()) return;
|
||||
if (type === "info" && !formData.lookup_target) return;
|
||||
|
||||
onSubmit(formData);
|
||||
const submitData = { ...formData };
|
||||
|
||||
if (type === "info" && !submitData.content?.trim()) {
|
||||
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
|
||||
submitData.content = `${targetLabel} 조회`;
|
||||
}
|
||||
|
||||
onSubmit(submitData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -61,11 +61,24 @@ export function WorkItemAddModal({
|
|||
detailTypes,
|
||||
editItem,
|
||||
}: WorkItemAddModalProps) {
|
||||
const [title, setTitle] = useState(editItem?.title || "");
|
||||
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y");
|
||||
const [description, setDescription] = useState(editItem?.description || "");
|
||||
const [title, setTitle] = useState("");
|
||||
const [isRequired, setIsRequired] = useState("Y");
|
||||
const [description, setDescription] = useState("");
|
||||
const [details, setDetails] = useState<ModalDetail[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && editItem) {
|
||||
setTitle(editItem.title || "");
|
||||
setIsRequired(editItem.is_required || "Y");
|
||||
setDescription(editItem.description || "");
|
||||
} else if (open && !editItem) {
|
||||
setTitle("");
|
||||
setIsRequired("Y");
|
||||
setDescription("");
|
||||
setDetails([]);
|
||||
}
|
||||
}, [open, editItem]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("");
|
||||
setIsRequired("Y");
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ interface WorkPhaseSectionProps {
|
|||
selectedWorkItemDetails: WorkItemDetail[];
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
readonly?: boolean;
|
||||
onSelectWorkItem: (workItemId: string) => void;
|
||||
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
|
||||
onAddWorkItem: (phase: string) => void;
|
||||
onEditWorkItem: (item: WorkItem) => void;
|
||||
onDeleteWorkItem: (id: string) => void;
|
||||
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void;
|
||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||
onDeleteDetail: (id: string) => void;
|
||||
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
|
||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
|
||||
onDeleteDetail: (id: string, phaseKey: string) => void;
|
||||
}
|
||||
|
||||
export function WorkPhaseSection({
|
||||
|
|
@ -45,9 +45,6 @@ export function WorkPhaseSection({
|
|||
onDeleteDetail,
|
||||
}: WorkPhaseSectionProps) {
|
||||
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
|
||||
const isThisSectionSelected = items.some(
|
||||
(i) => i.id === selectedWorkItemId
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
|
|
@ -94,7 +91,7 @@ export function WorkPhaseSection({
|
|||
item={item}
|
||||
isSelected={selectedWorkItemId === item.id}
|
||||
readonly={readonly}
|
||||
onClick={() => onSelectWorkItem(item.id)}
|
||||
onClick={() => onSelectWorkItem(item.id, phase.key)}
|
||||
onEdit={() => onEditWorkItem(item)}
|
||||
onDelete={() => onDeleteWorkItem(item.id)}
|
||||
/>
|
||||
|
|
@ -106,15 +103,15 @@ export function WorkPhaseSection({
|
|||
{/* 우측: 상세 리스트 */}
|
||||
<div className="flex-1">
|
||||
<WorkItemDetailList
|
||||
workItem={isThisSectionSelected ? selectedItem : null}
|
||||
details={isThisSectionSelected ? selectedWorkItemDetails : []}
|
||||
workItem={selectedItem}
|
||||
details={selectedWorkItemDetails}
|
||||
detailTypes={detailTypes}
|
||||
readonly={readonly}
|
||||
onCreateDetail={(data) =>
|
||||
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data)
|
||||
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
|
||||
}
|
||||
onUpdateDetail={onUpdateDetail}
|
||||
onDeleteDetail={onDeleteDetail}
|
||||
onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)}
|
||||
onDeleteDetail={(id) => onDeleteDetail(id, phase.key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
|||
{ value: "inspect", label: "검사항목" },
|
||||
{ value: "procedure", label: "작업절차" },
|
||||
{ value: "input", label: "직접입력" },
|
||||
{ value: "info", label: "정보조회" },
|
||||
],
|
||||
splitRatio: 30,
|
||||
leftPanelTitle: "품목 및 공정 선택",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
const [items, setItems] = useState<ItemData[]>([]);
|
||||
const [routings, setRoutings] = useState<RoutingVersion[]>([]);
|
||||
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
||||
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]);
|
||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
|
||||
// 섹션(phase)별 독립적인 선택 상태 관리
|
||||
const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState<Record<string, string | null>>({});
|
||||
const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState<Record<string, WorkItemDetail[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -101,15 +102,15 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 작업 항목 상세 조회
|
||||
const fetchWorkItemDetails = useCallback(async (workItemId: string) => {
|
||||
// 작업 항목 상세 조회 (phase별 독립 저장)
|
||||
const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`${API_BASE}/work-items/${workItemId}/details`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
setSelectedWorkItemDetails(res.data.data);
|
||||
setSelectedWorkItemId(workItemId);
|
||||
setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data }));
|
||||
setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("상세 조회 실패", err);
|
||||
|
|
@ -129,8 +130,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
processName: null,
|
||||
}));
|
||||
setWorkItems([]);
|
||||
setSelectedWorkItemDetails([]);
|
||||
setSelectedWorkItemId(null);
|
||||
setSelectedDetailsByPhase({});
|
||||
setSelectedWorkItemIdByPhase({});
|
||||
await fetchRoutings(itemCode);
|
||||
},
|
||||
[fetchRoutings]
|
||||
|
|
@ -151,8 +152,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
routingDetailId,
|
||||
processName,
|
||||
}));
|
||||
setSelectedWorkItemDetails([]);
|
||||
setSelectedWorkItemId(null);
|
||||
setSelectedDetailsByPhase({});
|
||||
setSelectedWorkItemIdByPhase({});
|
||||
await fetchWorkItems(routingDetailId);
|
||||
},
|
||||
[fetchWorkItems]
|
||||
|
|
@ -233,28 +234,43 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
|
||||
if (res.data?.success && selection.routingDetailId) {
|
||||
await fetchWorkItems(selection.routingDetailId);
|
||||
if (selectedWorkItemId === id) {
|
||||
setSelectedWorkItemDetails([]);
|
||||
setSelectedWorkItemId(null);
|
||||
// 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화
|
||||
setSelectedWorkItemIdByPhase(prev => {
|
||||
const next = { ...prev };
|
||||
for (const phaseKey of Object.keys(next)) {
|
||||
if (next[phaseKey] === id) {
|
||||
next[phaseKey] = null;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectedDetailsByPhase(prev => {
|
||||
const next = { ...prev };
|
||||
for (const phaseKey of Object.keys(next)) {
|
||||
if (selectedWorkItemIdByPhase[phaseKey] === id) {
|
||||
next[phaseKey] = [];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("작업 항목 삭제 실패", err);
|
||||
}
|
||||
},
|
||||
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems]
|
||||
[selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems]
|
||||
);
|
||||
|
||||
// 상세 추가
|
||||
const createDetail = useCallback(
|
||||
async (workItemId: string, data: Partial<WorkItemDetail>) => {
|
||||
async (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.post(`${API_BASE}/work-item-details`, {
|
||||
work_item_id: workItemId,
|
||||
...data,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
await fetchWorkItemDetails(workItemId);
|
||||
await fetchWorkItemDetails(workItemId, phaseKey);
|
||||
if (selection.routingDetailId) {
|
||||
await fetchWorkItems(selection.routingDetailId);
|
||||
}
|
||||
|
|
@ -268,32 +284,36 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
|
||||
// 상세 수정
|
||||
const updateDetail = useCallback(
|
||||
async (id: string, data: Partial<WorkItemDetail>) => {
|
||||
async (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.put(
|
||||
`${API_BASE}/work-item-details/${id}`,
|
||||
data
|
||||
);
|
||||
if (res.data?.success && selectedWorkItemId) {
|
||||
await fetchWorkItemDetails(selectedWorkItemId);
|
||||
if (res.data?.success) {
|
||||
const workItemId = selectedWorkItemIdByPhase[phaseKey];
|
||||
if (workItemId) {
|
||||
await fetchWorkItemDetails(workItemId, phaseKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("상세 수정 실패", err);
|
||||
}
|
||||
},
|
||||
[selectedWorkItemId, fetchWorkItemDetails]
|
||||
[selectedWorkItemIdByPhase, fetchWorkItemDetails]
|
||||
);
|
||||
|
||||
// 상세 삭제
|
||||
const deleteDetail = useCallback(
|
||||
async (id: string) => {
|
||||
async (id: string, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.delete(
|
||||
`${API_BASE}/work-item-details/${id}`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
if (selectedWorkItemId) {
|
||||
await fetchWorkItemDetails(selectedWorkItemId);
|
||||
const workItemId = selectedWorkItemIdByPhase[phaseKey];
|
||||
if (workItemId) {
|
||||
await fetchWorkItemDetails(workItemId, phaseKey);
|
||||
}
|
||||
if (selection.routingDetailId) {
|
||||
await fetchWorkItems(selection.routingDetailId);
|
||||
|
|
@ -304,7 +324,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
}
|
||||
},
|
||||
[
|
||||
selectedWorkItemId,
|
||||
selectedWorkItemIdByPhase,
|
||||
selection.routingDetailId,
|
||||
fetchWorkItemDetails,
|
||||
fetchWorkItems,
|
||||
|
|
@ -315,8 +335,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
items,
|
||||
routings,
|
||||
workItems,
|
||||
selectedWorkItemDetails,
|
||||
selectedWorkItemId,
|
||||
selectedWorkItemIdByPhase,
|
||||
selectedDetailsByPhase,
|
||||
selection,
|
||||
loading,
|
||||
saving,
|
||||
|
|
@ -325,7 +345,6 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
selectProcess,
|
||||
fetchWorkItems,
|
||||
fetchWorkItemDetails,
|
||||
setSelectedWorkItemId,
|
||||
createWorkItem,
|
||||
updateWorkItem,
|
||||
deleteWorkItem,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ interface V2RepeaterRendererProps {
|
|||
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||
parentId?: string | number;
|
||||
formData?: Record<string, any>;
|
||||
groupedData?: Record<string, any>[];
|
||||
}
|
||||
|
||||
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||
|
|
@ -33,6 +34,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
|||
onButtonClick,
|
||||
parentId,
|
||||
formData,
|
||||
groupedData,
|
||||
}) => {
|
||||
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
|
||||
const config: V2RepeaterConfig = React.useMemo(() => {
|
||||
|
|
@ -105,6 +107,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
|||
onButtonClick={onButtonClick}
|
||||
className={component?.className}
|
||||
formData={formData}
|
||||
groupedData={groupedData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
Trash2,
|
||||
Settings,
|
||||
Move,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
|
|
@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext";
|
|||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { PanelInlineComponent } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
|
||||
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
|
||||
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
|
||||
|
||||
// 수정 모달 상태
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
|
@ -3010,6 +3013,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
|
||||
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="mr-1 h-4 w-4" />
|
||||
엑셀
|
||||
</Button>
|
||||
)}
|
||||
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
|
|
@ -3017,6 +3027,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{componentConfig.leftPanel?.showSearch && (
|
||||
<div className="flex-shrink-0 border-b p-2">
|
||||
|
|
@ -3361,6 +3372,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}));
|
||||
|
||||
// 🔧 그룹화된 데이터 렌더링
|
||||
const hasGroupedLeftActions = !isDesignMode && (
|
||||
(componentConfig.leftPanel?.showEdit !== false) ||
|
||||
(componentConfig.leftPanel?.showDelete !== false)
|
||||
);
|
||||
if (groupedLeftData.length > 0) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
|
|
@ -3385,6 +3400,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{hasGroupedLeftActions && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
|
|
@ -3399,7 +3418,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<tr
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
className={`group hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -3417,6 +3436,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasGroupedLeftActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("left", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
{(componentConfig.leftPanel?.showDelete !== false) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("left", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -3429,6 +3476,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
|
||||
// 🔧 일반 테이블 렌더링 (그룹화 없음)
|
||||
const hasLeftTableActions = !isDesignMode && (
|
||||
(componentConfig.leftPanel?.showEdit !== false) ||
|
||||
(componentConfig.leftPanel?.showDelete !== false)
|
||||
);
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
|
|
@ -3447,6 +3498,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{hasLeftTableActions && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
|
|
@ -3461,7 +3516,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<tr
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
className={`group hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -3479,6 +3534,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasLeftTableActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("left", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
{(componentConfig.leftPanel?.showDelete !== false) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("left", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -4998,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{(componentConfig.leftPanel as any)?.showBomExcelUpload && (
|
||||
<BomExcelUploadModal
|
||||
open={bomExcelUploadOpen}
|
||||
onOpenChange={setBomExcelUploadOpen}
|
||||
onSuccess={() => {
|
||||
loadLeftData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,53 +14,71 @@ import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
|||
|
||||
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
|
||||
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
|
||||
// 다중 이미지(콤마 구분)인 경우 대표 이미지를 우선 표시
|
||||
const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||
const [displayObjid, setDisplayObjid] = React.useState<string>("");
|
||||
const [error, setError] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
// 다중 이미지인 경우 대표 이미지(첫 번째)만 사용
|
||||
const rawValue = String(value);
|
||||
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||
const parts = rawValue.split(",").map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// 단일 값 또는 경로인 경우
|
||||
if (parts.length <= 1) {
|
||||
const strValue = parts[0] || rawValue;
|
||||
setDisplayObjid(strValue);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
|
||||
if (isObjid) {
|
||||
// objid인 경우: 인증된 API로 blob 다운로드
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/files/preview/${strValue}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
if (mounted) {
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setImgSrc(url);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadImage();
|
||||
loadImageBlob(strValue, mounted, setImgSrc, setError, setLoading);
|
||||
} else {
|
||||
// 경로인 경우: 직접 URL 사용
|
||||
setImgSrc(getFullImageUrl(strValue));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
// blob URL 해제
|
||||
if (imgSrc && imgSrc.startsWith("blob:")) {
|
||||
window.URL.revokeObjectURL(imgSrc);
|
||||
return () => { mounted = false; };
|
||||
}
|
||||
};
|
||||
|
||||
// 다중 objid: 대표 이미지를 찾아서 표시
|
||||
const objids = parts.filter(s => /^\d+$/.test(s));
|
||||
if (objids.length === 0) {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
return () => { mounted = false; };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { getFileInfoByObjid } = await import("@/lib/api/file");
|
||||
let representativeId: string | null = null;
|
||||
|
||||
// 각 objid의 대표 여부를 확인
|
||||
for (const objid of objids) {
|
||||
const info = await getFileInfoByObjid(objid);
|
||||
if (info.success && info.data?.isRepresentative) {
|
||||
representativeId = objid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 대표 이미지가 없으면 첫 번째 사용
|
||||
const targetObjid = representativeId || objids[0];
|
||||
if (mounted) {
|
||||
setDisplayObjid(targetObjid);
|
||||
loadImageBlob(targetObjid, mounted, setImgSrc, setError, setLoading);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
// 대표 조회 실패 시 첫 번째 사용
|
||||
setDisplayObjid(objids[0]);
|
||||
loadImageBlob(objids[0], mounted, setImgSrc, setError, setLoading);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { mounted = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
|
|
@ -91,10 +109,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
|||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rawValue = String(value);
|
||||
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
||||
const isObjid = /^\d+$/.test(displayObjid);
|
||||
const openUrl = isObjid ? getFilePreviewUrl(displayObjid) : getFullImageUrl(displayObjid);
|
||||
window.open(openUrl, "_blank");
|
||||
}}
|
||||
onError={() => setError(true)}
|
||||
|
|
@ -104,6 +120,32 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
|||
});
|
||||
TableCellImage.displayName = "TableCellImage";
|
||||
|
||||
// 이미지 blob 로딩 헬퍼
|
||||
function loadImageBlob(
|
||||
objid: string,
|
||||
mounted: boolean,
|
||||
setImgSrc: (url: string) => void,
|
||||
setError: (err: boolean) => void,
|
||||
setLoading: (loading: boolean) => void,
|
||||
) {
|
||||
import("@/lib/api/client").then(({ apiClient }) => {
|
||||
apiClient.get(`/files/preview/${objid}`, { responseType: "blob" })
|
||||
.then((response) => {
|
||||
if (mounted) {
|
||||
const blob = new Blob([response.data]);
|
||||
setImgSrc(window.URL.createObjectURL(blob));
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -2172,7 +2214,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
|
||||
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('input[type="checkbox"]')) {
|
||||
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2198,35 +2240,38 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
|
||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
|
||||
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지)
|
||||
if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedCell({ rowIndex, colIndex });
|
||||
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
|
||||
tableContainerRef.current?.focus();
|
||||
|
||||
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
|
||||
// filteredData에서 해당 행의 데이터 가져오기
|
||||
const row = filteredData[rowIndex];
|
||||
if (!row) return;
|
||||
|
||||
// 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵
|
||||
const column = visibleColumns[colIndex];
|
||||
if (column?.columnName === "__checkbox__") return;
|
||||
|
||||
const rowKey = getRowKey(row, rowIndex);
|
||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||
|
||||
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
|
||||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||||
|
||||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
|
||||
// 분할 패널 좌측: 단일 행 선택 모드
|
||||
if (!isCurrentlySelected) {
|
||||
// 기존 선택 해제하고 새 행 선택
|
||||
setSelectedRows(new Set([rowKey]));
|
||||
setIsAllSelected(false);
|
||||
|
||||
// 분할 패널 컨텍스트에 데이터 저장
|
||||
splitPanelContext.setSelectedLeftData(row);
|
||||
|
||||
// onSelectedRowsChange 콜백 호출
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
|
||||
}
|
||||
|
|
@ -2234,6 +2279,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 일반 모드: 행 선택/해제 토글
|
||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||
|
||||
if (splitPanelContext && effectiveSplitPosition === "left") {
|
||||
if (!isCurrentlySelected) {
|
||||
splitPanelContext.setSelectedLeftData(row);
|
||||
} else {
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -5412,23 +5468,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택 정보 */}
|
||||
{selectedRows.size > 0 && (
|
||||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||||
<span className="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs">
|
||||
{selectedRows.size}개 선택됨
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedRows(new Set())}
|
||||
className="h-6 w-6 p-0"
|
||||
title="선택 해제"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 선택 정보 - 숨김 처리 */}
|
||||
|
||||
{/* 🆕 통합 검색 패널 */}
|
||||
{(tableConfig.toolbar?.showSearch ?? false) && (
|
||||
|
|
@ -5777,12 +5817,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
|
||||
{/* 🆕 편집 불가 컬럼 표시 */}
|
||||
{column.editable === false && (
|
||||
<span title="편집 불가">
|
||||
<Lock className="text-muted-foreground h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable !== false && sortColumn === column.columnName && (
|
||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||
|
|
@ -6315,6 +6349,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 날짜 타입: 캘린더 피커
|
||||
const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime";
|
||||
if (isDateType) {
|
||||
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
|
||||
return (
|
||||
<InlineCellDatePicker
|
||||
value={editingValue}
|
||||
onChange={setEditingValue}
|
||||
onSave={saveEditing}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
inputRef={editInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 입력 필드
|
||||
return (
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -22,11 +22,76 @@ import {
|
|||
Database,
|
||||
Table2,
|
||||
Link2,
|
||||
GripVertical,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
/**
|
||||
* 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴)
|
||||
*/
|
||||
function SortableColumnRow({
|
||||
id,
|
||||
col,
|
||||
index,
|
||||
isEntityJoin,
|
||||
onLabelChange,
|
||||
onWidthChange,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
col: ColumnConfig;
|
||||
index: number;
|
||||
isEntityJoin?: boolean;
|
||||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
)}
|
||||
<Input
|
||||
value={col.displayName || col.columnName}
|
||||
onChange={(e) => onLabelChange(e.target.value)}
|
||||
placeholder="표시명"
|
||||
className="h-6 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={col.width || ""}
|
||||
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||
placeholder="너비"
|
||||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TableListConfigPanelProps {
|
||||
config: TableListConfig;
|
||||
|
|
@ -366,11 +431,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
|
||||
if (existingColumn) return;
|
||||
|
||||
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
|
||||
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
|
||||
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
|
||||
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
|
||||
|
||||
// 라벨명 우선 사용, 없으면 컬럼명 사용
|
||||
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
|
||||
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
|
||||
|
||||
const newColumn: ColumnConfig = {
|
||||
columnName,
|
||||
|
|
@ -1333,7 +1398,38 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
/>
|
||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate text-xs">{column.label || column.columnName}</span>
|
||||
<span className="ml-auto text-[10px] text-gray-400">
|
||||
{isAdded && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
|
||||
? "편집 잠금 (클릭하여 해제)"
|
||||
: "편집 가능 (클릭하여 잠금)"
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
||||
config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentCol = config.columns?.find((c) => c.columnName === column.columnName);
|
||||
if (currentCol) {
|
||||
updateColumn(column.columnName, {
|
||||
editable: currentCol.editable === false ? undefined : false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.columns?.find((c) => c.columnName === column.columnName)?.editable === false ? (
|
||||
<Lock className="h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
|
||||
{column.input_type || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1427,6 +1523,63 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 컬럼 순서 변경 (DnD) */}
|
||||
{config.columns && config.columns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const columns = [...(config.columns || [])];
|
||||
const oldIndex = columns.findIndex((c) => c.columnName === active.id);
|
||||
const newIndex = columns.findIndex((c) => c.columnName === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const reordered = arrayMove(columns, oldIndex, newIndex);
|
||||
reordered.forEach((col, idx) => { col.order = idx; });
|
||||
handleChange("columns", reordered);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={(config.columns || []).map((c) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{(config.columns || []).map((column, idx) => {
|
||||
// displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기
|
||||
const resolvedLabel =
|
||||
column.displayName && column.displayName !== column.columnName
|
||||
? column.displayName
|
||||
: availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName;
|
||||
|
||||
const colWithLabel = { ...column, displayName: resolvedLabel };
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={column.columnName}
|
||||
id={column.columnName}
|
||||
col={colWithLabel}
|
||||
index={idx}
|
||||
isEntityJoin={!!column.isEntityJoin}
|
||||
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
|
||||
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
|
||||
onRemove={() => removeColumn(column.columnName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 필터링 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
|
@ -1453,3 +1606,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||
|
||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||
// select 옵션 로드 (데이터 변경 시 빈 옵션 재조회)
|
||||
useEffect(() => {
|
||||
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
|
||||
return;
|
||||
|
|
@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
return;
|
||||
}
|
||||
|
||||
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
|
||||
const loadedOptions: Record<string, Array<{ label: string; value: string }>> = {};
|
||||
let hasNewOptions = false;
|
||||
|
||||
for (const filter of selectFilters) {
|
||||
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
|
||||
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
||||
newOptions[filter.columnName] = options;
|
||||
if (options && options.length > 0) {
|
||||
loadedOptions[filter.columnName] = options;
|
||||
hasNewOptions = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
|
||||
}
|
||||
}
|
||||
setSelectOptions(newOptions);
|
||||
|
||||
if (hasNewOptions) {
|
||||
setSelectOptions((prev) => {
|
||||
// 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합
|
||||
const merged = { ...prev };
|
||||
for (const [key, value] of Object.entries(loadedOptions)) {
|
||||
if (!merged[key] || merged[key].length === 0) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadSelectOptions();
|
||||
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
|
||||
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]);
|
||||
|
||||
// 높이 변화 감지 및 알림 (실제 화면에서만)
|
||||
useEffect(() => {
|
||||
|
|
@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: `${width}px` }} align="start">
|
||||
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
|
|
@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
||||
<span className="truncate text-xs sm:text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -558,31 +558,7 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
|
||||
if (onSave) {
|
||||
try {
|
||||
await onSave();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
|
||||
|
||||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
|
||||
|
||||
// 🔧 디버그: beforeFormSave 이벤트 전 formData 확인
|
||||
console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", {
|
||||
keys: Object.keys(context.formData || {}),
|
||||
hasCompanyImage: "company_image" in (context.formData || {}),
|
||||
companyImageValue: context.formData?.company_image,
|
||||
});
|
||||
|
||||
// beforeFormSave 이벤트 발송 (BomItemEditor 등 서브 컴포넌트의 저장 처리)
|
||||
const beforeSaveEventDetail = {
|
||||
formData: context.formData,
|
||||
skipDefaultSave: false,
|
||||
|
|
@ -596,22 +572,28 @@ export class ButtonActionExecutor {
|
|||
}),
|
||||
);
|
||||
|
||||
// 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기
|
||||
if (beforeSaveEventDetail.pendingPromises.length > 0) {
|
||||
console.log(
|
||||
`[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`,
|
||||
);
|
||||
await Promise.all(beforeSaveEventDetail.pendingPromises);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// 검증 실패 시 저장 중단
|
||||
if (beforeSaveEventDetail.validationFailed) {
|
||||
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
if (onSave) {
|
||||
try {
|
||||
await onSave();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
|
||||
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
|
||||
|
|
@ -1893,7 +1875,11 @@ export class ButtonActionExecutor {
|
|||
mainFormDataKeys: Object.keys(mainFormData),
|
||||
});
|
||||
|
||||
// V2Repeater 저장 완료를 기다리기 위한 Promise
|
||||
// V2Repeater가 등록된 경우에만 저장 완료를 기다림
|
||||
// @ts-ignore
|
||||
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
|
||||
if (hasActiveRepeaters) {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
|
|
@ -1916,6 +1902,7 @@ export class ButtonActionExecutor {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
}
|
||||
|
||||
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
||||
context.onRefresh?.();
|
||||
|
|
@ -1951,6 +1938,10 @@ export class ButtonActionExecutor {
|
|||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
|
||||
if (hasActiveRepeaters) {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
|
|
@ -1973,7 +1964,7 @@ export class ButtonActionExecutor {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3182,16 +3173,16 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1. 화면 설명 가져오기
|
||||
let description = config.modalDescription || "";
|
||||
if (!description) {
|
||||
// 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴)
|
||||
let screenInfo: any = null;
|
||||
if (!config.modalTitle || !config.modalDescription) {
|
||||
try {
|
||||
const screenInfo = await screenApi.getScreen(config.targetScreenId);
|
||||
description = screenInfo?.description || "";
|
||||
screenInfo = await screenApi.getScreen(config.targetScreenId);
|
||||
} catch (error) {
|
||||
console.warn("화면 설명을 가져오지 못했습니다:", error);
|
||||
console.warn("화면 정보를 가져오지 못했습니다:", error);
|
||||
}
|
||||
}
|
||||
let description = config.modalDescription || screenInfo?.description || "";
|
||||
|
||||
// 2. 데이터 소스 및 선택된 데이터 수집
|
||||
let selectedData: any[] = [];
|
||||
|
|
@ -3297,7 +3288,7 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// 3. 동적 모달 제목 생성
|
||||
let finalTitle = config.modalTitle || "화면";
|
||||
let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록";
|
||||
|
||||
// 블록 기반 제목 처리
|
||||
if (config.modalTitleBlocks?.length) {
|
||||
|
|
|
|||
|
|
@ -262,7 +262,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -304,7 +303,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -338,7 +336,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -2669,7 +2666,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3323,7 +3319,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3391,7 +3386,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -3705,7 +3699,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6206,7 +6199,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6217,7 +6209,6 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6260,7 +6251,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6343,7 +6333,6 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -6976,7 +6965,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8127,8 +8115,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8450,7 +8437,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9210,7 +9196,6 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9299,7 +9284,6 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9401,7 +9385,6 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -10573,7 +10556,6 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11354,8 +11336,7 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -12684,7 +12665,6 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -12978,7 +12958,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -13008,7 +12987,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -13057,7 +13035,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13184,7 +13161,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13254,7 +13230,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13305,7 +13280,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13338,8 +13312,7 @@
|
|||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -13647,7 +13620,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -13670,8 +13642,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -14701,8 +14672,7 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -14790,7 +14760,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -15139,7 +15108,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -139,6 +139,23 @@ export interface SelectOption {
|
|||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2Select 필터 조건
|
||||
* 옵션 데이터를 조회할 때 적용할 WHERE 조건
|
||||
*/
|
||||
export interface V2SelectFilter {
|
||||
column: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
|
||||
/** 값 유형: static=고정값, field=다른 폼 필드 참조, user=로그인 사용자 정보 */
|
||||
valueType?: "static" | "field" | "user";
|
||||
/** static일 때 고정값 */
|
||||
value?: unknown;
|
||||
/** field일 때 참조할 폼 필드명 (columnName) */
|
||||
fieldRef?: string;
|
||||
/** user일 때 참조할 사용자 필드 */
|
||||
userField?: "companyCode" | "userId" | "deptCode" | "userName";
|
||||
}
|
||||
|
||||
export interface V2SelectConfig {
|
||||
mode: V2SelectMode;
|
||||
source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드)
|
||||
|
|
@ -151,7 +168,8 @@ export interface V2SelectConfig {
|
|||
table?: string;
|
||||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
filters?: Array<{ column: string; operator: string; value: unknown }>;
|
||||
// 옵션 필터 조건 (모든 source에서 사용 가능)
|
||||
filters?: V2SelectFilter[];
|
||||
// 엔티티 연결 (source: entity)
|
||||
entityTable?: string;
|
||||
entityValueField?: string;
|
||||
|
|
|
|||
|
|
@ -153,10 +153,12 @@ export interface CommonStyle {
|
|||
// 라벨 스타일
|
||||
labelDisplay?: boolean; // 라벨 표시 여부
|
||||
labelText?: string; // 라벨 텍스트
|
||||
labelPosition?: "top" | "left" | "right" | "bottom"; // 라벨 위치 (기본: top)
|
||||
labelFontSize?: string;
|
||||
labelColor?: string;
|
||||
labelFontWeight?: string;
|
||||
labelMarginBottom?: string;
|
||||
labelGap?: string; // 라벨-위젯 간격 (좌/우 배치 시 사용)
|
||||
|
||||
// 레이아웃
|
||||
display?: string;
|
||||
|
|
|
|||
|
|
@ -50,11 +50,13 @@ export interface RepeaterColumnConfig {
|
|||
width: ColumnWidthOption;
|
||||
visible: boolean;
|
||||
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
||||
hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨)
|
||||
hidden?: boolean; // 히든 처리 (화면에 안 보이지만 저장됨)
|
||||
isJoinColumn?: boolean;
|
||||
sourceTable?: string;
|
||||
// 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
|
||||
// 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
|
||||
isSourceDisplay?: boolean;
|
||||
// 소스 데이터의 다른 컬럼명에서 값을 매핑 (예: qty ← order_qty)
|
||||
sourceKey?: string;
|
||||
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
|
||||
inputType?: string; // text, number, date, code, entity 등
|
||||
// 🆕 자동 입력 설정
|
||||
|
|
@ -140,6 +142,20 @@ export interface CalculationRule {
|
|||
label?: string;
|
||||
}
|
||||
|
||||
// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회)
|
||||
export interface SourceDetailConfig {
|
||||
tableName: string; // 디테일 테이블명 (예: "sales_order_detail")
|
||||
foreignKey: string; // 디테일 테이블의 FK 컬럼 (예: "order_no")
|
||||
parentKey: string; // 전달받은 마스터 데이터에서 추출할 키 (예: "order_no")
|
||||
useEntityJoin?: boolean; // 엔티티 조인 사용 여부 (data-with-joins API)
|
||||
columnMapping?: Record<string, string>; // 리피터 컬럼 ← 조인 alias 매핑 (예: { "part_name": "part_code_item_name" })
|
||||
additionalJoinColumns?: Array<{
|
||||
sourceColumn: string;
|
||||
sourceTable: string;
|
||||
joinAlias: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 메인 설정 타입
|
||||
export interface V2RepeaterConfig {
|
||||
// 렌더링 모드
|
||||
|
|
@ -151,6 +167,9 @@ export interface V2RepeaterConfig {
|
|||
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
|
||||
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
|
||||
|
||||
// 소스 디테일 자동 조회 설정 (선택된 마스터의 디테일 행을 리피터로 로드)
|
||||
sourceDetailConfig?: SourceDetailConfig;
|
||||
|
||||
// 데이터 소스 설정
|
||||
dataSource: RepeaterDataSource;
|
||||
|
||||
|
|
@ -189,6 +208,7 @@ export interface V2RepeaterProps {
|
|||
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||
className?: string;
|
||||
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
|
||||
groupedData?: Record<string, any>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
|
||||
}
|
||||
|
||||
// 기본 설정값
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* 프로덕션에서 "관리자 메뉴로 전환" 버튼 가시성 테스트
|
||||
* 두 계정 (topseal_admin, rsw1206)으로 로그인하여 버튼 표시 여부 확인
|
||||
*
|
||||
* 실행: node scripts/browser-test-admin-switch-button.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-admin-switch-button.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("fs");
|
||||
|
||||
const BASE_URL = "https://v1.vexplor.com";
|
||||
const SCREENSHOT_DIR = "test-screenshots/admin-switch-test";
|
||||
|
||||
const ACCOUNTS = [
|
||||
{ userId: "topseal_admin", password: "qlalfqjsgh11", name: "topseal_admin" },
|
||||
{ userId: "rsw1206", password: "qlalfqjsgh11", name: "rsw1206" },
|
||||
];
|
||||
|
||||
async function runTest() {
|
||||
const results = { topseal_admin: {}, rsw1206: {} };
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADLESS !== "0",
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
return path;
|
||||
};
|
||||
|
||||
for (let i = 0; i < ACCOUNTS.length; i++) {
|
||||
const acc = ACCOUNTS[i];
|
||||
console.log(`\n========== ${acc.name} 테스트 (${i + 1}/${ACCOUNTS.length}) ==========\n`);
|
||||
|
||||
// 로그인 페이지로 이동
|
||||
await page.goto(`${BASE_URL}/login`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 20000,
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 로그인
|
||||
await page.fill("#userId", acc.userId);
|
||||
await page.fill("#password", acc.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 로그인 성공 시 대시보드 또는 메인으로 리다이렉트될 것임
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes("/login") && !currentUrl.includes("error")) {
|
||||
// 아직 로그인 페이지에 있다면 조금 더 대기
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
const afterLoginUrl = page.url();
|
||||
const screenshotPath = await screenshot(`01_${acc.name}_after_login`);
|
||||
|
||||
// "관리자 메뉴로 전환" 버튼 찾기
|
||||
const buttonSelectors = [
|
||||
'button:has-text("관리자 메뉴로 전환")',
|
||||
'[class*="button"]:has-text("관리자 메뉴로 전환")',
|
||||
'button >> text=관리자 메뉴로 전환',
|
||||
];
|
||||
|
||||
let buttonVisible = false;
|
||||
for (const sel of buttonSelectors) {
|
||||
try {
|
||||
const btn = page.locator(sel).first();
|
||||
const count = await btn.count();
|
||||
if (count > 0) {
|
||||
const isVisible = await btn.isVisible();
|
||||
if (isVisible) {
|
||||
buttonVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 추가: 페이지 내 텍스트로 버튼 존재 여부 확인
|
||||
if (!buttonVisible) {
|
||||
const pageText = await page.textContent("body");
|
||||
buttonVisible = pageText && pageText.includes("관리자 메뉴로 전환");
|
||||
}
|
||||
|
||||
results[acc.name] = {
|
||||
buttonVisible,
|
||||
screenshotPath,
|
||||
afterLoginUrl,
|
||||
};
|
||||
|
||||
console.log(` 버튼 가시성: ${buttonVisible ? "표시됨" : "표시 안 됨"}`);
|
||||
console.log(` URL: ${afterLoginUrl}`);
|
||||
|
||||
// 로그아웃 (다음 계정 테스트 전)
|
||||
if (i < ACCOUNTS.length - 1) {
|
||||
console.log("\n 로그아웃 중...");
|
||||
try {
|
||||
// 프로필 드롭다운 클릭 (좌측 하단)
|
||||
const profileBtn = page.locator(
|
||||
'button:has-text("로그아웃"), [class*="dropdown"]:has-text("로그아웃"), [data-radix-collection-item]:has-text("로그아웃")'
|
||||
);
|
||||
const profileTrigger = page.locator(
|
||||
'button[class*="flex w-full"][class*="gap-3"]'
|
||||
).first();
|
||||
if (await profileTrigger.count() > 0) {
|
||||
await profileTrigger.click();
|
||||
await page.waitForTimeout(500);
|
||||
const logoutItem = page.locator('text=로그아웃').first();
|
||||
if (await logoutItem.count() > 0) {
|
||||
await logoutItem.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
// 또는 직접 로그아웃 URL
|
||||
if (page.url().includes("/login") === false) {
|
||||
await page.goto(`${BASE_URL}/api/auth/logout`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 5000,
|
||||
}).catch(() => {});
|
||||
await page.goto(`${BASE_URL}/login`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(" 로그아웃 대체: 로그인 페이지로 직접 이동");
|
||||
await page.goto(`${BASE_URL}/login`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n========== 최종 결과 ==========\n");
|
||||
console.log("topseal_admin: 관리자 메뉴로 전환 버튼 =", results.topseal_admin.buttonVisible ? "표시됨" : "표시 안 됨");
|
||||
console.log("rsw1206: 관리자 메뉴로 전환 버튼 =", results.rsw1206.buttonVisible ? "표시됨" : "표시 안 됨");
|
||||
console.log("\n스크린샷:", SCREENSHOT_DIR);
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error("테스트 오류:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
runTest()
|
||||
.then((r) => {
|
||||
console.log("\n테스트 완료.");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* 거래처관리 화면 CRUD 브라우저 테스트
|
||||
* 실행: node scripts/browser-test-customer-crud.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-customer-crud.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = "test-screenshots";
|
||||
|
||||
async function runTest() {
|
||||
const results = { success: [], failed: [], screenshots: [] };
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// 스크린샷 디렉토리
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.screenshots.push(path);
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
};
|
||||
|
||||
console.log("\n=== 1단계: 로그인 ===\n");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await page.fill('#userId', 'topseal_admin');
|
||||
await page.fill('#password', 'qlalfqjsgh11');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("01_after_login");
|
||||
results.success.push("로그인 완료");
|
||||
|
||||
console.log("\n=== 2단계: 거래처관리 화면 이동 ===\n");
|
||||
await page.goto(`${BASE_URL}/screens/227`, { waitUntil: "domcontentloaded", timeout: 20000 });
|
||||
// 테이블 또는 메인 콘텐츠 로딩 대기 (API 호출 후 React 렌더링)
|
||||
try {
|
||||
await page.waitForSelector('table, tbody, [role="row"], .rt-tbody', { timeout: 25000 });
|
||||
results.success.push("테이블 로드 감지");
|
||||
} catch (e) {
|
||||
console.log(" [경고] 테이블 대기 타임아웃, 계속 진행");
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("02_screen_227");
|
||||
results.success.push("화면 227 로드");
|
||||
|
||||
console.log("\n=== 3단계: 거래처 선택 (READ 테스트) ===\n");
|
||||
// 좌측 테이블 행 선택 - 다양한 레이아웃 대응
|
||||
const rowSelectors = [
|
||||
'table tbody tr.cursor-pointer',
|
||||
'tbody tr.hover\\:bg-accent',
|
||||
'table tbody tr:has(td)',
|
||||
'tbody tr',
|
||||
];
|
||||
let rows = [];
|
||||
for (const sel of rowSelectors) {
|
||||
rows = await page.$$(sel);
|
||||
if (rows.length > 0) break;
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
await rows[0].click();
|
||||
results.success.push("거래처 행 클릭");
|
||||
} else {
|
||||
results.failed.push("거래처 테이블 행을 찾을 수 없음");
|
||||
// 디버그: 페이지 구조 저장
|
||||
const bodyHtml = await page.evaluate(() => {
|
||||
const tables = document.querySelectorAll('table, tbody, [role="grid"], [role="table"]');
|
||||
return `Tables found: ${tables.length}\n` + document.body.innerHTML.slice(0, 8000);
|
||||
});
|
||||
require("fs").writeFileSync(`${SCREENSHOT_DIR}/debug_body.html`, bodyHtml);
|
||||
console.log(" [디버그] body HTML 일부 저장: debug_body.html");
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("03_after_customer_select");
|
||||
|
||||
// SelectedItemsDetailInput 영역 확인
|
||||
const detailArea = await page.$('[data-component="selected-items-detail-input"], [class*="selected-items"], .selected-items-detail');
|
||||
if (detailArea) {
|
||||
results.success.push("SelectedItemsDetailInput 컴포넌트 렌더링 확인");
|
||||
} else {
|
||||
// 품목/입력 관련 영역이 있는지
|
||||
const hasInputArea = await page.$('input[placeholder*="품번"], input[placeholder*="품목"], [class*="detail"]');
|
||||
results.success.push(hasInputArea ? "입력 영역 확인됨" : "SelectedItemsDetailInput 영역 미확인");
|
||||
}
|
||||
|
||||
console.log("\n=== 4단계: 품목 추가 (CREATE 테스트) ===\n");
|
||||
const addBtnLoc = page.locator('button').filter({ hasText: /추가|품목/ }).first();
|
||||
const addBtnExists = await addBtnLoc.count() > 0;
|
||||
if (addBtnExists) {
|
||||
await addBtnLoc.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await screenshot("04_after_add_click");
|
||||
|
||||
// 모달/팝업에서 품목 선택
|
||||
const modalItem = await page.$('[role="dialog"] tr, [role="listbox"] [role="option"], .modal tbody tr');
|
||||
if (modalItem) {
|
||||
await modalItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// 필수 필드 입력
|
||||
const itemCodeInput = await page.$('input[name*="품번"], input[placeholder*="품번"], input[id*="item"]');
|
||||
if (itemCodeInput) {
|
||||
await itemCodeInput.fill("TEST_BROWSER");
|
||||
}
|
||||
await screenshot("04_before_save");
|
||||
|
||||
const saveBtnLoc = page.locator('button').filter({ hasText: /저장/ }).first();
|
||||
if (await saveBtnLoc.count() > 0) {
|
||||
await saveBtnLoc.click();
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("05_after_save");
|
||||
results.success.push("저장 버튼 클릭");
|
||||
|
||||
const toast = await page.$('[data-sonner-toast], .toast, [role="alert"]');
|
||||
if (toast) {
|
||||
const toastText = await toast.textContent();
|
||||
results.success.push(`토스트 메시지: ${toastText?.slice(0, 50)}`);
|
||||
}
|
||||
} else {
|
||||
results.failed.push("저장 버튼을 찾을 수 없음");
|
||||
}
|
||||
} else {
|
||||
results.failed.push("품목 추가/추가 버튼을 찾을 수 없음");
|
||||
await screenshot("04_no_add_button");
|
||||
}
|
||||
|
||||
console.log("\n=== 5단계: 최종 결과 ===\n");
|
||||
await screenshot("06_final_state");
|
||||
|
||||
// 콘솔 에러 수집
|
||||
const consoleErrors = [];
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
if (type === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
results.failed.push(`예외: ${err.message}`);
|
||||
try {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
|
||||
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
|
||||
} catch (_) {}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
console.log("\n========== 테스트 결과 ==========\n");
|
||||
console.log("성공:", results.success);
|
||||
console.log("실패:", results.failed);
|
||||
console.log("스크린샷:", results.screenshots);
|
||||
return results;
|
||||
}
|
||||
|
||||
runTest().then((r) => {
|
||||
process.exit(r.failed.length > 0 ? 1 : 0);
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* 거래처관리 메뉴 경유 브라우저 테스트
|
||||
* 영업관리 > 거래처관리 메뉴 클릭 후 상세 화면 진입
|
||||
* 실행: node scripts/browser-test-customer-via-menu.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-customer-via-menu.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = "test-screenshots";
|
||||
const CREDENTIALS = { userId: "topseal_admin", password: "qlalfqjsgh11" };
|
||||
|
||||
async function runTest() {
|
||||
const results = { success: [], failed: [], screenshots: [] };
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.screenshots.push(path);
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
};
|
||||
|
||||
try {
|
||||
// 로그인 (이미 로그인된 상태면 자동 리다이렉트됨)
|
||||
console.log("\n=== 로그인 확인 ===\n");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes("/login") && !(await page.$('input#userId'))) {
|
||||
// 로그인 폼이 있으면 로그인
|
||||
await page.fill("#userId", CREDENTIALS.userId);
|
||||
await page.fill("#password", CREDENTIALS.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
} else if (currentUrl.includes("/login")) {
|
||||
await page.fill("#userId", CREDENTIALS.userId);
|
||||
await page.fill("#password", CREDENTIALS.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
results.success.push("로그인/세션 확인");
|
||||
|
||||
// 단계 1: 영업관리 메뉴 클릭
|
||||
console.log("\n=== 단계 1: 영업관리 메뉴 클릭 ===\n");
|
||||
const salesMenu = page.locator('nav, aside').getByText('영업관리', { exact: true }).first();
|
||||
if (await salesMenu.count() > 0) {
|
||||
await salesMenu.click();
|
||||
await page.waitForTimeout(2000);
|
||||
results.success.push("영업관리 메뉴 클릭");
|
||||
} else {
|
||||
const salesAlt = page.getByRole('button', { name: /영업관리/ }).or(page.getByText('영업관리').first());
|
||||
if (await salesAlt.count() > 0) {
|
||||
await salesAlt.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
results.success.push("영업관리 메뉴 클릭 (대안)");
|
||||
} else {
|
||||
results.failed.push("영업관리 메뉴를 찾을 수 없음");
|
||||
}
|
||||
}
|
||||
await screenshot("01_after_sales_menu");
|
||||
|
||||
// 단계 2: 거래처관리 서브메뉴 클릭
|
||||
console.log("\n=== 단계 2: 거래처관리 서브메뉴 클릭 ===\n");
|
||||
const customerMenu = page.getByText("거래처관리", { exact: true }).first();
|
||||
if (await customerMenu.count() > 0) {
|
||||
await customerMenu.click();
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push("거래처관리 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("거래처관리 메뉴를 찾을 수 없음");
|
||||
}
|
||||
await screenshot("02_after_customer_menu");
|
||||
|
||||
// 단계 3: 거래처 목록 확인 및 행 클릭
|
||||
console.log("\n=== 단계 3: 거래처 목록 확인 ===\n");
|
||||
const rows = await page.$$('tbody tr, table tr, [role="row"]');
|
||||
const clickableRows = rows.length > 0 ? rows : [];
|
||||
if (clickableRows.length > 0) {
|
||||
await clickableRows[0].click();
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push(`거래처 행 클릭 (${clickableRows.length}개 행 중 첫 번째)`);
|
||||
} else {
|
||||
results.failed.push("거래처 테이블 행을 찾을 수 없음");
|
||||
}
|
||||
await screenshot("03_after_row_click");
|
||||
|
||||
// 단계 4: 편집/수정 버튼 또는 더블클릭 (분할 패널이면 행 선택만으로 우측에 상세 표시될 수 있음)
|
||||
console.log("\n=== 단계 4: 상세 화면 진입 시도 ===\n");
|
||||
const editBtn = page.locator('button').filter({ hasText: /편집|수정|상세/ }).first();
|
||||
let editEnabled = false;
|
||||
try {
|
||||
if (await editBtn.count() > 0) {
|
||||
editEnabled = !(await editBtn.isDisabled());
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (editEnabled) {
|
||||
await editBtn.click();
|
||||
results.success.push("편집/수정 버튼 클릭");
|
||||
} else {
|
||||
const row = await page.$('tbody tr, table tr');
|
||||
if (row) {
|
||||
await row.dblclick();
|
||||
results.success.push("행 더블클릭 시도");
|
||||
} else if (await editBtn.count() > 0) {
|
||||
results.success.push("수정 버튼 비활성화 - 분할 패널 우측 상세 확인");
|
||||
} else {
|
||||
results.failed.push("편집 버튼/행을 찾을 수 없음");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
results.success.push("상세 진입 스킵 - 우측 패널에 상세 표시 여부 확인");
|
||||
}
|
||||
await page.waitForTimeout(5000);
|
||||
await screenshot("04_after_detail_enter");
|
||||
|
||||
// 단계 5: 품목 관련 영역 확인
|
||||
console.log("\n=== 단계 5: 품목 관련 영역 확인 ===\n");
|
||||
const hasItemSection = await page.getByText(/품목|납품품목|거래처 품번|거래처 품명/).first().count() > 0;
|
||||
const hasDetailInput = await page.$('input[placeholder*="품번"], input[name*="품번"], [class*="selected-items"]');
|
||||
if (hasItemSection || hasDetailInput) {
|
||||
results.success.push("품목 관련 UI 확인됨");
|
||||
} else {
|
||||
results.failed.push("품목 관련 영역 미확인");
|
||||
}
|
||||
await screenshot("05_item_section");
|
||||
|
||||
console.log("\n========== 테스트 결과 ==========\n");
|
||||
console.log("성공:", results.success);
|
||||
console.log("실패:", results.failed);
|
||||
console.log("스크린샷:", results.screenshots);
|
||||
|
||||
} catch (err) {
|
||||
results.failed.push(`예외: ${err.message}`);
|
||||
try {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
|
||||
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
|
||||
} catch (_) {}
|
||||
console.error(err);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
runTest()
|
||||
.then((r) => process.exit(r.failed.length > 0 ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* 구매관리 - 공급업체관리 / 구매품목정보 CRUD 브라우저 테스트
|
||||
* 실행: node scripts/browser-test-purchase-supplier.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-purchase-supplier.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = "test-screenshots";
|
||||
const CREDENTIALS = { userId: "topseal_admin", password: "qlalfqjsgh11" };
|
||||
|
||||
async function runTest() {
|
||||
const results = { success: [], failed: [], screenshots: [] };
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.screenshots.push(path);
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
return path;
|
||||
};
|
||||
|
||||
const clickMenu = async (text) => {
|
||||
const loc = page.getByText(text, { exact: true }).first();
|
||||
if ((await loc.count()) > 0) {
|
||||
await loc.click();
|
||||
return true;
|
||||
}
|
||||
const alt = page.getByRole("link", { name: text }).or(page.locator(`a:has-text("${text}")`)).first();
|
||||
if ((await alt.count()) > 0) {
|
||||
await alt.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clickRow = async () => {
|
||||
const rows = await page.$$('tbody tr, table tr, [role="row"]');
|
||||
for (const r of rows) {
|
||||
const t = await r.textContent();
|
||||
if (t && !t.includes("데이터가 없습니다") && !t.includes("로딩")) {
|
||||
await r.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
await rows[0].click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clickButton = async (regex) => {
|
||||
const btn = page.locator("button").filter({ hasText: regex }).first();
|
||||
try {
|
||||
if ((await btn.count()) > 0 && !(await btn.isDisabled())) {
|
||||
await btn.click();
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
console.log("\n=== 로그인 확인 ===\n");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
if (page.url().includes("/login")) {
|
||||
await page.fill("#userId", CREDENTIALS.userId);
|
||||
await page.fill("#password", CREDENTIALS.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
results.success.push("세션 확인");
|
||||
|
||||
// ========== 테스트 1: 공급업체관리 ==========
|
||||
console.log("\n=== 테스트 1: 공급업체관리 ===\n");
|
||||
|
||||
console.log("단계 1: 구매관리 메뉴 열기");
|
||||
if (await clickMenu("구매관리")) {
|
||||
await page.waitForTimeout(3000);
|
||||
results.success.push("구매관리 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("구매관리 메뉴 미발견");
|
||||
}
|
||||
await screenshot("p1_01_purchase_menu");
|
||||
|
||||
console.log("단계 2: 공급업체관리 서브메뉴 클릭");
|
||||
if (await clickMenu("공급업체관리")) {
|
||||
await page.waitForTimeout(8000);
|
||||
results.success.push("공급업체관리 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("공급업체관리 메뉴 미발견");
|
||||
}
|
||||
await screenshot("p1_02_supplier_screen");
|
||||
|
||||
console.log("단계 3: 공급업체 선택");
|
||||
if (await clickRow()) {
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push("공급업체 행 클릭");
|
||||
} else {
|
||||
results.failed.push("공급업체 테이블 행 미발견");
|
||||
}
|
||||
await screenshot("p1_03_after_supplier_select");
|
||||
|
||||
console.log("단계 4: 납품품목 탭/영역 확인");
|
||||
const itemTab = page.getByText(/납품품목|품목/).first();
|
||||
if ((await itemTab.count()) > 0) {
|
||||
await itemTab.click();
|
||||
await page.waitForTimeout(3000);
|
||||
results.success.push("납품품목/품목 탭 클릭");
|
||||
} else {
|
||||
results.failed.push("납품품목 탭 미발견");
|
||||
}
|
||||
await screenshot("p1_04_item_tab");
|
||||
|
||||
console.log("단계 5: 품목 추가 시도");
|
||||
const addBtn = page.locator("button").filter({ hasText: /추가|\+ 추가/ }).first();
|
||||
let addBtnEnabled = false;
|
||||
try {
|
||||
addBtnEnabled = (await addBtn.count()) > 0 && !(await addBtn.isDisabled());
|
||||
} catch (_) {}
|
||||
if (addBtnEnabled) {
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const modal = await page.$('[role="dialog"], .modal, [class*="modal"]');
|
||||
if (modal) {
|
||||
const modalRow = await page.$('[role="dialog"] tbody tr, .modal tbody tr');
|
||||
if (modalRow) {
|
||||
await modalRow.click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
results.success.push("추가 버튼 클릭 및 품목 선택 시도");
|
||||
} else {
|
||||
results.failed.push("추가 버튼 미발견 또는 비활성화");
|
||||
}
|
||||
await screenshot("p1_05_add_item");
|
||||
|
||||
// ========== 테스트 2: 구매품목정보 ==========
|
||||
console.log("\n=== 테스트 2: 구매품목정보 ===\n");
|
||||
|
||||
console.log("단계 6: 구매품목정보 메뉴 클릭");
|
||||
if (await clickMenu("구매품목정보")) {
|
||||
await page.waitForTimeout(8000);
|
||||
results.success.push("구매품목정보 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("구매품목정보 메뉴 미발견");
|
||||
}
|
||||
await screenshot("p2_01_item_screen");
|
||||
|
||||
console.log("단계 7: 품목 선택 및 공급업체 확인");
|
||||
if (await clickRow()) {
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push("구매품목 행 클릭");
|
||||
} else {
|
||||
results.failed.push("구매품목 테이블 행 미발견");
|
||||
}
|
||||
await screenshot("p2_02_after_item_select");
|
||||
|
||||
// SelectedItemsDetailInput 컴포넌트 확인
|
||||
const hasDetailInput = await page.$('input[placeholder*="품번"], [class*="selected-items"], input[name*="품번"]');
|
||||
results.success.push(hasDetailInput ? "SelectedItemsDetailInput 렌더링 확인" : "SelectedItemsDetailInput 미확인");
|
||||
await screenshot("p2_03_final");
|
||||
|
||||
console.log("\n========== 테스트 결과 ==========\n");
|
||||
console.log("성공:", results.success);
|
||||
console.log("실패:", results.failed);
|
||||
console.log("스크린샷:", results.screenshots);
|
||||
|
||||
} catch (err) {
|
||||
results.failed.push(`예외: ${err.message}`);
|
||||
try {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
|
||||
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
|
||||
} catch (_) {}
|
||||
console.error(err);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
runTest()
|
||||
.then((r) => process.exit(r.failed.length > 0 ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "============================================"
|
||||
echo "WACE 솔루션 - npm 직접 실행 (Docker 없이)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/scripts/dev/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
||||
|
||||
# 기존 프로세스 정리
|
||||
echo "[1/4] 기존 프로세스 정리 중..."
|
||||
lsof -ti:8080 | xargs kill -9 2>/dev/null
|
||||
lsof -ti:9771 | xargs kill -9 2>/dev/null
|
||||
echo " 완료"
|
||||
echo ""
|
||||
|
||||
# 백엔드 npm install + 실행
|
||||
echo "[2/4] 백엔드 의존성 설치 중..."
|
||||
cd "$PROJECT_ROOT/backend-node"
|
||||
npm install --silent
|
||||
echo " 완료"
|
||||
echo ""
|
||||
|
||||
echo "[3/4] 백엔드 서버 시작 중 (포트 8080)..."
|
||||
npm run dev > "$BACKEND_LOG" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
echo " PID: $BACKEND_PID"
|
||||
echo ""
|
||||
|
||||
# 프론트엔드 npm install + 실행
|
||||
echo "[4/4] 프론트엔드 의존성 설치 + 서버 시작 중 (포트 9771)..."
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
npm install --silent
|
||||
npm run dev > "$FRONTEND_LOG" 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
echo " PID: $FRONTEND_PID"
|
||||
echo ""
|
||||
|
||||
sleep 3
|
||||
|
||||
echo "============================================"
|
||||
echo "모든 서비스가 시작되었습니다!"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo " [BACKEND] http://localhost:8080/api"
|
||||
echo " [FRONTEND] http://localhost:9771"
|
||||
echo ""
|
||||
echo " 백엔드 PID: $BACKEND_PID"
|
||||
echo " 프론트엔드 PID: $FRONTEND_PID"
|
||||
echo ""
|
||||
echo " 프론트엔드 로그: tail -f $FRONTEND_LOG"
|
||||
echo ""
|
||||
echo "Ctrl+C 로 종료하면 백엔드/프론트엔드 모두 종료됩니다."
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "--- 백엔드 로그 출력 시작 ---"
|
||||
echo ""
|
||||
|
||||
trap "echo ''; echo '서비스를 종료합니다...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM
|
||||
|
||||
tail -f "$BACKEND_LOG"
|
||||
Loading…
Reference in New Issue