jskim-node #406

Merged
kjs merged 7 commits from jskim-node into main 2026-03-09 18:05:31 +09:00
7 changed files with 350 additions and 503 deletions
Showing only changes of commit 2b4b7819c5 - Show all commits

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 ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회

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

View File

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

View File

@ -1747,7 +1747,53 @@ class NumberingRuleService {
`;
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) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
@ -1760,7 +1806,6 @@ class NumberingRuleService {
const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = `
SELECT
id,
@ -1779,7 +1824,7 @@ class NumberingRuleService {
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
});

View File

@ -669,38 +669,6 @@ export default function TableManagementPage() {
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
}
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
console.log("🔍 Numbering 저장 체크:", {
inputType: column.inputType,
numberingRuleId: column.numberingRuleId,
hasNumberingRuleId: !!column.numberingRuleId,
});
if (column.inputType === "numbering") {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
// numberingRuleId가 있으면 저장, 없으면 제거
if (column.numberingRuleId) {
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
} else {
// numberingRuleId가 없으면 빈 객체
finalDetailSettings = JSON.stringify(existingSettings);
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
}
}
const columnSetting = {
columnName: column.columnName,
columnLabel: column.displayName,
@ -844,28 +812,6 @@ export default function TableManagementPage() {
// detailSettings 계산
let finalDetailSettings = column.detailSettings || "";
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
if (column.inputType === "numbering" && column.numberingRuleId) {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
columnName: column.columnName,
numberingRuleId: column.numberingRuleId,
finalDetailSettings,
});
}
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
if (column.inputType === "entity" && column.referenceTable) {
let existingSettings: Record<string, unknown> = {};
@ -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 className="pl-4">

View File

@ -1,36 +1,30 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
saveNumberingRuleToTest,
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 { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils";
// 카테고리 값 트리 노드 타입
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
}
interface NumberingRuleDesignerProps {
@ -54,138 +48,100 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false);
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
const [columnSearch, setColumnSearch] = useState("");
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
tableName: string;
columnName: string;
displayName: string; // "테이블명.컬럼명" 형식
}
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
// 좌측: 채번 타입 컬럼 목록 로드
useEffect(() => {
loadRules();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
loadNumberingColumns();
}, []);
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
useEffect(() => {
if (currentRule?.categoryColumn) {
setSelectedCategoryKey(currentRule.categoryColumn);
} else {
setSelectedCategoryKey("");
}
}, [currentRule?.categoryColumn]);
// 카테고리 키 선택 시 해당 카테고리 값 로드
useEffect(() => {
if (selectedCategoryKey) {
const [tableName, columnName] = selectedCategoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
}, [selectedCategoryKey]);
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
}));
setAllCategoryOptions(options);
console.log("전체 카테고리 옵션 로드:", options);
}
} catch (error) {
console.error("카테고리 옵션 목록 조회 실패:", error);
}
};
// 특정 카테고리 컬럼의 값 트리 조회
const loadCategoryValues = async (tableName: string, columnName: string) => {
setLoadingCategories(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
setCategoryValues(response.data);
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
} else {
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 트리 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategories(false);
}
};
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
flattenCategoryValues(node.children, result);
}
}
return result;
};
const flatCategoryValues = flattenCategoryValues(categoryValues);
const loadRules = useCallback(async () => {
const loadNumberingColumns = async () => {
setLoading(true);
try {
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
menuObjid,
hasMenuObjid: !!menuObjid,
});
// test 테이블에서 조회
const response = await getNumberingRulesFromTest(menuObjid);
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
menuObjid,
success: response.success,
rulesCount: response.data?.length || 0,
rules: response.data,
});
if (response.success && response.data) {
setSavedRules(response.data);
} else {
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
}
} catch (error: any) {
toast.error(`로딩 실패: ${error.message}`);
console.error("채번 컬럼 목록 로드 실패:", error);
} finally {
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(() => {
if (currentRule) {
@ -343,60 +299,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return part;
});
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
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)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
setSavedRules((prev) => {
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
console.log("🔍 [handleSave] setSavedRules:", {
ruleId: ruleToSave.ruleId,
existsInPrev,
prevCount: prev.length,
});
if (existsInPrev) {
// 기존 규칙 업데이트
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
} else {
// 새 규칙 추가
return [...prev, savedData];
}
});
setCurrentRule(currentData);
setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
@ -407,143 +323,62 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally {
setLoading(false);
}
}, [currentRule, onSave, currentTableName, menuObjid]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
console.log("🔍 [handleSelectRule] 규칙 선택:", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
partsCount: rule.parts?.length || 0,
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setSelectedRuleId(rule.ruleId);
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
ruleId: ruleCopy.ruleId,
partsCount: ruleCopy.parts?.length || 0,
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setCurrentRule(ruleCopy);
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
}, []);
const handleDeleteSavedRule = useCallback(
async (ruleId: string) => {
setLoading(true);
try {
const response = await deleteNumberingRuleFromTest(ruleId);
if (response.success) {
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
if (selectedRuleId === ruleId) {
setSelectedRuleId(null);
setCurrentRule(null);
}
toast.success("규칙이 삭제되었습니다");
} else {
showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
}
} catch (error: any) {
showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally {
setLoading(false);
}
},
[selectedRuleId],
);
const handleNewRule = useCallback(() => {
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
console.log("📋 생성된 규칙 정보:", newRule);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, [currentTableName, menuObjid]);
}, [currentRule, onSave, selectedColumn]);
return (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 저장된 규칙 목록 */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
<div className="flex items-center justify-between">
{editingLeftTitle ? (
<Input
value={leftTitle}
onChange={(e) => setLeftTitle(e.target.value)}
onBlur={() => setEditingLeftTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
</div>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
<h2 className="text-sm font-semibold sm:text-base"> </h2>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex-1 space-y-1 overflow-y-auto">
{loading && numberingColumns.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p>
</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">
<p className="text-muted-foreground text-xs"> </p>
<p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
</div>
) : (
savedRules.map((rule) => (
<Card
key={rule.ruleId}
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
}`}
onClick={() => handleSelectRule(rule)}
>
<CardHeader className="px-3 py-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteSavedRule(rule.ruleId);
}}
filteredGroups.map(([tableName, group]) => (
<div key={tableName} className="mb-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
<FolderTree className="h-3 w-3" />
<span>{group.tableLabel}</span>
<span className="text-muted-foreground/60">({group.columns.length})</span>
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<div
key={`${col.tableName}.${col.columnName}`}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
isSelected
? "bg-primary/10 text-primary border-primary border font-medium"
: "hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
>
<Trash2 className="text-destructive h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
{col.columnLabel}
</div>
);
})}
</div>
))
)}
</div>
@ -557,8 +392,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (