Compare commits
No commits in common. "c98b2ccb4307f974ce14298c406eeed6106664cf" and "f6a02b5182a4f0b3900dc8da9188ad995dcce1e7" have entirely different histories.
c98b2ccb43
...
f6a02b5182
|
|
@ -233,35 +233,8 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
|
|||
const { moldCode } = req.params;
|
||||
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
|
||||
|
||||
let finalSerialNumber = serial_number;
|
||||
|
||||
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
|
||||
if (!finalSerialNumber) {
|
||||
try {
|
||||
const { numberingRuleService } = await import("../services/numberingRuleService");
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
"mold_serial",
|
||||
"serial_number"
|
||||
);
|
||||
|
||||
if (rule) {
|
||||
// formData에 mold_code를 포함 (reference 파트에서 참조)
|
||||
const formData = { mold_code: moldCode, ...req.body };
|
||||
finalSerialNumber = await numberingRuleService.allocateCode(
|
||||
rule.ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
|
||||
}
|
||||
} catch (numError: any) {
|
||||
logger.error("일련번호 자동 채번 실패", { error: numError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSerialNumber) {
|
||||
res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
|
||||
if (!serial_number) {
|
||||
res.status(400).json({ success: false, message: "일련번호는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +244,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
|
|||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
companyCode, moldCode, finalSerialNumber, status || "STORED",
|
||||
companyCode, moldCode, serial_number, status || "STORED",
|
||||
progress || 0, work_description || null, manager || null,
|
||||
completion_date || null, remarks || null, userId,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -405,30 +405,6 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
// 테이블+컬럼 기반 채번 규칙 조회 (메인 API)
|
||||
router.get(
|
||||
"/by-column/:tableName/:columnName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== 테스트 테이블용 API ====================
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
|
||||
|
|
|
|||
|
|
@ -3019,72 +3019,3 @@ export async function toggleColumnUnique(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일)
|
||||
*
|
||||
* @route GET /api/table-management/numbering-columns
|
||||
*/
|
||||
export async function getNumberingColumnsByCompany(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드를 확인할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
|
||||
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
COALESCE(
|
||||
tl.table_label,
|
||||
initcap(replace(ttc.table_name, '_', ' '))
|
||||
) AS "tableLabel",
|
||||
ttc.column_name AS "columnName",
|
||||
COALESCE(
|
||||
ttc.column_label,
|
||||
initcap(replace(ttc.column_name, '_', ' '))
|
||||
) AS "columnLabel",
|
||||
ttc.input_type AS "inputType"
|
||||
FROM table_type_columns ttc
|
||||
LEFT JOIN table_labels tl
|
||||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'numbering'
|
||||
AND ttc.company_code = $1
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
|
||||
|
||||
logger.info("채번 컬럼 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: columnsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("채번 컬럼 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
||||
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
|
|
@ -255,12 +254,6 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
|||
*/
|
||||
router.get("/category-columns", getCategoryColumnsByCompany);
|
||||
|
||||
/**
|
||||
* 회사 기준 모든 채번 타입 컬럼 조회
|
||||
* GET /api/table-management/numbering-columns
|
||||
*/
|
||||
router.get("/numbering-columns", getNumberingColumnsByCompany);
|
||||
|
||||
/**
|
||||
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||
* GET /api/table-management/menu/:menuObjid/category-columns
|
||||
|
|
|
|||
|
|
@ -494,7 +494,7 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
|
|
@ -502,58 +502,32 @@ class MasterDetailExcelService {
|
|||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 1. table_type_columns에서 numbering 타입인지 확인
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const ttcParams = companyCode && companyCode !== "*"
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const ttcResult = await query<any>(
|
||||
`SELECT input_type FROM table_type_columns
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
AND input_type = 'numbering' LIMIT 1`,
|
||||
ttcParams
|
||||
);
|
||||
|
||||
if (ttcResult.length === 0) return null;
|
||||
|
||||
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
|
||||
const ruleCompanyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const ruleParams = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const ruleResult = await query<any>(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
ruleParams
|
||||
);
|
||||
|
||||
if (ruleResult.length > 0) {
|
||||
return { numberingRuleId: ruleResult[0].rule_id };
|
||||
}
|
||||
|
||||
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
|
||||
const fallbackResult = await query<any>(
|
||||
`SELECT detail_settings FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
AND input_type = 'numbering'
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
ttcParams
|
||||
params
|
||||
);
|
||||
|
||||
for (const row of fallbackResult) {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
// 채번 타입인 행 찾기 (회사별 우선)
|
||||
for (const row of result) {
|
||||
if (row.input_type === "numbering") {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -566,7 +540,7 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
||||
* numbering_rules 테이블에서 table_name + column_name으로 직접 조회
|
||||
* 회사별 설정 우선, 공통(*) 설정 fallback
|
||||
* @returns Map<columnName, numberingRuleId>
|
||||
*/
|
||||
private async detectAllNumberingColumns(
|
||||
|
|
@ -575,7 +549,6 @@ class MasterDetailExcelService {
|
|||
): Promise<Map<string, string>> {
|
||||
const numberingCols = new Map<string, string>();
|
||||
try {
|
||||
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($2, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
|
|
@ -583,26 +556,22 @@ class MasterDetailExcelService {
|
|||
? [tableName, companyCode]
|
||||
: [tableName];
|
||||
|
||||
const ttcResult = await query<any>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
);
|
||||
|
||||
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
|
||||
for (const row of ttcResult) {
|
||||
const ruleResult = await query<any>(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
companyCode && companyCode !== "*"
|
||||
? [tableName, row.column_name, companyCode]
|
||||
: [tableName, row.column_name]
|
||||
);
|
||||
|
||||
if (ruleResult.length > 0) {
|
||||
numberingCols.set(row.column_name, ruleResult[0].rule_id);
|
||||
// 컬럼별로 회사 설정 우선 적용
|
||||
for (const row of result) {
|
||||
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
numberingCols.set(row.column_name, settings.numberingRuleId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -172,16 +172,6 @@ class NumberingRuleService {
|
|||
break;
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
prefixParts.push(String(formData[refColumn]));
|
||||
} else {
|
||||
prefixParts.push("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -1255,14 +1245,6 @@ class NumberingRuleService {
|
|||
return "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
|
|
@ -1393,13 +1375,6 @@ class NumberingRuleService {
|
|||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
case "reference": {
|
||||
const refCol2 = autoConfig.referenceColumnName;
|
||||
if (refCol2 && formData && formData[refCol2]) {
|
||||
return String(formData[refCol2]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1549,15 +1524,6 @@ class NumberingRuleService {
|
|||
return "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1781,53 +1747,7 @@ class NumberingRuleService {
|
|||
`;
|
||||
const params = [companyCode, tableName, columnName];
|
||||
|
||||
let result = await pool.query(query, params);
|
||||
|
||||
// fallback: column_name이 비어있는 레거시 규칙 검색
|
||||
if (result.rows.length === 0) {
|
||||
const fallbackQuery = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
cv.value_label AS "categoryValueLabel",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND (r.column_name IS NULL OR r.column_name = '')
|
||||
AND r.category_value_id IS NULL
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
result = await pool.query(fallbackQuery, [companyCode, tableName]);
|
||||
|
||||
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
|
||||
if (result.rows.length > 0) {
|
||||
const foundRule = result.rows[0];
|
||||
await pool.query(
|
||||
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
|
||||
[columnName, foundRule.ruleId, companyCode]
|
||||
);
|
||||
result.rows[0].columnName = columnName;
|
||||
logger.info("레거시 채번 규칙 자동 매핑 완료", {
|
||||
ruleId: foundRule.ruleId,
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
|
||||
|
|
@ -1840,6 +1760,7 @@ class NumberingRuleService {
|
|||
|
||||
const rule = result.rows[0];
|
||||
|
||||
// 파트 정보 조회 (테스트 테이블)
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -1858,7 +1779,7 @@ class NumberingRuleService {
|
|||
]);
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2691,32 +2691,6 @@ export class TableManagementService {
|
|||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
|
||||
try {
|
||||
const companyCode = data.company_code || "*";
|
||||
const numberingColsResult = await query<any>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering'
|
||||
AND company_code IN ($2, '*')`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const row of numberingColsResult) {
|
||||
const col = row.column_name;
|
||||
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
|
||||
if (rule) {
|
||||
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
|
||||
data[col] = generatedCode;
|
||||
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (numErr: any) {
|
||||
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
||||
}
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
|
|
|
|||
|
|
@ -669,6 +669,38 @@ export default function TableManagementPage() {
|
|||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
||||
}
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
console.log("🔍 Numbering 저장 체크:", {
|
||||
inputType: column.inputType,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
hasNumberingRuleId: !!column.numberingRuleId,
|
||||
});
|
||||
|
||||
if (column.inputType === "numbering") {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// numberingRuleId가 있으면 저장, 없으면 제거
|
||||
if (column.numberingRuleId) {
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
|
||||
} else {
|
||||
// numberingRuleId가 없으면 빈 객체
|
||||
finalDetailSettings = JSON.stringify(existingSettings);
|
||||
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
|
||||
}
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
|
|
@ -812,6 +844,28 @@ export default function TableManagementPage() {
|
|||
// detailSettings 계산
|
||||
let finalDetailSettings = column.detailSettings || "";
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
if (column.inputType === "numbering" && column.numberingRuleId) {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
|
||||
columnName: column.columnName,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
finalDetailSettings,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
|
||||
if (column.inputType === "entity" && column.referenceTable) {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
|
|
@ -1933,7 +1987,118 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
|
||||
{/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */}
|
||||
{column.inputType === "numbering" && (
|
||||
<div className="w-64">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">채번규칙</label>
|
||||
<Popover
|
||||
open={numberingComboboxOpen[column.columnName] || false}
|
||||
onOpenChange={(open) =>
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: open,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={numberingComboboxOpen[column.columnName] || false}
|
||||
disabled={numberingRulesLoading}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{numberingRulesLoading
|
||||
? "로딩 중..."
|
||||
: column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
|
||||
?.ruleName || column.numberingRuleId
|
||||
: "채번규칙 선택..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
채번규칙을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
handleColumnChange(columnIndex, "numberingRuleId", undefined);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 자동 저장 제거 - 전체 저장 버튼으로 저장
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!column.numberingRuleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule) => (
|
||||
<CommandItem
|
||||
key={rule.ruleId}
|
||||
value={`${rule.ruleName} ${rule.ruleId}`}
|
||||
onSelect={() => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
// 상태 업데이트만 (자동 저장 제거)
|
||||
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 전체 저장 버튼으로 저장
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.numberingRuleId === rule.ruleId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rule.ruleName}</span>
|
||||
{rule.tableName && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{rule.tableName}.{rule.columnName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{column.numberingRuleId && (
|
||||
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-0.5 text-[10px]">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
<span>규칙 설정됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ interface AutoConfigPanelProps {
|
|||
config?: any;
|
||||
onChange: (config: any) => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
|
|
@ -38,7 +37,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|||
config = {},
|
||||
onChange,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
// 1. 순번 (자동 증가)
|
||||
if (partType === "sequence") {
|
||||
|
|
@ -163,18 +161,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 6. 참조 (마스터-디테일 분번)
|
||||
if (partType === "reference") {
|
||||
return (
|
||||
<ReferenceConfigSection
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
isPreview={isPreview}
|
||||
tableName={tableName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -1102,94 +1088,3 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ReferenceConfigSection({
|
||||
config,
|
||||
onChange,
|
||||
isPreview,
|
||||
tableName,
|
||||
}: {
|
||||
config: any;
|
||||
onChange: (c: any) => void;
|
||||
isPreview: boolean;
|
||||
tableName?: string;
|
||||
}) {
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingCols, setLoadingCols] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) return;
|
||||
setLoadingCols(true);
|
||||
|
||||
const loadEntityColumns = async () => {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(
|
||||
`/screen-management/tables/${tableName}/columns`
|
||||
);
|
||||
const allCols = response.data?.data || response.data || [];
|
||||
const entityCols = allCols.filter(
|
||||
(c: any) =>
|
||||
(c.inputType || c.input_type) === "entity" ||
|
||||
(c.inputType || c.input_type) === "numbering"
|
||||
);
|
||||
setColumns(
|
||||
entityCols.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName:
|
||||
c.columnLabel || c.column_label || c.columnName || c.column_name,
|
||||
dataType: c.dataType || c.data_type || "",
|
||||
inputType: c.inputType || c.input_type || "",
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setLoadingCols(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEntityColumns();
|
||||
}, [tableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">참조 컬럼</Label>
|
||||
<Select
|
||||
value={config.referenceColumnName || ""}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...config, referenceColumnName: value })
|
||||
}
|
||||
disabled={isPreview || loadingCols}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingCols
|
||||
? "로딩 중..."
|
||||
: columns.length === 0
|
||||
? "엔티티 컬럼 없음"
|
||||
: "컬럼 선택"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.displayName} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
마스터 테이블과 연결된 엔티티/채번 컬럼의 값을 코드에 포함합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ interface NumberingRuleCardProps {
|
|||
onUpdate: (updates: Partial<NumberingRulePart>) => void;
|
||||
onDelete: () => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||
|
|
@ -24,7 +23,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
onUpdate,
|
||||
onDelete,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border bg-card flex-1">
|
||||
|
|
@ -59,7 +57,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
date: { dateFormat: "YYYYMMDD" },
|
||||
text: { textValue: "CODE" },
|
||||
category: { categoryKey: "", categoryMappings: [] },
|
||||
reference: { referenceColumnName: "" },
|
||||
};
|
||||
onUpdate({
|
||||
partType: newPartType,
|
||||
|
|
@ -108,7 +105,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
config={part.autoConfig}
|
||||
onChange={(autoConfig) => onUpdate({ autoConfig })}
|
||||
isPreview={isPreview}
|
||||
tableName={tableName}
|
||||
/>
|
||||
) : (
|
||||
<ManualConfigPanel
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
|
||||
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import {
|
||||
saveNumberingRuleToTest,
|
||||
deleteNumberingRuleFromTest,
|
||||
getNumberingRulesFromTest,
|
||||
} from "@/lib/api/numberingRule";
|
||||
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NumberingColumn {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface GroupedColumns {
|
||||
tableLabel: string;
|
||||
columns: NumberingColumn[];
|
||||
// 카테고리 값 트리 노드 타입
|
||||
interface CategoryValueNode {
|
||||
valueId: number;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
depth: number;
|
||||
path: string;
|
||||
parentValueId: number | null;
|
||||
children?: CategoryValueNode[];
|
||||
}
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
|
|
@ -48,100 +54,138 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
currentTableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [columnSearch, setColumnSearch] = useState("");
|
||||
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
|
||||
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||
|
||||
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
// 좌측: 채번 타입 컬럼 목록 로드
|
||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||
interface CategoryOption {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
displayName: string; // "테이블명.컬럼명" 형식
|
||||
}
|
||||
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
|
||||
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
|
||||
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
|
||||
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadNumberingColumns();
|
||||
loadRules();
|
||||
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
|
||||
}, []);
|
||||
|
||||
const loadNumberingColumns = async () => {
|
||||
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
|
||||
useEffect(() => {
|
||||
if (currentRule?.categoryColumn) {
|
||||
setSelectedCategoryKey(currentRule.categoryColumn);
|
||||
} else {
|
||||
setSelectedCategoryKey("");
|
||||
}
|
||||
}, [currentRule?.categoryColumn]);
|
||||
|
||||
// 카테고리 키 선택 시 해당 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
if (selectedCategoryKey) {
|
||||
const [tableName, columnName] = selectedCategoryKey.split(".");
|
||||
if (tableName && columnName) {
|
||||
loadCategoryValues(tableName, columnName);
|
||||
}
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
}, [selectedCategoryKey]);
|
||||
|
||||
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
|
||||
const loadAllCategoryOptions = async () => {
|
||||
try {
|
||||
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
|
||||
const response = await getAllCategoryKeys();
|
||||
if (response.success && response.data) {
|
||||
const options: CategoryOption[] = response.data.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
columnName: item.columnName,
|
||||
displayName: `${item.tableName}.${item.columnName}`,
|
||||
}));
|
||||
setAllCategoryOptions(options);
|
||||
console.log("전체 카테고리 옵션 로드:", options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 옵션 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 카테고리 컬럼의 값 트리 조회
|
||||
const loadCategoryValues = async (tableName: string, columnName: string) => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const response = await getCategoryTree(tableName, columnName);
|
||||
if (response.success && response.data) {
|
||||
setCategoryValues(response.data);
|
||||
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 트리 조회 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
|
||||
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
flattenCategoryValues(node.children, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategoryValues = flattenCategoryValues(categoryValues);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/numbering-columns");
|
||||
if (response.data.success && response.data.data) {
|
||||
setNumberingColumns(response.data.data);
|
||||
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
|
||||
menuObjid,
|
||||
hasMenuObjid: !!menuObjid,
|
||||
});
|
||||
|
||||
// test 테이블에서 조회
|
||||
const response = await getNumberingRulesFromTest(menuObjid);
|
||||
|
||||
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
|
||||
menuObjid,
|
||||
success: response.success,
|
||||
rulesCount: response.data?.length || 0,
|
||||
rules: response.data,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSavedRules(response.data);
|
||||
} else {
|
||||
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
||||
toast.error(`로딩 실패: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
|
||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
||||
setSelectedColumn({ tableName, columnName });
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const rule = response.data.data as NumberingRuleConfig;
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
} else {
|
||||
// 규칙 없으면 신규 생성 모드
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
}
|
||||
} catch {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블별로 그룹화
|
||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
||||
if (!acc[col.tableName]) {
|
||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
||||
}
|
||||
acc[col.tableName].columns.push(col);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 검색 필터 적용
|
||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
||||
if (!columnSearch) return true;
|
||||
const search = columnSearch.toLowerCase();
|
||||
return (
|
||||
tableName.toLowerCase().includes(search) ||
|
||||
group.tableLabel.toLowerCase().includes(search) ||
|
||||
group.columns.some(
|
||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
||||
)
|
||||
);
|
||||
});
|
||||
}, [menuObjid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
|
|
@ -299,20 +343,60 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
return part;
|
||||
});
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
|
||||
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
|
||||
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
|
||||
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "table" as const,
|
||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
||||
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
currentTableName,
|
||||
menuObjid,
|
||||
"currentRule.tableName": currentRule.tableName,
|
||||
"currentRule.menuObjid": currentRule.menuObjid,
|
||||
"ruleToSave.tableName": ruleToSave.tableName,
|
||||
"ruleToSave.menuObjid": ruleToSave.menuObjid,
|
||||
"ruleToSave.scopeType": ruleToSave.scopeType,
|
||||
ruleToSave,
|
||||
});
|
||||
|
||||
// 테스트 테이블에 저장 (numbering_rules)
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
|
||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
|
||||
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
|
||||
setSavedRules((prev) => {
|
||||
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
|
||||
|
||||
console.log("🔍 [handleSave] setSavedRules:", {
|
||||
ruleId: ruleToSave.ruleId,
|
||||
existsInPrev,
|
||||
prevCount: prev.length,
|
||||
});
|
||||
|
||||
if (existsInPrev) {
|
||||
// 기존 규칙 업데이트
|
||||
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
|
||||
} else {
|
||||
// 새 규칙 추가
|
||||
return [...prev, savedData];
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentRule(currentData);
|
||||
setSelectedRuleId(response.data.ruleId);
|
||||
|
||||
await onSave?.(response.data);
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
|
|
@ -323,62 +407,143 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, onSave, selectedColumn]);
|
||||
}, [currentRule, onSave, currentTableName, menuObjid]);
|
||||
|
||||
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||
console.log("🔍 [handleSelectRule] 규칙 선택:", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
partsCount: rule.parts?.length || 0,
|
||||
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
||||
});
|
||||
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
|
||||
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
|
||||
|
||||
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
|
||||
ruleId: ruleCopy.ruleId,
|
||||
partsCount: ruleCopy.parts?.length || 0,
|
||||
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
||||
});
|
||||
|
||||
setCurrentRule(ruleCopy);
|
||||
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSavedRule = useCallback(
|
||||
async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRuleFromTest(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
||||
if (selectedRuleId === ruleId) {
|
||||
setSelectedRuleId(null);
|
||||
setCurrentRule(null);
|
||||
}
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
} else {
|
||||
showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const handleNewRule = useCallback(() => {
|
||||
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
|
||||
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 채번 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
||||
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||
};
|
||||
|
||||
console.log("📋 생성된 규칙 정보:", newRule);
|
||||
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(newRule);
|
||||
|
||||
toast.success("새 규칙이 생성되었습니다");
|
||||
}, [currentTableName, menuObjid]);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full gap-4 ${className}`}>
|
||||
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
|
||||
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold sm:text-base">채번 컬럼</h2>
|
||||
{/* 좌측: 저장된 규칙 목록 */}
|
||||
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{editingLeftTitle ? (
|
||||
<Input
|
||||
value={leftTitle}
|
||||
onChange={(e) => setLeftTitle(e.target.value)}
|
||||
onBlur={() => setEditingLeftTitle(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
|
||||
className="h-8 text-sm font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={columnSearch}
|
||||
onChange={(e) => setColumnSearch(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
||||
<Plus className="mr-2 h-4 w-4" />새 규칙 생성
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||
{loading && numberingColumns.length === 0 ? (
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
) : savedRules.length === 0 ? (
|
||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{numberingColumns.length === 0
|
||||
? "채번 타입 컬럼이 없습니다"
|
||||
: "검색 결과가 없습니다"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">저장된 규칙이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map(([tableName, group]) => (
|
||||
<div key={tableName} className="mb-2">
|
||||
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
|
||||
<FolderTree className="h-3 w-3" />
|
||||
<span>{group.tableLabel}</span>
|
||||
<span className="text-muted-foreground/60">({group.columns.length})</span>
|
||||
</div>
|
||||
{group.columns.map((col) => {
|
||||
const isSelected =
|
||||
selectedColumn?.tableName === col.tableName &&
|
||||
selectedColumn?.columnName === col.columnName;
|
||||
return (
|
||||
<div
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary border-primary border font-medium"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
||||
>
|
||||
{col.columnLabel}
|
||||
savedRules.map((rule) => (
|
||||
<Card
|
||||
key={rule.ruleId}
|
||||
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
|
||||
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||
}`}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
>
|
||||
<CardHeader className="px-3 py-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSavedRule(rule.ruleId);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="text-destructive h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -392,9 +557,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
{!currentRule ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">컬럼을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다</p>
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">규칙을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 규칙을 선택하거나 새로 생성하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -460,7 +624,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
tableName={selectedColumn?.tableName}
|
||||
/>
|
||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||
{index < currentRule.parts.length - 1 && (
|
||||
|
|
|
|||
|
|
@ -619,40 +619,45 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
try {
|
||||
// 채번 규칙 ID 캐싱 (한 번만 조회)
|
||||
if (!numberingRuleIdRef.current) {
|
||||
// table_name + column_name 기반으로 채번 규칙 조회
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
|
||||
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const columnsResponse = await getTableColumns(tableName);
|
||||
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// by-column 조회 실패 시 detailSettings fallback
|
||||
if (!columnsResponse.success || !columnsResponse.data) {
|
||||
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = columnsResponse.data.columns || columnsResponse.data;
|
||||
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
||||
|
||||
if (!targetColumn) {
|
||||
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
// detailSettings에서 numberingRuleId 추출
|
||||
if (targetColumn.detailSettings) {
|
||||
try {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const columnsResponse = await getTableColumns(tableName);
|
||||
if (columnsResponse.success && columnsResponse.data) {
|
||||
const columns = columnsResponse.data.columns || columnsResponse.data;
|
||||
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
||||
if (targetColumn?.detailSettings) {
|
||||
const parsed = typeof targetColumn.detailSettings === "string"
|
||||
? JSON.parse(targetColumn.detailSettings)
|
||||
: targetColumn.detailSettings;
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
}
|
||||
// 문자열이면 파싱, 객체면 그대로 사용
|
||||
const parsed = typeof targetColumn.detailSettings === "string"
|
||||
? JSON.parse(targetColumn.detailSettings)
|
||||
: targetColumn.detailSettings;
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
|
||||
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
|
||||
if (parsed.numberingRuleId && onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// JSON 파싱 실패
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const numberingRuleId = numberingRuleIdRef.current;
|
||||
|
||||
if (!numberingRuleId) {
|
||||
console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
|
||||
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -929,42 +929,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return result;
|
||||
}, []);
|
||||
|
||||
// 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율)
|
||||
const renderProgressCell = useCallback(
|
||||
(col: any, item: any, parentData: any) => {
|
||||
const current = Number(item[col.numerator] || 0);
|
||||
const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0);
|
||||
const percentage = max > 0 ? Math.round((current / max) * 100) : 0;
|
||||
const barWidth = Math.min(percentage, 100);
|
||||
const barColor =
|
||||
percentage > 100
|
||||
? "bg-red-600"
|
||||
: percentage >= 90
|
||||
? "bg-red-500"
|
||||
: percentage >= 70
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[120px] items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="bg-muted h-2 w-full rounded-full">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
{current.toLocaleString()} / {max.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium">{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||
const formatCellValue = useCallback(
|
||||
(
|
||||
|
|
@ -3986,14 +3950,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
{tabSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
|
|
@ -4102,14 +4064,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
{listSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
|
|
@ -4526,14 +4486,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
|
||||
// 드래그 가능한 컬럼 아이템
|
||||
function SortableColumnRow({
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, onProgressChange, availableChildColumns, availableParentColumns,
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
}: {
|
||||
id: string;
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string };
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||
index: number;
|
||||
isNumeric: boolean;
|
||||
isEntityJoin?: boolean;
|
||||
|
|
@ -41,9 +41,6 @@ function SortableColumnRow({
|
|||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
onProgressChange?: (updates: { numerator?: string; denominator?: string }) => void;
|
||||
availableChildColumns?: Array<{ columnName: string; columnLabel: string }>;
|
||||
availableParentColumns?: Array<{ columnName: string; columnLabel: string }>;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
|
@ -56,44 +53,12 @@ function SortableColumnRow({
|
|||
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
col.type === "progress" && "border-emerald-200 bg-emerald-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{col.type === "progress" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="shrink-0 cursor-pointer rounded bg-emerald-100 px-1 text-[9px] font-medium text-emerald-700 hover:bg-emerald-200" title="클릭하여 설정 변경">BAR</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 space-y-2 p-3" align="start">
|
||||
<p className="text-xs font-medium">프로그레스 설정</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">현재값 (자식 컬럼)</Label>
|
||||
<Select value={col.numerator || ""} onValueChange={(v) => onProgressChange?.({ numerator: v })}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(availableChildColumns || []).map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">최대값 (부모 컬럼)</Label>
|
||||
<Select value={col.denominator || ""} onValueChange={(v) => onProgressChange?.({ denominator: v })}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(availableParentColumns || []).map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : isEntityJoin ? (
|
||||
{isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
|
|
@ -691,13 +656,6 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
onProgressChange={(updates) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], ...updates };
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
|
||||
availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -727,104 +685,6 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 컬럼 추가 */}
|
||||
{tab.tableName && (
|
||||
<div className="border-border/60 my-2 border-t pt-2">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 select-none">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-emerald-500 transition-transform group-open:rotate-90" />
|
||||
<span className="text-[10px] font-medium text-emerald-600">프로그레스 컬럼 추가</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<Input
|
||||
id={`tab-${tabIndex}-progress-label`}
|
||||
placeholder="예: 샷수 현황"
|
||||
className="h-7 text-xs"
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">현재값 (자식 컬럼)</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
const el = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
|
||||
if (el) el.value = v;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" id={`tab-${tabIndex}-progress-numerator`} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">최대값 (부모 컬럼)</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
const el = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
|
||||
if (el) el.value = v;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{leftTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" id={`tab-${tabIndex}-progress-denominator`} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs text-emerald-700 border-emerald-300 hover:bg-emerald-100"
|
||||
onClick={() => {
|
||||
const labelEl = document.getElementById(`tab-${tabIndex}-progress-label`) as HTMLInputElement;
|
||||
const numEl = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
|
||||
const denEl = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
|
||||
const label = labelEl?.value || "프로그레스";
|
||||
const numerator = numEl?.value;
|
||||
const denominator = denEl?.value;
|
||||
if (!numerator || !denominator) return;
|
||||
updateTab({
|
||||
columns: [
|
||||
...selectedColumns,
|
||||
{
|
||||
name: `progress_${numerator}_${denominator}`,
|
||||
label,
|
||||
width: 200,
|
||||
type: "progress",
|
||||
numerator,
|
||||
denominator,
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
if (labelEl) labelEl.value = "";
|
||||
}}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
|
||||
{(() => {
|
||||
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
|
||||
|
|
|
|||
|
|
@ -35,20 +35,14 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||
page: 1,
|
||||
size: 9999,
|
||||
search: relationColumn ? { [relationColumn]: parentValue } : {},
|
||||
const res = await apiClient.get(`/table-management/data/${tableName}`, {
|
||||
params: {
|
||||
autoFilter: "true",
|
||||
[relationColumn]: parentValue,
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = res.data?.data;
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(responseData)) {
|
||||
rows = responseData;
|
||||
} else if (responseData && typeof responseData === "object") {
|
||||
rows = Array.isArray(responseData.data) ? responseData.data :
|
||||
Array.isArray(responseData.rows) ? responseData.rows : [];
|
||||
}
|
||||
const rows: any[] = res.data?.data || res.data?.rows || res.data || [];
|
||||
const grouped: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
|
|
@ -75,7 +69,7 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
|
|||
};
|
||||
|
||||
const getCount = (item: StatusCountItem) => {
|
||||
if (item.value === "__TOTAL__" || item.value === "__ALL__") {
|
||||
if (item.value === "__TOTAL__") {
|
||||
return Object.values(counts).reduce((sum, c) => sum + c, 0);
|
||||
}
|
||||
const values = item.value.split(",").map((v) => v.trim());
|
||||
|
|
|
|||
|
|
@ -233,47 +233,6 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// 상태 컬럼의 카테고리 값 로드
|
||||
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.tableName || !config.statusColumn) {
|
||||
setStatusCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryValues = async () => {
|
||||
setLoadingCategoryValues(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${config.tableName}/${config.statusColumn}/values`
|
||||
);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const flatValues: Array<{ value: string; label: string }> = [];
|
||||
const flatten = (items: any[]) => {
|
||||
for (const item of items) {
|
||||
flatValues.push({
|
||||
value: item.valueCode || item.value_code,
|
||||
label: item.valueLabel || item.value_label,
|
||||
});
|
||||
if (item.children?.length > 0) flatten(item.children);
|
||||
}
|
||||
};
|
||||
flatten(response.data.data);
|
||||
setStatusCategoryValues(flatValues);
|
||||
}
|
||||
} catch {
|
||||
setStatusCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategoryValues(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryValues();
|
||||
}, [config.tableName, config.statusColumn]);
|
||||
|
||||
const tableComboItems = tables.map((t) => ({
|
||||
value: t.tableName,
|
||||
label: t.displayName,
|
||||
|
|
@ -411,52 +370,15 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingCategoryValues && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 카테고리 값 로딩...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item: StatusCountItem, i: number) => (
|
||||
<div key={i} className="space-y-1 rounded-md border p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{statusCategoryValues.length > 0 ? (
|
||||
<Select
|
||||
value={item.value || ""}
|
||||
onValueChange={(v) => {
|
||||
handleItemChange(i, "value", v);
|
||||
if (v === "__ALL__" && !item.label) {
|
||||
handleItemChange(i, "label", "전체");
|
||||
} else {
|
||||
const catVal = statusCategoryValues.find((cv) => cv.value === v);
|
||||
if (catVal && !item.label) {
|
||||
handleItemChange(i, "label", catVal.label);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="카테고리 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__" className="text-xs font-medium">
|
||||
전체
|
||||
</SelectItem>
|
||||
{statusCategoryValues.map((cv) => (
|
||||
<SelectItem key={cv.value} value={cv.value} className="text-xs">
|
||||
{cv.label} ({cv.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => handleItemChange(i, "value", e.target.value)}
|
||||
placeholder="상태값 (예: IN_USE)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => handleItemChange(i, "value", e.target.value)}
|
||||
placeholder="상태값 (예: IN_USE)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -496,12 +418,6 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
|
||||
<p className="text-[10px] text-amber-600">
|
||||
카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@
|
|||
* 코드 파트 유형 (5가지)
|
||||
*/
|
||||
export type CodePartType =
|
||||
| "sequence" // 순번 (자동 증가 숫자)
|
||||
| "number" // 숫자 (고정 자릿수)
|
||||
| "date" // 날짜 (다양한 날짜 형식)
|
||||
| "text" // 문자 (텍스트)
|
||||
| "category" // 카테고리 (카테고리 값에 따른 형식)
|
||||
| "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용)
|
||||
| "sequence" // 순번 (자동 증가 숫자)
|
||||
| "number" // 숫자 (고정 자릿수)
|
||||
| "date" // 날짜 (다양한 날짜 형식)
|
||||
| "text" // 문자 (텍스트)
|
||||
| "category"; // 카테고리 (카테고리 값에 따른 형식)
|
||||
|
||||
/**
|
||||
* 생성 방식
|
||||
|
|
@ -78,9 +77,6 @@ export interface NumberingRulePart {
|
|||
// 카테고리용
|
||||
categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type")
|
||||
categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑
|
||||
|
||||
// 참조용 (마스터-디테일 분번)
|
||||
referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함)
|
||||
};
|
||||
|
||||
// 직접 입력 설정
|
||||
|
|
@ -136,7 +132,6 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string;
|
|||
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
|
||||
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
|
||||
{ value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" },
|
||||
{ value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" },
|
||||
];
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue