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.
This commit is contained in:
kjs 2026-03-09 14:10:08 +09:00
parent f6a02b5182
commit 2b4b7819c5
7 changed files with 350 additions and 503 deletions

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,32 +502,58 @@ 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") {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) { // 2. numbering_rules에서 table_name + column_name으로 규칙 조회
return { numberingRuleId: settings.numberingRuleId }; 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"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
} }
} }
@ -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

@ -1747,7 +1747,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 +1806,6 @@ class NumberingRuleService {
const rule = result.rows[0]; const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = ` const partsQuery = `
SELECT SELECT
id, id,
@ -1779,7 +1824,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

@ -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

@ -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
value={leftTitle}
onChange={(e) => setLeftTitle(e.target.value)}
onBlur={() => setEditingLeftTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm"> <Input
<Plus className="mr-2 h-4 w-4" /> value={columnSearch}
</Button> onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<div className="flex-1 space-y-2 overflow-y-auto"> <div className="flex-1 space-y-1 overflow-y-auto">
{loading ? ( {loading && numberingColumns.length === 0 ? (
<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)} </div>
> {group.columns.map((col) => {
<CardHeader className="px-3 py-0"> const isSelected =
<div className="flex items-start justify-between"> selectedColumn?.tableName === col.tableName &&
<div className="flex-1"> selectedColumn?.columnName === col.columnName;
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle> return (
</div> <div
<Button key={`${col.tableName}.${col.columnName}`}
variant="ghost" className={cn(
size="icon" "cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
className="h-6 w-6" isSelected
onClick={(e) => { ? "bg-primary/10 text-primary border-primary border font-medium"
e.stopPropagation(); : "hover:bg-accent"
handleDeleteSavedRule(rule.ruleId); )}
}} onClick={() => handleSelectColumn(col.tableName, col.columnName)}
> >
<Trash2 className="text-destructive h-3 w-3" /> {col.columnLabel}
</Button> </div>
</div> );
</CardHeader> })}
</Card> </div>
)) ))
)} )}
</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>
) : ( ) : (