- Enhanced the `createMoldSerial` function to automatically generate serial numbers based on defined numbering rules when the serial number is not provided. - Integrated error handling for the automatic numbering process, ensuring robust logging for success and failure cases. - Updated the `NumberingRuleService` to support reference column handling, allowing for dynamic prefix generation based on related data. - Modified the frontend components to accommodate new reference configurations, improving user experience in managing numbering rules. These changes significantly enhance the mold management functionality by automating serial number generation and improving the flexibility of numbering rules.
1196 lines
42 KiB
TypeScript
1196 lines
42 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Check, ChevronsUpDown } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
|
|
interface AutoConfigPanelProps {
|
|
partType: CodePartType;
|
|
config?: any;
|
|
onChange: (config: any) => void;
|
|
isPreview?: boolean;
|
|
tableName?: string;
|
|
}
|
|
|
|
interface TableInfo {
|
|
tableName: string;
|
|
displayName: string;
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
columnName: string;
|
|
displayName: string;
|
|
dataType: string;
|
|
inputType?: string;
|
|
}
|
|
|
|
export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|
partType,
|
|
config = {},
|
|
onChange,
|
|
isPreview = false,
|
|
tableName,
|
|
}) => {
|
|
// 1. 순번 (자동 증가)
|
|
if (partType === "sequence") {
|
|
return (
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">순번 자릿수</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
value={config.sequenceLength || 3}
|
|
onChange={(e) =>
|
|
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 })
|
|
}
|
|
disabled={isPreview}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
예: 3 → 001, 4 → 0001
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">시작 번호</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={config.startFrom || 1}
|
|
onChange={(e) =>
|
|
onChange({ ...config, startFrom: parseInt(e.target.value) || 1 })
|
|
}
|
|
disabled={isPreview}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
순번이 시작될 번호
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 2. 숫자 (고정 자릿수)
|
|
if (partType === "number") {
|
|
return (
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">숫자 자릿수</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
value={config.numberLength || 4}
|
|
onChange={(e) =>
|
|
onChange({ ...config, numberLength: parseInt(e.target.value) || 4 })
|
|
}
|
|
disabled={isPreview}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
예: 4 → 0001, 5 → 00001
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">숫자 값</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={config.numberValue || 0}
|
|
onChange={(e) =>
|
|
onChange({ ...config, numberValue: parseInt(e.target.value) || 0 })
|
|
}
|
|
disabled={isPreview}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
고정으로 사용할 숫자
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 3. 날짜
|
|
if (partType === "date") {
|
|
return (
|
|
<DateConfigPanel
|
|
config={config}
|
|
onChange={onChange}
|
|
isPreview={isPreview}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 4. 문자
|
|
if (partType === "text") {
|
|
return (
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">텍스트 값</Label>
|
|
<Input
|
|
value={config.textValue || ""}
|
|
onChange={(e) => onChange({ ...config, textValue: e.target.value })}
|
|
placeholder="예: PRJ, CODE, PROD"
|
|
disabled={isPreview}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
고정으로 사용할 텍스트 또는 코드
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 5. 카테고리
|
|
if (partType === "category") {
|
|
return (
|
|
<CategoryConfigPanel
|
|
config={config}
|
|
onChange={onChange}
|
|
isPreview={isPreview}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 6. 참조 (마스터-디테일 분번)
|
|
if (partType === "reference") {
|
|
return (
|
|
<ReferenceConfigSection
|
|
config={config}
|
|
onChange={onChange}
|
|
isPreview={isPreview}
|
|
tableName={tableName}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* 날짜 타입 전용 설정 패널
|
|
* - 날짜 형식 선택
|
|
* - 컬럼 값 기준 생성 옵션
|
|
*/
|
|
interface DateConfigPanelProps {
|
|
config?: any;
|
|
onChange: (config: any) => void;
|
|
isPreview?: boolean;
|
|
}
|
|
|
|
const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
|
|
config = {},
|
|
onChange,
|
|
isPreview = false,
|
|
}) => {
|
|
// 테이블 목록
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
|
|
|
// 컬럼 목록
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
|
|
|
|
// 체크박스 상태
|
|
const useColumnValue = config.useColumnValue || false;
|
|
const sourceTableName = config.sourceTableName || "";
|
|
const sourceColumnName = config.sourceColumnName || "";
|
|
|
|
// 테이블 목록 로드
|
|
useEffect(() => {
|
|
if (useColumnValue && tables.length === 0) {
|
|
loadTables();
|
|
}
|
|
}, [useColumnValue]);
|
|
|
|
// 테이블 변경 시 컬럼 로드
|
|
useEffect(() => {
|
|
if (sourceTableName) {
|
|
loadColumns(sourceTableName);
|
|
} else {
|
|
setColumns([]);
|
|
}
|
|
}, [sourceTableName]);
|
|
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
const tableList = response.data.map((t: any) => ({
|
|
tableName: t.tableName || t.table_name,
|
|
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
|
|
}));
|
|
setTables(tableList);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
|
|
const loadColumns = async (tableName: string) => {
|
|
setLoadingColumns(true);
|
|
try {
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
|
if (response.success && response.data) {
|
|
const rawColumns = response.data?.columns || response.data;
|
|
// 날짜 타입 컬럼만 필터링
|
|
const dateColumns = (rawColumns as any[]).filter((col: any) => {
|
|
const inputType = col.inputType || col.input_type || "";
|
|
const dataType = (col.dataType || col.data_type || "").toLowerCase();
|
|
return (
|
|
inputType === "date" ||
|
|
inputType === "datetime" ||
|
|
dataType.includes("date") ||
|
|
dataType.includes("timestamp")
|
|
);
|
|
});
|
|
|
|
setColumns(
|
|
dateColumns.map((col: any) => ({
|
|
columnName: col.columnName || col.column_name,
|
|
displayName: col.displayName || col.column_label || col.columnName || col.column_name,
|
|
dataType: col.dataType || col.data_type || "",
|
|
inputType: col.inputType || col.input_type || "",
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
|
|
// 선택된 테이블/컬럼 라벨
|
|
const selectedTableLabel = useMemo(() => {
|
|
const found = tables.find((t) => t.tableName === sourceTableName);
|
|
return found ? `${found.displayName} (${found.tableName})` : "";
|
|
}, [tables, sourceTableName]);
|
|
|
|
const selectedColumnLabel = useMemo(() => {
|
|
const found = columns.find((c) => c.columnName === sourceColumnName);
|
|
return found ? `${found.displayName} (${found.columnName})` : "";
|
|
}, [columns, sourceColumnName]);
|
|
|
|
return (
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{/* 날짜 형식 선택 */}
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">날짜 형식</Label>
|
|
<Select
|
|
value={config.dateFormat || "YYYYMMDD"}
|
|
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
|
|
disabled={isPreview}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DATE_FORMAT_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
|
{option.label} ({option.example})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
{useColumnValue
|
|
? "선택한 컬럼의 날짜 값이 이 형식으로 변환됩니다"
|
|
: "현재 날짜가 자동으로 입력됩니다"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 컬럼 값 기준 생성 체크박스 */}
|
|
<div className="flex items-start gap-2">
|
|
<Checkbox
|
|
id="useColumnValue"
|
|
checked={useColumnValue}
|
|
onCheckedChange={(checked) => {
|
|
onChange({
|
|
...config,
|
|
useColumnValue: checked,
|
|
// 체크 해제 시 테이블/컬럼 초기화
|
|
...(checked ? {} : { sourceTableName: "", sourceColumnName: "" }),
|
|
});
|
|
}}
|
|
disabled={isPreview}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1">
|
|
<Label
|
|
htmlFor="useColumnValue"
|
|
className="cursor-pointer text-xs font-medium sm:text-sm"
|
|
>
|
|
날짜 컬럼 기준으로 생성
|
|
</Label>
|
|
<p className="text-[10px] text-muted-foreground sm:text-xs">
|
|
폼에 입력된 날짜 값으로 코드를 생성합니다
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 선택 (체크 시 표시) */}
|
|
{useColumnValue && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">테이블</Label>
|
|
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={tableComboboxOpen}
|
|
disabled={isPreview || loadingTables}
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
{loadingTables
|
|
? "로딩 중..."
|
|
: sourceTableName
|
|
? selectedTableLabel
|
|
: "테이블 선택"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs sm:text-sm">
|
|
테이블을 찾을 수 없습니다
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{tables.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.displayName} ${table.tableName}`}
|
|
onSelect={() => {
|
|
onChange({
|
|
...config,
|
|
sourceTableName: table.tableName,
|
|
sourceColumnName: "", // 테이블 변경 시 컬럼 초기화
|
|
});
|
|
setTableComboboxOpen(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
sourceTableName === table.tableName ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.displayName}</span>
|
|
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 컬럼 선택 */}
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">날짜 컬럼</Label>
|
|
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={columnComboboxOpen}
|
|
disabled={isPreview || loadingColumns || !sourceTableName}
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
{loadingColumns
|
|
? "로딩 중..."
|
|
: !sourceTableName
|
|
? "테이블을 먼저 선택하세요"
|
|
: sourceColumnName
|
|
? selectedColumnLabel
|
|
: columns.length === 0
|
|
? "날짜 컬럼이 없습니다"
|
|
: "컬럼 선택"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs sm:text-sm">
|
|
날짜 컬럼을 찾을 수 없습니다
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{columns.map((column) => (
|
|
<CommandItem
|
|
key={column.columnName}
|
|
value={`${column.displayName} ${column.columnName}`}
|
|
onSelect={() => {
|
|
onChange({ ...config, sourceColumnName: column.columnName });
|
|
setColumnComboboxOpen(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
sourceColumnName === column.columnName ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{column.displayName}</span>
|
|
<span className="text-[10px] text-gray-500">
|
|
{column.columnName} ({column.inputType || column.dataType})
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{sourceTableName && columns.length === 0 && !loadingColumns && (
|
|
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
|
|
이 테이블에 날짜 타입 컬럼이 없습니다
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 카테고리 타입 전용 설정 패널
|
|
* - 카테고리 선택 (테이블.컬럼)
|
|
* - 카테고리 값별 형식 매핑
|
|
*/
|
|
import { CategoryFormatMapping } from "@/types/numbering-rule";
|
|
import { Plus, Trash2, FolderTree } from "lucide-react";
|
|
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
|
|
|
|
interface CategoryValueNode {
|
|
valueId: number;
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
depth: number;
|
|
path: string;
|
|
parentValueId: number | null;
|
|
children?: CategoryValueNode[];
|
|
}
|
|
|
|
interface CategoryConfigPanelProps {
|
|
config?: {
|
|
categoryKey?: string;
|
|
categoryMappings?: CategoryFormatMapping[];
|
|
};
|
|
onChange: (config: any) => void;
|
|
isPreview?: boolean;
|
|
}
|
|
|
|
const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|
config = {},
|
|
onChange,
|
|
isPreview = false,
|
|
}) => {
|
|
// 카테고리 옵션 (테이블.컬럼 + 라벨)
|
|
const [categoryOptions, setCategoryOptions] = useState<{
|
|
tableName: string;
|
|
columnName: string;
|
|
displayName: string;
|
|
displayLabel: string; // 라벨 (테이블라벨.컬럼라벨)
|
|
}[]>([]);
|
|
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
|
|
|
|
// 카테고리 값 트리
|
|
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
|
|
const [loadingValues, setLoadingValues] = useState(false);
|
|
|
|
// 계층적 선택 상태 (대분류, 중분류, 소분류)
|
|
const [level1Id, setLevel1Id] = useState<number | null>(null);
|
|
const [level2Id, setLevel2Id] = useState<number | null>(null);
|
|
const [level3Id, setLevel3Id] = useState<number | null>(null);
|
|
const [level1Open, setLevel1Open] = useState(false);
|
|
const [level2Open, setLevel2Open] = useState(false);
|
|
const [level3Open, setLevel3Open] = useState(false);
|
|
|
|
// 형식 입력
|
|
const [newFormat, setNewFormat] = useState("");
|
|
|
|
// 수정 모드
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
|
|
// 수정 모드 진입 중 플래그 (useEffect 초기화 방지)
|
|
const isEditingRef = useRef(false);
|
|
|
|
const categoryKey = config.categoryKey || "";
|
|
const mappings = config.categoryMappings || [];
|
|
|
|
// 이미 추가된 카테고리 ID 목록 (수정 중인 항목 제외)
|
|
const addedValueIds = useMemo(() => {
|
|
return mappings
|
|
.filter(m => m.categoryValueId !== editingId)
|
|
.map(m => m.categoryValueId);
|
|
}, [mappings, editingId]);
|
|
|
|
// 카테고리 옵션 로드
|
|
useEffect(() => {
|
|
loadCategoryOptions();
|
|
}, []);
|
|
|
|
// 카테고리 키 변경 시 값 로드 및 선택 초기화
|
|
useEffect(() => {
|
|
if (categoryKey) {
|
|
const [tableName, columnName] = categoryKey.split(".");
|
|
if (tableName && columnName) {
|
|
loadCategoryValues(tableName, columnName);
|
|
}
|
|
} else {
|
|
setCategoryValues([]);
|
|
}
|
|
// 선택 초기화
|
|
setLevel1Id(null);
|
|
setLevel2Id(null);
|
|
setLevel3Id(null);
|
|
}, [categoryKey]);
|
|
|
|
// 대분류 변경 시 중분류/소분류 초기화 (수정 모드 진입 중에는 건너뜀)
|
|
useEffect(() => {
|
|
if (isEditingRef.current) return;
|
|
setLevel2Id(null);
|
|
setLevel3Id(null);
|
|
}, [level1Id]);
|
|
|
|
// 중분류 변경 시 소분류 초기화 (수정 모드 진입 중에는 건너뜀)
|
|
useEffect(() => {
|
|
if (isEditingRef.current) return;
|
|
setLevel3Id(null);
|
|
}, [level2Id]);
|
|
|
|
const loadCategoryOptions = async () => {
|
|
try {
|
|
const response = await getAllCategoryKeys();
|
|
if (response.success && response.data) {
|
|
const options = response.data.map((item: { tableName: string; columnName: string; tableLabel?: string; columnLabel?: string }) => ({
|
|
tableName: item.tableName,
|
|
columnName: item.columnName,
|
|
displayName: `${item.tableName}.${item.columnName}`,
|
|
displayLabel: `${item.tableLabel || item.tableName}.${item.columnLabel || item.columnName}`,
|
|
}));
|
|
setCategoryOptions(options);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 옵션 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
const loadCategoryValues = async (tableName: string, columnName: string) => {
|
|
console.log("loadCategoryValues 호출:", { tableName, columnName });
|
|
setLoadingValues(true);
|
|
try {
|
|
const response = await getCategoryTree(tableName, columnName);
|
|
console.log("getCategoryTree 응답:", response);
|
|
if (response.success && response.data) {
|
|
console.log("카테고리 트리 로드 성공:", response.data);
|
|
setCategoryValues(response.data);
|
|
} else {
|
|
console.log("카테고리 트리 로드 실패:", response.error);
|
|
setCategoryValues([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 값 로드 실패:", error);
|
|
setCategoryValues([]);
|
|
} finally {
|
|
setLoadingValues(false);
|
|
}
|
|
};
|
|
|
|
// 이미 추가된 항목 확인 (해당 노드 또는 하위 노드가 추가되었는지)
|
|
const isNodeOrDescendantAdded = useCallback((node: CategoryValueNode): boolean => {
|
|
if (addedValueIds.includes(node.valueId)) return true;
|
|
if (node.children?.length) {
|
|
return node.children.every(child => isNodeOrDescendantAdded(child));
|
|
}
|
|
return false;
|
|
}, [addedValueIds]);
|
|
|
|
// 각 레벨별 항목 계산 (이미 추가된 항목 필터링)
|
|
const level1Items = useMemo(() => {
|
|
return categoryValues.filter(v => !isNodeOrDescendantAdded(v));
|
|
}, [categoryValues, isNodeOrDescendantAdded]);
|
|
|
|
const level2Items = useMemo(() => {
|
|
if (!level1Id) return [];
|
|
const parent = categoryValues.find(v => v.valueId === level1Id);
|
|
const children = parent?.children || [];
|
|
return children.filter(v => !isNodeOrDescendantAdded(v));
|
|
}, [categoryValues, level1Id, isNodeOrDescendantAdded]);
|
|
|
|
const level3Items = useMemo(() => {
|
|
if (!level2Id) return [];
|
|
const parent = categoryValues.find(v => v.valueId === level1Id);
|
|
const level2Parent = parent?.children?.find(v => v.valueId === level2Id);
|
|
const children = level2Parent?.children || [];
|
|
return children.filter(v => !addedValueIds.includes(v.valueId));
|
|
}, [categoryValues, level1Id, level2Id, addedValueIds]);
|
|
|
|
// 선택된 값 정보 계산
|
|
const getSelectedInfo = () => {
|
|
// 가장 깊은 레벨의 선택된 값
|
|
const selectedId = level3Id || level2Id || level1Id;
|
|
if (!selectedId) return null;
|
|
|
|
// 선택된 노드 찾기
|
|
const findNode = (nodes: CategoryValueNode[], id: number): CategoryValueNode | null => {
|
|
for (const node of nodes) {
|
|
if (node.valueId === id) return node;
|
|
if (node.children?.length) {
|
|
const found = findNode(node.children, id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const node = findNode(categoryValues, selectedId);
|
|
if (!node) return null;
|
|
|
|
// 경로 생성
|
|
const pathParts: string[] = [];
|
|
const l1 = categoryValues.find(v => v.valueId === level1Id);
|
|
if (l1) pathParts.push(l1.valueLabel);
|
|
if (level2Id) {
|
|
const l2 = level2Items.find(v => v.valueId === level2Id);
|
|
if (l2) pathParts.push(l2.valueLabel);
|
|
}
|
|
if (level3Id) {
|
|
const l3 = level3Items.find(v => v.valueId === level3Id);
|
|
if (l3) pathParts.push(l3.valueLabel);
|
|
}
|
|
|
|
return {
|
|
valueId: selectedId,
|
|
valueCode: node.valueCode, // valueCode 추가 (V2Select 호환)
|
|
valueLabel: node.valueLabel,
|
|
valuePath: pathParts.join(" > "),
|
|
};
|
|
};
|
|
|
|
const selectedInfo = getSelectedInfo();
|
|
|
|
// 매핑 추가/수정
|
|
const handleAddMapping = () => {
|
|
if (!selectedInfo || !newFormat.trim()) return;
|
|
|
|
const newMapping: CategoryFormatMapping = {
|
|
categoryValueId: selectedInfo.valueId,
|
|
categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
|
|
categoryValueLabel: selectedInfo.valueLabel,
|
|
categoryValuePath: selectedInfo.valuePath,
|
|
format: newFormat.trim(),
|
|
};
|
|
|
|
let updatedMappings: CategoryFormatMapping[];
|
|
|
|
if (editingId !== null) {
|
|
// 수정 모드: 기존 항목 교체
|
|
updatedMappings = mappings.map(m =>
|
|
m.categoryValueId === editingId ? newMapping : m
|
|
);
|
|
} else {
|
|
// 추가 모드: 중복 체크
|
|
const exists = mappings.some(m => m.categoryValueId === selectedInfo.valueId);
|
|
if (exists) {
|
|
alert("이미 추가된 카테고리입니다");
|
|
return;
|
|
}
|
|
updatedMappings = [...mappings, newMapping];
|
|
}
|
|
|
|
onChange({
|
|
...config,
|
|
categoryMappings: updatedMappings,
|
|
});
|
|
|
|
// 초기화
|
|
setLevel1Id(null);
|
|
setLevel2Id(null);
|
|
setLevel3Id(null);
|
|
setNewFormat("");
|
|
setEditingId(null);
|
|
};
|
|
|
|
// 매핑 수정 모드 진입
|
|
const handleEditMapping = (mapping: CategoryFormatMapping) => {
|
|
// useEffect 초기화 방지 플래그 설정
|
|
isEditingRef.current = true;
|
|
|
|
// 해당 카테고리의 경로를 파싱해서 레벨별로 설정
|
|
const findParentIds = (nodes: CategoryValueNode[], targetId: number, path: number[] = []): number[] | null => {
|
|
for (const node of nodes) {
|
|
if (node.valueId === targetId) {
|
|
return path;
|
|
}
|
|
if (node.children?.length) {
|
|
const result = findParentIds(node.children, targetId, [...path, node.valueId]);
|
|
if (result) return result;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const parentPath = findParentIds(categoryValues, mapping.categoryValueId);
|
|
|
|
if (parentPath && parentPath.length > 0) {
|
|
setLevel1Id(parentPath[0] || null);
|
|
if (parentPath.length === 2) {
|
|
// 3단계: 대분류 > 중분류 > 소분류
|
|
setLevel2Id(parentPath[1]);
|
|
setLevel3Id(mapping.categoryValueId);
|
|
} else if (parentPath.length === 1) {
|
|
// 2단계: 대분류 > 중분류
|
|
setLevel2Id(mapping.categoryValueId);
|
|
setLevel3Id(null);
|
|
} else {
|
|
setLevel2Id(null);
|
|
setLevel3Id(null);
|
|
}
|
|
} else {
|
|
// 루트 레벨 항목 (1단계)
|
|
setLevel1Id(mapping.categoryValueId);
|
|
setLevel2Id(null);
|
|
setLevel3Id(null);
|
|
}
|
|
|
|
setNewFormat(mapping.format);
|
|
setEditingId(mapping.categoryValueId);
|
|
|
|
// 다음 렌더링 사이클에서 플래그 해제
|
|
setTimeout(() => {
|
|
isEditingRef.current = false;
|
|
}, 0);
|
|
};
|
|
|
|
// 수정 취소
|
|
const handleCancelEdit = () => {
|
|
setLevel1Id(null);
|
|
setLevel2Id(null);
|
|
setLevel3Id(null);
|
|
setNewFormat("");
|
|
setEditingId(null);
|
|
};
|
|
|
|
// 매핑 삭제
|
|
const handleRemoveMapping = (valueId: number) => {
|
|
onChange({
|
|
...config,
|
|
categoryMappings: mappings.filter(m => m.categoryValueId !== valueId),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 카테고리 선택 */}
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">카테고리 구분</Label>
|
|
<Popover open={categoryKeyOpen} onOpenChange={setCategoryKeyOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={categoryKeyOpen}
|
|
disabled={isPreview}
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
<span className="truncate">
|
|
{categoryKey
|
|
? categoryOptions.find(o => o.displayName === categoryKey)?.displayLabel || categoryKey
|
|
: "카테고리 선택"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="카테고리 검색..." className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">카테고리가 없습니다</CommandEmpty>
|
|
<CommandGroup>
|
|
{categoryOptions.map((opt) => (
|
|
<CommandItem
|
|
key={opt.displayName}
|
|
value={opt.displayLabel}
|
|
onSelect={() => {
|
|
onChange({ ...config, categoryKey: opt.displayName, categoryMappings: [] });
|
|
setCategoryKeyOpen(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<Check className={cn("mr-2 h-4 w-4", categoryKey === opt.displayName ? "opacity-100" : "opacity-0")} />
|
|
{opt.displayLabel}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 형식 설정 */}
|
|
{categoryKey && (
|
|
<div className="space-y-3 border-t pt-3">
|
|
<Label className="flex items-center gap-2 text-xs font-medium sm:text-sm">
|
|
<FolderTree className="h-4 w-4" />
|
|
형식 설정
|
|
</Label>
|
|
|
|
{/* 계층적 선택 UI */}
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-2">
|
|
{/* 대분류 선택 */}
|
|
<div className="min-w-[100px] flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">대분류</Label>
|
|
<Popover open={level1Open} onOpenChange={setLevel1Open}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
disabled={isPreview || loadingValues || level1Items.length === 0}
|
|
className="h-8 w-full justify-between text-xs"
|
|
>
|
|
<span className="truncate">
|
|
{loadingValues ? "로딩..." : level1Items.find(v => v.valueId === level1Id)?.valueLabel || "선택"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-48 p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">항목이 없습니다</CommandEmpty>
|
|
<CommandGroup>
|
|
{level1Items.map((val) => (
|
|
<CommandItem
|
|
key={val.valueId}
|
|
value={val.valueLabel}
|
|
onSelect={() => {
|
|
setLevel1Id(val.valueId);
|
|
setLevel1Open(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", level1Id === val.valueId ? "opacity-100" : "opacity-0")} />
|
|
{val.valueLabel}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 중분류 선택 (대분류 선택 후 하위가 있을 때만 표시) */}
|
|
{level1Id && level2Items.length > 0 && (
|
|
<div className="min-w-[100px] flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">중분류</Label>
|
|
<Popover open={level2Open} onOpenChange={setLevel2Open}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
disabled={isPreview}
|
|
className="h-8 w-full justify-between text-xs"
|
|
>
|
|
<span className="truncate">
|
|
{level2Items.find(v => v.valueId === level2Id)?.valueLabel || "선택"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-48 p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">항목이 없습니다</CommandEmpty>
|
|
<CommandGroup>
|
|
{level2Items.map((val) => (
|
|
<CommandItem
|
|
key={val.valueId}
|
|
value={val.valueLabel}
|
|
onSelect={() => {
|
|
setLevel2Id(val.valueId);
|
|
setLevel2Open(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", level2Id === val.valueId ? "opacity-100" : "opacity-0")} />
|
|
{val.valueLabel}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
|
|
{/* 소분류 선택 (중분류 선택 후 하위가 있을 때만 표시) */}
|
|
{level2Id && level3Items.length > 0 && (
|
|
<div className="min-w-[100px] flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">소분류</Label>
|
|
<Popover open={level3Open} onOpenChange={setLevel3Open}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
disabled={isPreview}
|
|
className="h-8 w-full justify-between text-xs"
|
|
>
|
|
<span className="truncate">
|
|
{level3Items.find(v => v.valueId === level3Id)?.valueLabel || "선택"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-48 p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">항목이 없습니다</CommandEmpty>
|
|
<CommandGroup>
|
|
{level3Items.map((val) => (
|
|
<CommandItem
|
|
key={val.valueId}
|
|
value={val.valueLabel}
|
|
onSelect={() => {
|
|
setLevel3Id(val.valueId);
|
|
setLevel3Open(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", level3Id === val.valueId ? "opacity-100" : "opacity-0")} />
|
|
{val.valueLabel}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 형식 입력 + 추가/수정 버튼 */}
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">형식</Label>
|
|
<Input
|
|
value={newFormat}
|
|
onChange={(e) => setNewFormat(e.target.value.toUpperCase())}
|
|
placeholder="예: ITM, VLV, PIP"
|
|
disabled={isPreview || !selectedInfo}
|
|
className="h-8 text-xs"
|
|
maxLength={10}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end gap-1">
|
|
{editingId !== null && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleCancelEdit}
|
|
className="h-8 text-xs"
|
|
>
|
|
취소
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
onClick={handleAddMapping}
|
|
disabled={isPreview || !selectedInfo || !newFormat.trim()}
|
|
className="h-8"
|
|
>
|
|
{editingId !== null ? <Check className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 선택된 경로 표시 */}
|
|
{selectedInfo && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{editingId !== null ? "수정 중: " : "선택: "}{selectedInfo.valuePath}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 추가된 매핑 목록 */}
|
|
{mappings.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">추가된 형식 (클릭하여 수정)</Label>
|
|
<div className="space-y-1">
|
|
{mappings.map((m) => (
|
|
<div
|
|
key={m.categoryValueId}
|
|
className={cn(
|
|
"flex cursor-pointer items-center justify-between rounded px-2 py-1 transition-colors hover:bg-muted",
|
|
editingId === m.categoryValueId ? "bg-primary/10 ring-1 ring-primary" : "bg-muted/50"
|
|
)}
|
|
onClick={() => !isPreview && handleEditMapping(m)}
|
|
>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-muted-foreground">{m.categoryValuePath || m.categoryValueLabel}</span>
|
|
<span>→</span>
|
|
<span className="font-mono font-medium">{m.format}</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveMapping(m.categoryValueId);
|
|
}}
|
|
disabled={isPreview}
|
|
className="h-5 w-5"
|
|
>
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
선택된 카테고리 값에 따라 다른 형식이 생성됩니다
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function ReferenceConfigSection({
|
|
config,
|
|
onChange,
|
|
isPreview,
|
|
tableName,
|
|
}: {
|
|
config: any;
|
|
onChange: (c: any) => void;
|
|
isPreview: boolean;
|
|
tableName?: string;
|
|
}) {
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [loadingCols, setLoadingCols] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!tableName) return;
|
|
setLoadingCols(true);
|
|
|
|
const loadEntityColumns = async () => {
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const response = await apiClient.get(
|
|
`/screen-management/tables/${tableName}/columns`
|
|
);
|
|
const allCols = response.data?.data || response.data || [];
|
|
const entityCols = allCols.filter(
|
|
(c: any) =>
|
|
(c.inputType || c.input_type) === "entity" ||
|
|
(c.inputType || c.input_type) === "numbering"
|
|
);
|
|
setColumns(
|
|
entityCols.map((c: any) => ({
|
|
columnName: c.columnName || c.column_name,
|
|
displayName:
|
|
c.columnLabel || c.column_label || c.columnName || c.column_name,
|
|
dataType: c.dataType || c.data_type || "",
|
|
inputType: c.inputType || c.input_type || "",
|
|
}))
|
|
);
|
|
} catch {
|
|
setColumns([]);
|
|
} finally {
|
|
setLoadingCols(false);
|
|
}
|
|
};
|
|
|
|
loadEntityColumns();
|
|
}, [tableName]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">참조 컬럼</Label>
|
|
<Select
|
|
value={config.referenceColumnName || ""}
|
|
onValueChange={(value) =>
|
|
onChange({ ...config, referenceColumnName: value })
|
|
}
|
|
disabled={isPreview || loadingCols}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue
|
|
placeholder={
|
|
loadingCols
|
|
? "로딩 중..."
|
|
: columns.length === 0
|
|
? "엔티티 컬럼 없음"
|
|
: "컬럼 선택"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{columns.map((col) => (
|
|
<SelectItem
|
|
key={col.columnName}
|
|
value={col.columnName}
|
|
className="text-xs"
|
|
>
|
|
{col.displayName} ({col.columnName})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
마스터 테이블과 연결된 엔티티/채번 컬럼의 값을 코드에 포함합니다
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|