Compare commits

...

3 Commits

Author SHA1 Message Date
kjs c98b2ccb43 feat: Add progress bar functionality to SplitPanelLayoutComponent and configuration options
- Implemented a new progress bar rendering function in the SplitPanelLayoutComponent to visually represent the ratio of child to parent values.
- Enhanced the SortableColumnRow component to support progress column configuration, allowing users to set current and maximum values through a popover interface.
- Updated the AdditionalTabConfigPanel to include options for adding progress columns, improving user experience in managing data visualization.

These changes significantly enhance the functionality and usability of the split panel layout by providing visual progress indicators and configuration options for users.
2026-03-09 18:05:00 +09:00
kjs 4d6783e508 feat: Implement automatic serial number generation and reference handling in mold management
- Enhanced the `createMoldSerial` function to automatically generate serial numbers based on defined numbering rules when the serial number is not provided.
- Integrated error handling for the automatic numbering process, ensuring robust logging for success and failure cases.
- Updated the `NumberingRuleService` to support reference column handling, allowing for dynamic prefix generation based on related data.
- Modified the frontend components to accommodate new reference configurations, improving user experience in managing numbering rules.

These changes significantly enhance the mold management functionality by automating serial number generation and improving the flexibility of numbering rules.
2026-03-09 15:34:31 +09:00
kjs 2b4b7819c5 feat: Add Numbering Rule APIs and Frontend Integration
- Implemented a new API endpoint to retrieve numbering rules based on table and column names, enhancing the flexibility of numbering rule management.
- Added a new service method to handle the retrieval of numbering columns specific to a company, ensuring proper company code filtering.
- Updated the frontend to load and display numbering columns, allowing users to select and manage numbering rules effectively.
- Refactored existing logic to improve the handling of numbering rules, including fallback mechanisms for legacy data.

These changes enhance the functionality and user experience in managing numbering rules within the application.
2026-03-09 14:10:08 +09:00
17 changed files with 891 additions and 575 deletions

View File

@ -233,8 +233,35 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
const { moldCode } = req.params; const { moldCode } = req.params;
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
if (!serial_number) { let finalSerialNumber = serial_number;
res.status(400).json({ success: false, message: "일련번호는 필수입니다." });
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
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: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
return; return;
} }
@ -244,7 +271,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
RETURNING * RETURNING *
`; `;
const params = [ const params = [
companyCode, moldCode, serial_number, status || "STORED", companyCode, moldCode, finalSerialNumber, status || "STORED",
progress || 0, work_description || null, manager || null, progress || 0, work_description || null, manager || null,
completion_date || null, remarks || null, userId, completion_date || null, remarks || null, userId,
]; ];

View File

@ -405,6 +405,30 @@ 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 ==================== // ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회 // [테스트] 테스트 테이블에서 채번 규칙 목록 조회

View File

@ -3019,3 +3019,72 @@ 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,
});
}
}

View File

