2025-11-04 13:58:21 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-01-21 17:51:59 +09:00
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
2025-11-04 13:58:21 +09:00
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
2026-01-19 18:21:30 +09:00
|
|
|
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";
|
2025-11-04 13:58:21 +09:00
|
|
|
import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule";
|
2026-01-19 18:21:30 +09:00
|
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
2025-11-04 13:58:21 +09:00
|
|
|
|
|
|
|
|
interface AutoConfigPanelProps {
|
|
|
|
|
partType: CodePartType;
|
|
|
|
|
config?: any;
|
|
|
|
|
onChange: (config: any) => void;
|
|
|
|
|
isPreview?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 18:21:30 +09:00
|
|
|
interface TableInfo {
|
|
|
|
|
tableName: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ColumnInfo {
|
|
|
|
|
columnName: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
dataType: string;
|
|
|
|
|
inputType?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 13:58:21 +09:00
|
|
|
export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|
|
|
|
partType,
|
|
|
|
|
config = {},
|
|
|
|
|
onChange,
|
|
|
|
|
isPreview = false,
|
|
|
|
|
}) => {
|
2025-11-04 16:17:19 +09:00
|
|
|
// 1. 순번 (자동 증가)
|
2025-11-04 13:58:21 +09:00
|
|
|
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}
|
2025-11-04 16:17:19 +09:00
|
|
|
value={config.sequenceLength || 3}
|
2025-11-04 13:58:21 +09:00
|
|
|
onChange={(e) =>
|
2025-11-04 16:17:19 +09:00
|
|
|
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 })
|
2025-11-04 13:58:21 +09:00
|
|
|
}
|
|
|
|
|
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">
|
2025-11-04 16:17:19 +09:00
|
|
|
예: 3 → 001, 4 → 0001
|
2025-11-04 13:58:21 +09:00
|
|
|
</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"
|
|
|
|
|
/>
|
2025-11-04 16:17:19 +09:00
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
|
|
|
순번이 시작될 번호
|
|
|
|
|
</p>
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 16:17:19 +09:00
|
|
|
// 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. 날짜
|
2025-11-04 13:58:21 +09:00
|
|
|
if (partType === "date") {
|
2026-01-19 18:21:30 +09:00
|
|
|
return (
|
|
|
|
|
<DateConfigPanel
|
|
|
|
|
config={config}
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
isPreview={isPreview}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. 문자
|
|
|
|
|
if (partType === "text") {
|
2025-11-04 13:58:21 +09:00
|
|
|
return (
|
2026-01-19 18:21:30 +09:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 17:51:59 +09:00
|
|
|
// 5. 카테고리
|
|
|
|
|
if (partType === "category") {
|
|
|
|
|
return (
|
|
|
|
|
<CategoryConfigPanel
|
|
|
|
|
config={config}
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
isPreview={isPreview}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 18:21:30 +09:00
|
|
|
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">
|
|
|
|
|
{/* 날짜 형식 선택 */}
|
2025-11-04 13:58:21 +09:00
|
|
|
<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>
|
2025-11-04 16:17:19 +09:00
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
2026-01-19 18:21:30 +09:00
|
|
|
{useColumnValue
|
|
|
|
|
? "선택한 컬럼의 날짜 값이 이 형식으로 변환됩니다"
|
|
|
|
|
: "현재 날짜가 자동으로 입력됩니다"}
|
2025-11-04 13:58:21 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-19 18:21:30 +09:00
|
|
|
{/* 컬럼 값 기준 생성 체크박스 */}
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<Checkbox
|
|
|
|
|
id="useColumnValue"
|
|
|
|
|
checked={useColumnValue}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
onChange({
|
|
|
|
|
...config,
|
|
|
|
|
useColumnValue: checked,
|
|
|
|
|
// 체크 해제 시 테이블/컬럼 초기화
|
|
|
|
|
...(checked ? {} : { sourceTableName: "", sourceColumnName: "" }),
|
|
|
|
|
});
|
|
|
|
|
}}
|
2025-11-04 13:58:21 +09:00
|
|
|
disabled={isPreview}
|
2026-01-19 18:21:30 +09:00
|
|
|
className="mt-0.5"
|
2025-11-04 13:58:21 +09:00
|
|
|
/>
|
2026-01-19 18:21:30 +09:00
|
|
|
<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>
|
2025-11-04 13:58:21 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-19 18:21:30 +09:00
|
|
|
{/* 테이블 선택 (체크 시 표시) */}
|
|
|
|
|
{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>
|
|
|
|
|
);
|
2025-11-04 13:58:21 +09:00
|
|
|
};
|
2026-01-21 17:51:59 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 카테고리 타입 전용 설정 패널
|
|
|
|
|
* - 카테고리 선택 (테이블.컬럼)
|
|
|
|
|
* - 카테고리 값별 형식 매핑
|
|
|
|
|
*/
|
|
|
|
|
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,
|
2026-02-06 17:10:24 +09:00
|
|
|
valueCode: node.valueCode, // valueCode 추가 (V2Select 호환)
|
2026-01-21 17:51:59 +09:00
|
|
|
valueLabel: node.valueLabel,
|
|
|
|
|
valuePath: pathParts.join(" > "),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectedInfo = getSelectedInfo();
|
|
|
|
|
|
|
|
|
|
// 매핑 추가/수정
|
|
|
|
|
const handleAddMapping = () => {
|
|
|
|
|
if (!selectedInfo || !newFormat.trim()) return;
|
|
|
|
|
|
|
|
|
|
const newMapping: CategoryFormatMapping = {
|
|
|
|
|
categoryValueId: selectedInfo.valueId,
|
2026-02-06 17:10:24 +09:00
|
|
|
categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
|
2026-01-21 17:51:59 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|