diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index a8765d18..2888a1f3 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -14,6 +14,35 @@ interface NumberingRulePart { autoConfig?: any; manualConfig?: any; generatedValue?: string; + separatorAfter?: string; +} + +/** + * 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출 + */ +function extractSeparatorAfterFromParts(parts: any[]): any[] { + return parts.map((part) => { + if (part.autoConfig?.separatorAfter !== undefined) { + part.separatorAfter = part.autoConfig.separatorAfter; + } + return part; + }); +} + +/** + * 파트별 개별 구분자를 사용하여 코드 결합 + * 마지막 파트의 separatorAfter는 무시됨 + */ +function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; + result += sep; + } + }); + return result; } interface NumberingRuleConfig { @@ -141,7 +170,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { @@ -274,7 +303,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; @@ -381,7 +410,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, @@ -517,7 +546,7 @@ class NumberingRuleService { companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, { @@ -633,7 +662,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); return rule; } @@ -708,17 +737,25 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + // auto_config에 separatorAfter 포함 + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + // autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동 + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } await client.query("COMMIT"); @@ -820,17 +857,23 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } } @@ -1053,7 +1096,8 @@ class NumberingRuleService { } })); - const previewCode = parts.join(rule.separator || ""); + const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, @@ -1164,8 +1208,8 @@ class NumberingRuleService { } })); - const separator = rule.separator || ""; - const previewTemplate = previewParts.join(separator); + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 @@ -1382,7 +1426,8 @@ class NumberingRuleService { } })); - const allocatedCode = parts.join(rule.separator || ""); + const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); + const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); // 순번이 있는 경우에만 증가 const hasSequence = rule.parts.some( @@ -1541,7 +1586,7 @@ class NumberingRuleService { rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info("[테스트] 채번 규칙 목록 조회 완료", { @@ -1634,7 +1679,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { ruleId: rule.ruleId, @@ -1754,12 +1799,14 @@ class NumberingRuleService { auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); @@ -1914,7 +1961,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("카테고리 조건 매칭 채번 규칙 찾음", { ruleId: rule.ruleId, @@ -1973,7 +2020,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { ruleId: rule.ruleId, @@ -2056,7 +2103,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index c19c2631..fc83165a 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1607,7 +1607,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator ); case "entity": @@ -1620,7 +1621,14 @@ export class TableManagementService { ); default: - // 기본 문자열 검색 (actualValue 사용) + // operator에 따라 정확 일치 또는 부분 일치 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(actualValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${actualValue}%`], @@ -1634,10 +1642,19 @@ export class TableManagementService { ); // 오류 시 기본 검색으로 폴백 let fallbackValue = value; + let fallbackOperator = "contains"; if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; + fallbackOperator = value.operator || "contains"; } + if (fallbackOperator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(fallbackValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${fallbackValue}%`], @@ -1784,7 +1801,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" ): Promise<{ whereClause: string; values: any[]; @@ -1794,7 +1812,14 @@ export class TableManagementService { const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { - // 코드 타입이 아니면 기본 검색 + // 코드 타입이 아니면 operator에 따라 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${value}%`], @@ -1802,6 +1827,15 @@ export class TableManagementService { }; } + // select 필터(equals)인 경우 정확한 코드값 매칭만 수행 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } + if (typeof value === "string" && value.trim() !== "") { // 코드값 또는 코드명으로 검색 return { diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index d1444d4e..e9731017 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC = ({ isPreview = false, }) => { return ( - +
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 9320f00e..8b521fe0 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC = ({ const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); - // 구분자 관련 상태 - const [separatorType, setSeparatorType] = useState("-"); - const [customSeparator, setCustomSeparator] = useState(""); + // 구분자 관련 상태 (개별 파트 사이 구분자) + const [separatorTypes, setSeparatorTypes] = useState>({}); + const [customSeparators, setCustomSeparators] = useState>({}); // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 interface CategoryOption { @@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); - // currentRule이 변경될 때 구분자 상태 동기화 + // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { - if (currentRule) { - const sep = currentRule.separator ?? "-"; - // 빈 문자열이면 "none" - if (sep === "") { - setSeparatorType("none"); - setCustomSeparator(""); - return; - } - // 미리 정의된 구분자인지 확인 (none, custom 제외) - const predefinedOption = SEPARATOR_OPTIONS.find( - opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep - ); - if (predefinedOption) { - setSeparatorType(predefinedOption.value); - setCustomSeparator(""); - } else { - // 직접 입력된 구분자 - setSeparatorType("custom"); - setCustomSeparator(sep); - } + if (currentRule && currentRule.parts.length > 0) { + const newSepTypes: Record = {}; + const newCustomSeps: Record = {}; + + currentRule.parts.forEach((part) => { + const sep = part.separatorAfter ?? currentRule.separator ?? "-"; + if (sep === "") { + newSepTypes[part.order] = "none"; + newCustomSeps[part.order] = ""; + } else { + const predefinedOption = SEPARATOR_OPTIONS.find( + opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + ); + if (predefinedOption) { + newSepTypes[part.order] = predefinedOption.value; + newCustomSeps[part.order] = ""; + } else { + newSepTypes[part.order] = "custom"; + newCustomSeps[part.order] = sep; + } + } + }); + + setSeparatorTypes(newSepTypes); + setCustomSeparators(newCustomSeps); } - }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + }, [currentRule?.ruleId]); - // 구분자 변경 핸들러 - const handleSeparatorChange = useCallback((type: SeparatorType) => { - setSeparatorType(type); + // 개별 파트 구분자 변경 핸들러 + const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { + setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); if (type !== "custom") { const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const newSeparator = option?.displayValue ?? ""; - setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); - setCustomSeparator(""); + setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part + ), + }; + }); } }, []); - // 직접 입력 구분자 변경 핸들러 - const handleCustomSeparatorChange = useCallback((value: string) => { - // 최대 2자 제한 + // 개별 파트 직접 입력 구분자 변경 핸들러 + const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); - setCustomSeparator(trimmedValue); - setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part + ), + }; + }); }, []); const handleAddPart = useCallback(() => { @@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC = ({ partType: "text", generationMethod: "auto", autoConfig: { textValue: "CODE" }, + separatorAfter: "-", }; setCurrentRule((prev) => { @@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC = ({ return { ...prev, parts: [...prev.parts, newPart] }; }); + // 새 파트의 구분자 상태 초기화 + setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); + setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); @@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 구분자 설정 */} -
-
- - -
- {separatorType === "custom" && ( -
- - handleCustomSeparatorChange(e.target.value)} - className="h-9" - placeholder="최대 2자" - maxLength={2} - /> -
- )} -

- 규칙 사이에 들어갈 문자입니다 -

-
@@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC = ({

규칙을 추가하여 코드를 구성하세요

) : ( -
+
{currentRule.parts.map((part, index) => ( - handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - /> + +
+ handleUpdatePart(part.order, updates)} + onDelete={() => handleDeletePart(part.order)} + isPreview={isPreview} + /> + {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} + {index < currentRule.parts.length - 1 && ( +
+ 뒤 구분자 + + {separatorTypes[part.order] === "custom" && ( + handlePartCustomSeparatorChange(part.order, e.target.value)} + className="h-6 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+
))}
)} diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index a9179959..eff551a1 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC = ({ return "규칙을 추가해주세요"; } - const parts = config.parts - .sort((a, b) => a.order - b.order) - .map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; + const sortedParts = config.parts.sort((a, b) => a.order - b.order); + + const partValues = sortedParts.map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - // 1. 순번 (자동 증가) - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - - // 2. 숫자 (고정 자릿수) - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - - // 3. 날짜 - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - - // 컬럼 기준 생성인 경우 placeholder 표시 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - // 형식에 맞는 placeholder 반환 - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - - // 현재 날짜 기준 생성 - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; + case "YYYY": return "[YYYY]"; + case "YY": return "[YY]"; + case "YYYYMM": return "[YYYYMM]"; + case "YYMM": return "[YYMM]"; + case "YYYYMMDD": return "[YYYYMMDD]"; + case "YYMMDD": return "[YYMMDD]"; + default: return "[DATE]"; } } - - // 4. 문자 - case "text": - return autoConfig.textValue || "TEXT"; - - default: - return "XXX"; + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } } - }); + case "text": + return autoConfig.textValue || "TEXT"; + default: + return "XXX"; + } + }); - return parts.join(config.separator || ""); + // 파트별 개별 구분자로 결합 + const globalSep = config.separator ?? "-"; + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? globalSep; + result += sep; + } + }); + return result; }, [config]); if (compact) { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index d0f9d5aa..20f37f8f 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -940,23 +940,35 @@ export const TableListComponent: React.FC = ({ } } - // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 + // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback + } + + // fallback: 현재 로드된 데이터에서 고유 값 추출 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValuesMap = new Map(); // value -> label + const uniqueValuesMap = new Map(); data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); - // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 59cb47fa..1f922188 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1015,23 +1015,35 @@ export const TableListComponent: React.FC = ({ } } - // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 + // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch (error: any) { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback + } + + // fallback: 현재 로드된 데이터에서 고유 값 추출 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValuesMap = new Map(); // value -> label + const uniqueValuesMap = new Map(); data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); - // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 8d35f119..b56d563c 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2054,11 +2054,11 @@ export class ButtonActionExecutor { const { tableName, screenId } = context; // 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음) + // initializeForm에서 __tableSection_ (더블), 수정 시 _tableSection_ (싱글) 사용 const universalFormModalKey = Object.keys(formData).find((key) => { const value = formData[key]; if (!value || typeof value !== "object" || Array.isArray(value)) return false; - // _tableSection_ 키가 있는지 확인 - return Object.keys(value).some((k) => k.startsWith("_tableSection_")); + return Object.keys(value).some((k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_")); }); if (!universalFormModalKey) { @@ -2117,11 +2117,18 @@ export class ButtonActionExecutor { const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; for (const [key, value] of Object.entries(modalData)) { - if (key.startsWith("_tableSection_")) { - const sectionId = key.replace("_tableSection_", ""); - tableSectionData[sectionId] = value as any[]; + // initializeForm: __tableSection_ (더블), 수정 시: _tableSection_ (싱글) → 통일 처리 + if (key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) { + if (Array.isArray(value)) { + const normalizedKey = key.startsWith("__tableSection_") + ? key.replace("__tableSection_", "") + : key.replace("_tableSection_", ""); + // 싱글 언더스코어 키(수정된 데이터)가 더블 언더스코어 키(초기 데이터)보다 우선 + if (!tableSectionData[normalizedKey] || key.startsWith("_tableSection_")) { + tableSectionData[normalizedKey] = value as any[]; + } + } } else if (!key.startsWith("_")) { - // _로 시작하지 않는 필드는 공통 필드로 처리 commonFieldsData[key] = value; } } @@ -2306,10 +2313,11 @@ export class ButtonActionExecutor { // originalGroupedData 전달이 누락된 경우를 처리 console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`); - // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 - // item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록 - // 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터) - const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo }; + // 마스터/디테일 테이블 분리 시: commonFieldsData 병합하지 않음 + // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 + const rowToUpdate = hasSeparateTargetTable + ? { ...item, ...userInfo } + : { ...commonFieldsData, ...item, ...userInfo }; Object.keys(rowToUpdate).forEach((key) => { if (key.startsWith("_")) { delete rowToUpdate[key]; @@ -2330,17 +2338,20 @@ export class ButtonActionExecutor { continue; } - // 변경 사항 확인 (공통 필드 포함) - // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀) - const currentDataWithCommon = { ...item, ...commonFieldsData }; - const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); + // 변경 사항 확인 + // 마스터/디테일 테이블이 분리된 경우(hasSeparateTargetTable): + // 마스터 필드(commonFieldsData)를 디테일에 병합하지 않음 + // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 + // 같은 테이블인 경우: 공통 필드 병합 유지 (공유 필드 업데이트 필요) + const dataForComparison = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item }; + const hasChanges = this.checkForChanges(originalItem, dataForComparison); if (hasChanges) { // 변경된 필드만 추출하여 부분 업데이트 const updateResult = await DynamicFormApi.updateFormDataPartial( item.id, originalItem, - currentDataWithCommon, + dataForComparison, saveTableName, ); diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 49264541..3b14a6bc 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -52,6 +52,9 @@ export interface NumberingRulePart { partType: CodePartType; // 파트 유형 generationMethod: GenerationMethod; // 생성 방식 + // 이 파트 뒤에 붙을 구분자 (마지막 파트는 무시됨) + separatorAfter?: string; + // 자동 생성 설정 autoConfig?: { // 순번용