@ -25,6 +25,7 @@ import {
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/ */
router.get("/category-columns", getCategoryColumnsByCompany); router.get("/category-columns", getCategoryColumnsByCompany);
/**
*
* GET /api/table-management/numbering-columns
*/
router.get("/numbering-columns", getNumberingColumnsByCompany);
/** /**
* *
* GET /api/table-management/menu/:menuObjid/category-columns * GET /api/table-management/menu/:menuObjid/category-columns

View File

@ -494,7 +494,7 @@ class MasterDetailExcelService {
/** /**
* , ID를 * , ID를
* , (*) fallback * numbering_rules table_name + column_name + company_code로
*/ */
private async detectNumberingRuleForColumn( private async detectNumberingRuleForColumn(
tableName: string, tableName: string,
@ -502,34 +502,60 @@ class MasterDetailExcelService {
companyCode?: string companyCode?: string
): Promise<{ numberingRuleId: string } | null> { ): Promise<{ numberingRuleId: string } | null> {
try { try {
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) // 1. table_type_columns에서 numbering 타입인지 확인
const companyCondition = companyCode && companyCode !== "*" const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')` ? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`; : `AND company_code = '*'`;
const params = companyCode && companyCode !== "*" const ttcParams = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode] ? [tableName, columnName, companyCode]
: [tableName, columnName]; : [tableName, columnName];
const result = await query<any>( const ttcResult = await query<any>(
`SELECT input_type, detail_settings, company_code `SELECT input_type FROM table_type_columns
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition} WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, AND input_type = 'numbering' LIMIT 1`,
params ttcParams
); );
// 채번 타입인 행 찾기 (회사별 우선) if (ttcResult.length === 0) return null;
for (const row of result) {
if (row.input_type === "numbering") { // 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
);
for (const row of fallbackResult) {
const settings = typeof row.detail_settings === "string" const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}") ? JSON.parse(row.detail_settings || "{}")
: row.detail_settings; : row.detail_settings;
if (settings?.numberingRuleId) { if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId }; return { numberingRuleId: settings.numberingRuleId };
} }
} }
}
return null; return null;
} catch (error) { } catch (error) {
@ -540,7 +566,7 @@ class MasterDetailExcelService {
/** /**
* *
* , (*) fallback * numbering_rules table_name + column_name으로
* @returns Map<columnName, numberingRuleId> * @returns Map<columnName, numberingRuleId>
*/ */
private async detectAllNumberingColumns( private async detectAllNumberingColumns(
@ -549,6 +575,7 @@ class MasterDetailExcelService {
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>(); const numberingCols = new Map<string, string>();
try { try {
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
const companyCondition = companyCode && companyCode !== "*" const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')` ? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`; : `AND company_code = '*'`;
@ -556,22 +583,26 @@ class MasterDetailExcelService {
? [tableName, companyCode] ? [tableName, companyCode]
: [tableName]; : [tableName];
const result = await query<any>( const ttcResult = await query<any>(
`SELECT column_name, detail_settings, company_code `SELECT DISTINCT column_name FROM table_type_columns
FROM table_type_columns WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params params
); );
// 컬럼별로 회사 설정 우선 적용 // 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
for (const row of result) { for (const row of ttcResult) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 const ruleResult = await query<any>(
const settings = typeof row.detail_settings === "string" `SELECT rule_id FROM numbering_rules
? JSON.parse(row.detail_settings || "{}") WHERE table_name = $1 AND column_name = $2 ${companyCondition}
: row.detail_settings; ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
if (settings?.numberingRuleId) { LIMIT 1`,
numberingCols.set(row.column_name, settings.numberingRuleId); companyCode && companyCode !== "*"
? [tableName, row.column_name, companyCode]
: [tableName, row.column_name]
);
if (ruleResult.length > 0) {
numberingCols.set(row.column_name, ruleResult[0].rule_id);
} }
} }

View File

@ -172,6 +172,16 @@ class NumberingRuleService {
break; break;
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default: default:
break; break;
} }
@ -1245,6 +1255,14 @@ class NumberingRuleService {
return ""; return "";
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default: default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType }); logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return ""; return "";
@ -1375,6 +1393,13 @@ class NumberingRuleService {
return catMapping2?.format || "CATEGORY"; return catMapping2?.format || "CATEGORY";
} }
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default: default:
return ""; return "";
} }
@ -1524,6 +1549,15 @@ class NumberingRuleService {
return ""; 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: default:
return ""; return "";
} }
@ -1747,7 +1781,53 @@ class NumberingRuleService {
`; `;
const params = [companyCode, tableName, columnName]; const params = [companyCode, tableName, columnName];
const result = await pool.query(query, params); 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,
});
}
}
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
@ -1760,7 +1840,6 @@ class NumberingRuleService {
const rule = result.rows[0]; const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = ` const partsQuery = `
SELECT SELECT
id, id,
@ -1779,7 +1858,7 @@ class NumberingRuleService {
]); ]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows); rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
ruleName: rule.ruleName, ruleName: rule.ruleName,
}); });

View File

@ -2691,6 +2691,32 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`); 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 skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => { const existingColumns = Object.keys(data).filter((col) => {

View File

@ -669,38 +669,6 @@ export default function TableManagementPage() {
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); 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 = { const columnSetting = {
columnName: column.columnName, columnName: column.columnName,
columnLabel: column.displayName, columnLabel: column.displayName,
@ -844,28 +812,6 @@ export default function TableManagementPage() {
// detailSettings 계산 // detailSettings 계산
let finalDetailSettings = column.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에 엔티티 설정 포함 // 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
if (column.inputType === "entity" && column.referenceTable) { if (column.inputType === "entity" && column.referenceTable) {
let existingSettings: Record<string, unknown> = {}; let existingSettings: Record<string, unknown> = {};
@ -1987,118 +1933,7 @@ 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> </div>
<div className="pl-4"> <div className="pl-4">

View File

@ -18,6 +18,7 @@ interface AutoConfigPanelProps {
config?: any; config?: any;
onChange: (config: any) => void; onChange: (config: any) => void;
isPreview?: boolean; isPreview?: boolean;
tableName?: string;
} }
interface TableInfo { interface TableInfo {
@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
config = {}, config = {},
onChange, onChange,
isPreview = false, isPreview = false,
tableName,
}) => { }) => {
// 1. 순번 (자동 증가) // 1. 순번 (자동 증가)
if (partType === "sequence") { if (partType === "sequence") {
@ -161,6 +163,18 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
); );
} }
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null; return null;
}; };
@ -1088,3 +1102,94 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
</div> </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>
);
}

View File

@ -16,6 +16,7 @@ interface NumberingRuleCardProps {
onUpdate: (updates: Partial<NumberingRulePart>) => void; onUpdate: (updates: Partial<NumberingRulePart>) => void;
onDelete: () => void; onDelete: () => void;
isPreview?: boolean; isPreview?: boolean;
tableName?: string;
} }
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
onUpdate, onUpdate,
onDelete, onDelete,
isPreview = false, isPreview = false,
tableName,
}) => { }) => {
return ( return (
<Card className="border-border bg-card flex-1"> <Card className="border-border bg-card flex-1">
@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
date: { dateFormat: "YYYYMMDD" }, date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" }, text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] }, category: { categoryKey: "", categoryMappings: [] },
reference: { referenceColumnName: "" },
}; };
onUpdate({ onUpdate({
partType: newPartType, partType: newPartType,
@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
config={part.autoConfig} config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })} onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview} isPreview={isPreview}
tableName={tableName}
/> />
) : ( ) : (
<ManualConfigPanel <ManualConfigPanel

View File

@ -1,36 +1,30 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react"; import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview"; import { NumberingRulePreview } from "./NumberingRulePreview";
import { import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
saveNumberingRuleToTest, import { apiClient } from "@/lib/api/client";
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"; import { cn } from "@/lib/utils";
// 카테고리 값 트리 노드 타입 interface NumberingColumn {
interface CategoryValueNode { tableName: string;
valueId: number; tableLabel: string;
valueCode: string; columnName: string;
valueLabel: string; columnLabel: string;
depth: number; }
path: string;
parentValueId: number | null; interface GroupedColumns {
children?: CategoryValueNode[]; tableLabel: string;
columns: NumberingColumn[];
} }
interface NumberingRuleDesignerProps { interface NumberingRuleDesignerProps {
@ -54,138 +48,100 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName, currentTableName,
menuObjid, menuObjid,
}) => { }) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]); const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null); const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null); const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); const [columnSearch, setColumnSearch] = useState("");
const [rightTitle, setRightTitle] = useState("규칙 편집"); const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자) // 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({}); const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({}); 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(() => { useEffect(() => {
loadRules(); loadNumberingColumns();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
}, []); }, []);
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화 const loadNumberingColumns = async () => {
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); setLoading(true);
try { try {
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", { const response = await apiClient.get("/table-management/numbering-columns");
menuObjid, if (response.data.success && response.data.data) {
hasMenuObjid: !!menuObjid, setNumberingColumns(response.data.data);
});
// 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) { } catch (error: any) {
toast.error(`로딩 실패: ${error.message}`); console.error("채번 컬럼 목록 로드 실패:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [menuObjid]); };
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
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)
)
);
});
useEffect(() => { useEffect(() => {
if (currentRule) { if (currentRule) {
@ -343,60 +299,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return part; return part;
}); });
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
const ruleToSave = { const ruleToSave = {
...currentRule, ...currentRule,
parts: partsWithDefaults, parts: partsWithDefaults,
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정 scopeType: "table" as const,
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) tableName: selectedColumn?.tableName || currentRule.tableName || "",
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준) columnName: selectedColumn?.columnName || currentRule.columnName || "",
}; };
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) // 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave); const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) { if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; 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); setCurrentRule(currentData);
setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data); await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다"); toast.success("채번 규칙이 저장되었습니다");
} else { } else {
@ -407,143 +323,62 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentRule, onSave, currentTableName, menuObjid]); }, [currentRule, onSave, selectedColumn]);
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 ( return (
<div className={`flex h-full gap-4 ${className}`}> <div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 저장된 규칙 목록 */} {/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4"> <div className="flex w-72 flex-shrink-0 flex-col gap-3">
<div className="flex items-center justify-between"> <h2 className="text-sm font-semibold sm:text-base"> </h2>
{editingLeftTitle ? (
<Input <Input
value={leftTitle} value={columnSearch}
onChange={(e) => setLeftTitle(e.target.value)} onChange={(e) => setColumnSearch(e.target.value)}
onBlur={() => setEditingLeftTitle(false)} placeholder="검색..."
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)} className="h-8 text-xs"
className="h-8 text-sm font-semibold"
autoFocus
/> />
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm"> <div className="flex-1 space-y-1 overflow-y-auto">
<Plus className="mr-2 h-4 w-4" /> {loading && numberingColumns.length === 0 ? (
</Button>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p> <p className="text-muted-foreground text-xs"> ...</p>
</div> </div>
) : savedRules.length === 0 ? ( ) : filteredGroups.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed"> <div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs"> </p> <p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
</div> </div>
) : ( ) : (
savedRules.map((rule) => ( filteredGroups.map(([tableName, group]) => (
<Card <div key={tableName} className="mb-2">
key={rule.ruleId} <div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${ <FolderTree className="h-3 w-3" />
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card" <span>{group.tableLabel}</span>
}`} <span className="text-muted-foreground/60">({group.columns.length})</span>
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 {group.columns.map((col) => {
variant="ghost" const isSelected =
size="icon" selectedColumn?.tableName === col.tableName &&
className="h-6 w-6" selectedColumn?.columnName === col.columnName;
onClick={(e) => { return (
e.stopPropagation(); <div
handleDeleteSavedRule(rule.ruleId); 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)}
> >
<Trash2 className="text-destructive h-3 w-3" /> {col.columnLabel}
</Button> </div>
);
})}
</div> </div>
</CardHeader>
</Card>
)) ))
)} )}
</div> </div>
@ -557,8 +392,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{!currentRule ? ( {!currentRule ? (
<div className="flex h-full flex-col items-center justify-center"> <div className="flex h-full flex-col items-center justify-center">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p> <FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<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>
</div> </div>
) : ( ) : (
@ -624,6 +460,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
onUpdate={(updates) => handleUpdatePart(part.order, updates)} onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)} onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview} isPreview={isPreview}
tableName={selectedColumn?.tableName}
/> />
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && ( {index < currentRule.parts.length - 1 && (

View File

@ -619,45 +619,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
try { try {
// 채번 규칙 ID 캐싱 (한 번만 조회) // 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) { 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;
if (onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
}
}
} catch {
// by-column 조회 실패 시 detailSettings fallback
try {
const { getTableColumns } = await import("@/lib/api/tableManagement"); const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName); const columnsResponse = await getTableColumns(tableName);
if (columnsResponse.success && columnsResponse.data) {
if (!columnsResponse.success || !columnsResponse.data) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
return;
}
const columns = columnsResponse.data.columns || columnsResponse.data; const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName); const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (targetColumn?.detailSettings) {
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
// detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings) {
try {
// 문자열이면 파싱, 객체면 그대로 사용
const parsed = typeof targetColumn.detailSettings === "string" const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings) ? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings; : targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null; numberingRuleIdRef.current = parsed.numberingRuleId || null;
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
if (parsed.numberingRuleId && onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
} }
} catch {
// JSON 파싱 실패
} }
} catch { /* ignore */ }
} }
} }
const numberingRuleId = numberingRuleIdRef.current; const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) { if (!numberingRuleId) {
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName }); console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
return; return;
} }

View File

@ -929,6 +929,42 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return result; 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( const formatCellValue = useCallback(
( (
@ -3950,7 +3986,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
> >
{tabSummaryColumns.map((col: any) => ( {tabSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs"> <td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue( {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
rightCategoryMappings, rightCategoryMappings,
@ -4064,7 +4102,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
> >
{listSummaryColumns.map((col: any) => ( {listSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs"> <td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue( {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
rightCategoryMappings, rightCategoryMappings,
@ -4486,7 +4526,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-xs whitespace-nowrap" className="px-3 py-2 text-xs whitespace-nowrap"
style={{ textAlign: col.align || "left" }} style={{ textAlign: col.align || "left" }}
> >
{formatCellValue( {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
rightCategoryMappings, rightCategoryMappings,

View File

@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
// 드래그 가능한 컬럼 아이템 // 드래그 가능한 컬럼 아이템
function SortableColumnRow({ function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, onProgressChange, availableChildColumns, availableParentColumns,
}: { }: {
id: string; id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean }; col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string };
index: number; index: number;
isNumeric: boolean; isNumeric: boolean;
isEntityJoin?: boolean; isEntityJoin?: boolean;
@ -41,6 +41,9 @@ function SortableColumnRow({
onRemove: () => void; onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void; onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (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 { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition }; const style = { transform: CSS.Transform.toString(transform), transition };
@ -53,12 +56,44 @@ function SortableColumnRow({
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5", "flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md", isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30", 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"> <div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" /> <GripVertical className="h-3 w-3" />
</div> </div>
{isEntityJoin ? ( {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 ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" /> <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> <span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
@ -656,6 +691,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
newColumns[index] = { ...newColumns[index], showInDetail: checked }; newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateTab({ columns: newColumns }); 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 }))}
/> />
); );
})} })}
@ -685,6 +727,104 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
))} ))}
</div> </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 조인 컬럼 - 아코디언 (접기/펼치기) */} {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
{(() => { {(() => {
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;

View File

@ -35,14 +35,20 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
setLoading(true); setLoading(true);
try { try {
const res = await apiClient.get(`/table-management/data/${tableName}`, { const res = await apiClient.post(`/table-management/tables/${tableName}/data`, {
params: { page: 1,
autoFilter: "true", size: 9999,
[relationColumn]: parentValue, search: relationColumn ? { [relationColumn]: parentValue } : {},
},
}); });
const rows: any[] = res.data?.data || res.data?.rows || res.data || []; 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 grouped: Record<string, number> = {}; const grouped: Record<string, number> = {};
for (const row of rows) { for (const row of rows) {
@ -69,7 +75,7 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
}; };
const getCount = (item: StatusCountItem) => { const getCount = (item: StatusCountItem) => {
if (item.value === "__TOTAL__") { if (item.value === "__TOTAL__" || item.value === "__ALL__") {
return Object.values(counts).reduce((sum, c) => sum + c, 0); return Object.values(counts).reduce((sum, c) => sum + c, 0);
} }
const values = item.value.split(",").map((v) => v.trim()); const values = item.value.split(",").map((v) => v.trim());

View File

@ -233,6 +233,47 @@ 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) => ({ const tableComboItems = tables.map((t) => ({
value: t.tableName, value: t.tableName,
label: t.displayName, label: t.displayName,
@ -370,15 +411,52 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
</Button> </Button>
</div> </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) => ( {items.map((item: StatusCountItem, i: number) => (
<div key={i} className="space-y-1 rounded-md border p-2"> <div key={i} className="space-y-1 rounded-md border p-2">
<div className="flex items-center gap-1"> <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 <Input
value={item.value} value={item.value}
onChange={(e) => handleItemChange(i, "value", e.target.value)} onChange={(e) => handleItemChange(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)" placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs" className="h-7 text-xs"
/> />
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -418,6 +496,12 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
</div> </div>
</div> </div>
))} ))}
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
<p className="text-[10px] text-amber-600">
. &gt; .
</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -11,7 +11,8 @@ export type CodePartType =
| "number" // 숫자 (고정 자릿수) | "number" // 숫자 (고정 자릿수)
| "date" // 날짜 (다양한 날짜 형식) | "date" // 날짜 (다양한 날짜 형식)
| "text" // 문자 (텍스트) | "text" // 문자 (텍스트)
| "category"; // 카테고리 (카테고리 값에 따른 형식) | "category" // 카테고리 (카테고리 값에 따른 형식)
| "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용)
/** /**
* *
@ -77,6 +78,9 @@ export interface NumberingRulePart {
// 카테고리용 // 카테고리용
categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type") categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type")
categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑 categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑
// 참조용 (마스터-디테일 분번)
referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함)
}; };
// 직접 입력 설정 // 직접 입력 설정
@ -132,6 +136,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string;
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
{ value: "text", label: "문자", description: "텍스트 또는 코드" }, { value: "text", label: "문자", description: "텍스트 또는 코드" },
{ value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" }, { value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" },
{ value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" },
]; ];
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [