2025-11-04 13:58:21 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
2025-11-04 16:17:19 +09:00
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
2026-03-19 15:07:07 +09:00
|
|
|
import { Plus, Save, Search, Hash, Table2 } from "lucide-react";
|
2025-11-04 13:58:21 +09:00
|
|
|
import { toast } from "sonner";
|
2026-03-03 16:04:11 +09:00
|
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
2025-12-04 13:28:13 +09:00
|
|
|
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
2026-03-17 16:20:24 +09:00
|
|
|
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
2025-11-04 13:58:21 +09:00
|
|
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
2026-03-17 16:20:24 +09:00
|
|
|
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
|
2026-03-19 15:07:07 +09:00
|
|
|
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
|
|
|
|
import { apiClient } from "@/lib/api/client";
|
2026-01-21 17:51:59 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
interface NumberingColumn {
|
|
|
|
|
tableName: string;
|
|
|
|
|
tableLabel: string;
|
|
|
|
|
columnName: string;
|
|
|
|
|
columnLabel: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface GroupedColumns {
|
|
|
|
|
tableLabel: string;
|
|
|
|
|
columns: NumberingColumn[];
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 13:58:21 +09:00
|
|
|
interface NumberingRuleDesignerProps {
|
|
|
|
|
initialConfig?: NumberingRuleConfig;
|
|
|
|
|
onSave?: (config: NumberingRuleConfig) => void;
|
|
|
|
|
onChange?: (config: NumberingRuleConfig) => void;
|
|
|
|
|
maxRules?: number;
|
|
|
|
|
isPreview?: boolean;
|
|
|
|
|
className?: string;
|
2026-03-17 16:20:24 +09:00
|
|
|
currentTableName?: string;
|
|
|
|
|
menuObjid?: number;
|
2025-11-04 13:58:21 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|
|
|
|
initialConfig,
|
|
|
|
|
onSave,
|
|
|
|
|
onChange,
|
|
|
|
|
maxRules = 6,
|
|
|
|
|
isPreview = false,
|
|
|
|
|
className = "",
|
2025-11-07 14:27:07 +09:00
|
|
|
currentTableName,
|
2025-11-11 14:32:00 +09:00
|
|
|
menuObjid,
|
2025-11-04 13:58:21 +09:00
|
|
|
}) => {
|
2026-03-19 15:07:07 +09:00
|
|
|
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
|
|
|
|
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
2025-11-04 13:58:21 +09:00
|
|
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
2026-03-17 16:20:24 +09:00
|
|
|
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
2025-11-04 13:58:21 +09:00
|
|
|
const [loading, setLoading] = useState(false);
|
2026-03-19 15:07:07 +09:00
|
|
|
const [columnSearch, setColumnSearch] = useState("");
|
2026-02-25 12:25:30 +09:00
|
|
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
|
|
|
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
2025-11-04 13:58:21 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-19 15:07:07 +09:00
|
|
|
loadNumberingColumns();
|
2025-11-04 13:58:21 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
const loadNumberingColumns = async () => {
|
2026-03-09 14:10:08 +09:00
|
|
|
setLoading(true);
|
2026-01-21 17:51:59 +09:00
|
|
|
try {
|
2026-03-19 15:07:07 +09:00
|
|
|
const response = await apiClient.get("/table-management/numbering-columns");
|
|
|
|
|
if (response.data.success && response.data.data) {
|
|
|
|
|
setNumberingColumns(response.data.data);
|
2026-01-21 17:51:59 +09:00
|
|
|
}
|
2026-03-19 15:07:07 +09:00
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("채번 컬럼 목록 로드 실패:", error);
|
2026-01-21 17:51:59 +09:00
|
|
|
} finally {
|
2026-03-09 14:10:08 +09:00
|
|
|
setLoading(false);
|
2026-01-21 17:51:59 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
|
|
|
|
setSelectedColumn({ tableName, columnName });
|
2026-03-17 16:20:24 +09:00
|
|
|
setSelectedPartOrder(null);
|
2026-03-19 15:07:07 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-09 14:10:08 +09:00
|
|
|
};
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
// 테이블별 그룹화
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
});
|
2025-11-04 13:58:21 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-17 16:20:24 +09:00
|
|
|
if (currentRule) onChange?.(currentRule);
|
2025-11-04 13:58:21 +09:00
|
|
|
}, [currentRule, onChange]);
|
|
|
|
|
|
2025-12-04 13:28:13 +09:00
|
|
|
useEffect(() => {
|
2026-02-25 12:25:30 +09:00
|
|
|
if (currentRule && currentRule.parts.length > 0) {
|
|
|
|
|
const newSepTypes: Record<number, SeparatorType> = {};
|
|
|
|
|
const newCustomSeps: Record<number, string> = {};
|
|
|
|
|
currentRule.parts.forEach((part) => {
|
|
|
|
|
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
|
|
|
|
if (sep === "") {
|
|
|
|
|
newSepTypes[part.order] = "none";
|
|
|
|
|
newCustomSeps[part.order] = "";
|
|
|
|
|
} else {
|
2026-03-17 16:20:24 +09:00
|
|
|
const opt = SEPARATOR_OPTIONS.find(
|
|
|
|
|
(o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
|
2026-02-25 12:25:30 +09:00
|
|
|
);
|
2026-03-17 16:20:24 +09:00
|
|
|
if (opt) {
|
|
|
|
|
newSepTypes[part.order] = opt.value;
|
2026-02-25 12:25:30 +09:00
|
|
|
newCustomSeps[part.order] = "";
|
|
|
|
|
} else {
|
|
|
|
|
newSepTypes[part.order] = "custom";
|
|
|
|
|
newCustomSeps[part.order] = sep;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setSeparatorTypes(newSepTypes);
|
|
|
|
|
setCustomSeparators(newCustomSeps);
|
2025-12-04 13:28:13 +09:00
|
|
|
}
|
2026-02-25 12:25:30 +09:00
|
|
|
}, [currentRule?.ruleId]);
|
2025-12-04 13:28:13 +09:00
|
|
|
|
2026-02-25 12:25:30 +09:00
|
|
|
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
2026-03-17 16:20:24 +09:00
|
|
|
setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type }));
|
2025-12-04 13:28:13 +09:00
|
|
|
if (type !== "custom") {
|
2026-03-17 16:20:24 +09:00
|
|
|
const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type);
|
2025-12-04 13:28:13 +09:00
|
|
|
const newSeparator = option?.displayValue ?? "";
|
2026-03-17 16:20:24 +09:00
|
|
|
setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" }));
|
2026-02-25 12:25:30 +09:00
|
|
|
setCurrentRule((prev) => {
|
|
|
|
|
if (!prev) return null;
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
2026-03-17 16:20:24 +09:00
|
|
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
|
2026-02-25 12:25:30 +09:00
|
|
|
};
|
|
|
|
|
});
|
2025-12-04 13:28:13 +09:00
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-25 12:25:30 +09:00
|
|
|
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
2025-12-04 13:28:13 +09:00
|
|
|
const trimmedValue = value.slice(0, 2);
|
2026-03-17 16:20:24 +09:00
|
|
|
setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue }));
|
2026-02-25 12:25:30 +09:00
|
|
|
setCurrentRule((prev) => {
|
|
|
|
|
if (!prev) return null;
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
2026-03-17 16:20:24 +09:00
|
|
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
|
2026-02-25 12:25:30 +09:00
|
|
|
};
|
|
|
|
|
});
|
2025-12-04 13:28:13 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2025-11-04 13:58:21 +09:00
|
|
|
const handleAddPart = useCallback(() => {
|
|
|
|
|
if (!currentRule) return;
|
|
|
|
|
if (currentRule.parts.length >= maxRules) {
|
|
|
|
|
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const newPart: NumberingRulePart = {
|
|
|
|
|
id: `part-${Date.now()}`,
|
|
|
|
|
order: currentRule.parts.length + 1,
|
2025-11-04 16:17:19 +09:00
|
|
|
partType: "text",
|
2025-11-04 13:58:21 +09:00
|
|
|
generationMethod: "auto",
|
2025-11-04 16:17:19 +09:00
|
|
|
autoConfig: { textValue: "CODE" },
|
2026-02-25 12:25:30 +09:00
|
|
|
separatorAfter: "-",
|
2025-11-04 13:58:21 +09:00
|
|
|
};
|
2026-03-17 16:20:24 +09:00
|
|
|
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
|
|
|
|
|
setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
|
|
|
|
|
setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
|
2025-11-04 13:58:21 +09:00
|
|
|
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
|
|
|
|
}, [currentRule, maxRules]);
|
|
|
|
|
|
2026-02-05 16:31:39 +09:00
|
|
|
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
|
2025-11-04 13:58:21 +09:00
|
|
|
setCurrentRule((prev) => {
|
|
|
|
|
if (!prev) return null;
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
2026-03-17 16:20:24 +09:00
|
|
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, ...updates } : p)),
|
2025-11-04 13:58:21 +09:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-05 16:31:39 +09:00
|
|
|
const handleDeletePart = useCallback((partOrder: number) => {
|
2025-11-04 13:58:21 +09:00
|
|
|
setCurrentRule((prev) => {
|
|
|
|
|
if (!prev) return null;
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
2026-03-17 16:20:24 +09:00
|
|
|
parts: prev.parts
|
|
|
|
|
.filter((p) => p.order !== partOrder)
|
|
|
|
|
.map((p, i) => ({ ...p, order: i + 1 })),
|
2025-11-04 13:58:21 +09:00
|
|
|
};
|
|
|
|
|
});
|
2026-03-17 16:20:24 +09:00
|
|
|
setSelectedPartOrder(null);
|
2025-11-04 13:58:21 +09:00
|
|
|
toast.success("규칙이 삭제되었습니다");
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSave = useCallback(async () => {
|
|
|
|
|
if (!currentRule) {
|
|
|
|
|
toast.error("저장할 규칙이 없습니다");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (currentRule.parts.length === 0) {
|
|
|
|
|
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2025-12-08 19:10:07 +09:00
|
|
|
const defaultAutoConfigs: Record<string, any> = {
|
|
|
|
|
sequence: { sequenceLength: 3, startFrom: 1 },
|
|
|
|
|
number: { numberLength: 4, numberValue: 1 },
|
|
|
|
|
date: { dateFormat: "YYYYMMDD" },
|
|
|
|
|
text: { textValue: "" },
|
|
|
|
|
};
|
|
|
|
|
const partsWithDefaults = currentRule.parts.map((part) => {
|
|
|
|
|
if (part.generationMethod === "auto") {
|
|
|
|
|
const defaults = defaultAutoConfigs[part.partType] || {};
|
2026-03-17 16:20:24 +09:00
|
|
|
return { ...part, autoConfig: { ...defaults, ...part.autoConfig } };
|
2025-12-08 19:10:07 +09:00
|
|
|
}
|
|
|
|
|
return part;
|
|
|
|
|
});
|
2025-11-07 14:27:07 +09:00
|
|
|
const ruleToSave = {
|
|
|
|
|
...currentRule,
|
2025-12-08 19:10:07 +09:00
|
|
|
parts: partsWithDefaults,
|
2026-03-19 15:07:07 +09:00
|
|
|
scopeType: "table" as const,
|
|
|
|
|
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
|
|
|
|
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
2025-11-07 14:27:07 +09:00
|
|
|
};
|
2026-01-21 17:51:59 +09:00
|
|
|
const response = await saveNumberingRuleToTest(ruleToSave);
|
2025-11-04 13:58:21 +09:00
|
|
|
if (response.success && response.data) {
|
2026-03-19 15:07:07 +09:00
|
|
|
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
|
|
|
|
setCurrentRule(currentData);
|
2025-11-04 13:58:21 +09:00
|
|
|
await onSave?.(response.data);
|
|
|
|
|
toast.success("채번 규칙이 저장되었습니다");
|
|
|
|
|
} else {
|
2026-03-17 16:20:24 +09:00
|
|
|
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
|
|
|
|
|
guidance: "설정을 확인하고 다시 시도해 주세요.",
|
|
|
|
|
});
|
2025-11-04 13:58:21 +09:00
|
|
|
}
|
2026-03-17 16:20:24 +09:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
showErrorToast("채번 규칙 저장에 실패했습니다", error, {
|
|
|
|
|
guidance: "설정을 확인하고 다시 시도해 주세요.",
|
|
|
|
|
});
|
2025-11-04 13:58:21 +09:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
2026-03-19 15:07:07 +09:00
|
|
|
}, [currentRule, onSave, selectedColumn]);
|
2026-03-09 14:10:08 +09:00
|
|
|
|
2026-03-17 16:20:24 +09:00
|
|
|
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
|
|
|
|
|
const globalSep = currentRule?.separator ?? "-";
|
|
|
|
|
const partItems = currentRule ? computePartDisplayItems(currentRule) : [];
|
2026-03-09 14:10:08 +09:00
|
|
|
|
2026-03-17 16:20:24 +09:00
|
|
|
return (
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className={cn("flex h-full", className)}>
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) */}
|
|
|
|
|
<div className="code-nav flex w-[240px] flex-shrink-0 flex-col border-r border-border">
|
|
|
|
|
<div className="code-nav-head flex flex-col gap-2 border-b border-border px-3 py-2.5">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<span className="text-xs font-bold">채번 컬럼 ({numberingColumns.length})</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
value={columnSearch}
|
|
|
|
|
onChange={(e) => setColumnSearch(e.target.value)}
|
|
|
|
|
placeholder="검색..."
|
|
|
|
|
className="h-7 pl-7 text-xs"
|
|
|
|
|
/>
|
2026-03-17 16:20:24 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-nav-list flex-1 overflow-y-auto">
|
2026-03-19 15:07:07 +09:00
|
|
|
{loading && numberingColumns.length === 0 ? (
|
2026-03-17 16:20:24 +09:00
|
|
|
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
|
|
|
|
|
로딩 중...
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
2026-03-19 15:07:07 +09:00
|
|
|
) : filteredGroups.length === 0 ? (
|
|
|
|
|
<div className="flex h-24 flex-col items-center justify-center gap-1 px-3 text-center text-xs text-muted-foreground">
|
|
|
|
|
<Hash className="h-6 w-6" />
|
|
|
|
|
{numberingColumns.length === 0
|
|
|
|
|
? "채번 타입 컬럼이 없습니다"
|
|
|
|
|
: "검색 결과 없음"}
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-19 15:07:07 +09:00
|
|
|
filteredGroups.map(([tableName, group]) => (
|
|
|
|
|
<div key={tableName}>
|
|
|
|
|
<div className="flex items-center gap-1.5 bg-muted/50 px-3 py-1.5">
|
|
|
|
|
<Table2 className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
<span className="truncate text-[10px] font-bold text-muted-foreground">
|
|
|
|
|
{group.tableLabel || tableName}
|
2026-03-17 16:47:12 +09:00
|
|
|
</span>
|
2026-03-19 15:07:07 +09:00
|
|
|
</div>
|
|
|
|
|
{group.columns.map((col) => {
|
|
|
|
|
const isSelected =
|
|
|
|
|
selectedColumn?.tableName === col.tableName &&
|
|
|
|
|
selectedColumn?.columnName === col.columnName;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={`${col.tableName}.${col.columnName}`}
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left transition-colors",
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-l-[3px] border-l-primary bg-primary/5 pl-2.5 font-bold"
|
|
|
|
|
: "pl-5 hover:bg-accent"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
|
|
|
|
>
|
|
|
|
|
<Hash className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="truncate text-xs font-semibold">
|
|
|
|
|
{col.columnLabel || col.columnName}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="truncate text-[9px] text-muted-foreground">
|
|
|
|
|
{col.columnName}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))
|
2025-11-04 13:58:21 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
2025-11-04 13:58:21 +09:00
|
|
|
{!currentRule ? (
|
2026-03-17 16:20:24 +09:00
|
|
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
2026-03-19 15:07:07 +09:00
|
|
|
<Hash className="mb-3 h-10 w-10 text-muted-foreground" />
|
|
|
|
|
<p className="mb-2 text-lg font-medium text-muted-foreground">컬럼을 선택하세요</p>
|
2026-03-17 16:20:24 +09:00
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-03-19 15:07:07 +09:00
|
|
|
좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다
|
2026-03-17 16:20:24 +09:00
|
|
|
</p>
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 헤더: 규칙명 + 적용 대상 표시 */}
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="flex flex-col gap-2 px-6 pt-4">
|
2026-03-19 15:07:07 +09:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<Label className="text-xs font-medium">규칙명</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={currentRule.ruleName}
|
|
|
|
|
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
|
|
|
|
placeholder="예: 프로젝트 코드"
|
|
|
|
|
className="h-9 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{selectedColumn && (
|
|
|
|
|
<div className="flex-shrink-0 pt-4">
|
|
|
|
|
<span className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
{selectedColumn.tableName}.{selectedColumn.columnName}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 미리보기 스트립 */}
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
|
2026-03-17 16:20:24 +09:00
|
|
|
<NumberingRulePreview config={currentRule} variant="strip" />
|
2025-11-06 11:25:59 +09:00
|
|
|
</div>
|
2025-11-04 13:58:21 +09:00
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 파이프라인 영역 */}
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
|
|
|
|
|
<div className="area-label flex items-center gap-1.5">
|
|
|
|
|
<span className="text-xs font-bold">코드 구성</span>
|
|
|
|
|
<span className="cnt text-xs font-medium text-muted-foreground">
|
2025-11-04 13:58:21 +09:00
|
|
|
{currentRule.parts.length}/{maxRules}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
|
2026-03-17 16:20:24 +09:00
|
|
|
{currentRule.parts.length === 0 ? (
|
2026-03-19 15:07:07 +09:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex h-24 min-w-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
|
|
|
|
|
onClick={handleAddPart}
|
|
|
|
|
disabled={isPreview || loading}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-6 w-6" />
|
|
|
|
|
클릭하여 첫 번째 규칙을 추가하세요
|
|
|
|
|
</button>
|
2026-03-17 16:20:24 +09:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{currentRule.parts.map((part, index) => {
|
|
|
|
|
const item = partItems.find((i) => i.order === part.order);
|
|
|
|
|
const sep = part.separatorAfter ?? globalSep;
|
2026-03-19 15:07:07 +09:00
|
|
|
const isPartSelected = selectedPartOrder === part.order;
|
2026-03-17 16:20:24 +09:00
|
|
|
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
|
|
|
|
|
return (
|
|
|
|
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
2026-03-17 16:47:12 +09:00
|
|
|
"pipe-segment min-w-[120px] flex-shrink-0 rounded-[10px] border-2 px-4 py-3 text-center transition-all",
|
2026-03-17 16:20:24 +09:00
|
|
|
part.partType === "date" && "border-warning",
|
|
|
|
|
part.partType === "text" && "border-primary",
|
|
|
|
|
part.partType === "sequence" && "border-primary",
|
|
|
|
|
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
|
2026-03-19 15:07:07 +09:00
|
|
|
isPartSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
2026-02-25 12:25:30 +09:00
|
|
|
)}
|
2026-03-17 16:20:24 +09:00
|
|
|
onClick={() => setSelectedPartOrder(part.order)}
|
|
|
|
|
>
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="seg-type text-[8px] font-bold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
{typeLabel}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={cn("seg-value mt-1 truncate font-mono text-base font-extrabold leading-none", getPartTypeColorClass(part.partType))}>
|
2026-03-17 16:20:24 +09:00
|
|
|
{item?.displayValue ?? "-"}
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
{index < currentRule.parts.length - 1 && (
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="pipe-connector flex w-8 flex-shrink-0 flex-col items-center justify-center gap-0.5">
|
|
|
|
|
<span className="conn-line text-xs font-bold text-muted-foreground">→</span>
|
|
|
|
|
<span className="conn-sep rounded border border-border bg-muted px-1 py-0.5 text-[8px] font-semibold text-muted-foreground">
|
|
|
|
|
{sep || "-"}
|
2026-03-17 16:20:24 +09:00
|
|
|
</span>
|
2026-03-17 16:47:12 +09:00
|
|
|
</div>
|
2026-03-17 16:20:24 +09:00
|
|
|
)}
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-03-17 17:12:54 +09:00
|
|
|
className="pipe-add flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
|
2026-03-17 16:20:24 +09:00
|
|
|
onClick={handleAddPart}
|
|
|
|
|
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
|
|
|
|
aria-label="규칙 추가"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 설정 패널 */}
|
2026-03-17 16:20:24 +09:00
|
|
|
{selectedPart && (
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
|
|
|
|
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
2026-03-17 16:20:24 +09:00
|
|
|
<NumberingRuleCard
|
|
|
|
|
part={selectedPart}
|
|
|
|
|
onUpdate={(updates) => handleUpdatePart(selectedPart.order, updates)}
|
|
|
|
|
onDelete={() => handleDeletePart(selectedPart.order)}
|
|
|
|
|
isPreview={isPreview}
|
|
|
|
|
tableName={currentRule.tableName ?? currentTableName}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{currentRule.parts.some((p) => p.order === selectedPart.order) && (
|
|
|
|
|
<div className="mt-3 flex items-center gap-2">
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">뒤 구분자</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={separatorTypes[selectedPart.order] ?? "-"}
|
|
|
|
|
onValueChange={(v) => handlePartSeparatorChange(selectedPart.order, v as SeparatorType)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 w-24 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{SEPARATOR_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
|
|
|
{opt.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{separatorTypes[selectedPart.order] === "custom" && (
|
|
|
|
|
<Input
|
|
|
|
|
value={customSeparators[selectedPart.order] ?? ""}
|
|
|
|
|
onChange={(e) => handlePartCustomSeparatorChange(selectedPart.order, e.target.value)}
|
|
|
|
|
className="h-7 w-14 text-center text-[10px]"
|
|
|
|
|
placeholder="2자"
|
|
|
|
|
maxLength={2}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 저장 바 */}
|
2026-03-17 16:47:12 +09:00
|
|
|
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
|
2026-03-17 16:20:24 +09:00
|
|
|
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
|
|
|
|
{currentRule.tableName && (
|
|
|
|
|
<span>테이블: {currentRule.tableName}</span>
|
|
|
|
|
)}
|
|
|
|
|
{currentRule.columnName && (
|
|
|
|
|
<span className="ml-2">컬럼: {currentRule.columnName}</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="ml-2">구분자: {globalSep || "-"}</span>
|
|
|
|
|
{currentRule.resetPeriod && currentRule.resetPeriod !== "none" && (
|
|
|
|
|
<span className="ml-2">리셋: {currentRule.resetPeriod}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-04 13:58:21 +09:00
|
|
|
<Button
|
2026-03-17 16:20:24 +09:00
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={isPreview || loading}
|
|
|
|
|
className="h-9 gap-2 text-sm font-medium"
|
2025-11-04 13:58:21 +09:00
|
|
|
>
|
2026-03-17 16:20:24 +09:00
|
|
|
<Save className="h-4 w-4" />
|
2025-11-04 13:58:21 +09:00
|
|
|
{loading ? "저장 중..." : "저장"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|