refactor: Enhance rack structure component with format configuration and segment handling
- Introduced `FormatSegment` and `LocationFormatConfig` types to manage the formatting of location codes and names. - Added `defaultFormatConfig` to provide default segment configurations for location codes and names. - Implemented `buildFormattedString` function to generate formatted strings based on active segments and their configurations. - Updated `RackStructureComponent` to utilize the new formatting logic for generating location codes and names. - Enhanced `RackStructureConfigPanel` to allow users to edit format settings for location codes and names using `FormatSegmentEditor`. These changes improve the flexibility and usability of the rack structure component by allowing dynamic formatting of location identifiers.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { FormatSegment } from "./types";
|
||||
import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config";
|
||||
|
||||
// 개별 세그먼트 행
|
||||
interface SortableSegmentRowProps {
|
||||
segment: FormatSegment;
|
||||
index: number;
|
||||
onChange: (index: number, updates: Partial<FormatSegment>) => void;
|
||||
}
|
||||
|
||||
function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `${segment.type}-${index}` });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 rounded border bg-white px-2 py-1.5",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
<span className="truncate text-xs font-medium">
|
||||
{SEGMENT_TYPE_LABELS[segment.type]}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
checked={segment.showLabel}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(index, { showLabel: checked === true })
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={segment.label}
|
||||
onChange={(e) => onChange(index, { label: e.target.value })}
|
||||
placeholder=""
|
||||
className={cn(
|
||||
"h-6 px-1 text-xs",
|
||||
!segment.showLabel && "text-gray-400 line-through",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={segment.separatorAfter}
|
||||
onChange={(e) => onChange(index, { separatorAfter: e.target.value })}
|
||||
placeholder=""
|
||||
className="h-6 px-1 text-center text-xs"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5}
|
||||
value={segment.pad}
|
||||
onChange={(e) =>
|
||||
onChange(index, { pad: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
disabled={segment.type !== "row" && segment.type !== "level"}
|
||||
className={cn(
|
||||
"h-6 px-1 text-center text-xs",
|
||||
segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FormatSegmentEditor 메인 컴포넌트
|
||||
interface FormatSegmentEditorProps {
|
||||
label: string;
|
||||
segments: FormatSegment[];
|
||||
onChange: (segments: FormatSegment[]) => void;
|
||||
sampleValues?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function FormatSegmentEditor({
|
||||
label,
|
||||
segments,
|
||||
onChange,
|
||||
sampleValues = SAMPLE_VALUES,
|
||||
}: FormatSegmentEditorProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const preview = useMemo(
|
||||
() => buildFormattedString(segments, sampleValues),
|
||||
[segments, sampleValues],
|
||||
);
|
||||
|
||||
const sortableIds = useMemo(
|
||||
() => segments.map((seg, i) => `${seg.type}-${i}`),
|
||||
[segments],
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortableIds.indexOf(active.id as string);
|
||||
const newIndex = sortableIds.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
onChange(arrayMove([...segments], oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const handleSegmentChange = (index: number, updates: Partial<FormatSegment>) => {
|
||||
const updated = segments.map((seg, i) =>
|
||||
i === index ? { ...seg, ...updates } : seg,
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">{label}</div>
|
||||
|
||||
<div className="grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 px-2 text-[10px] text-gray-500">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span>라벨</span>
|
||||
<span className="text-center">구분</span>
|
||||
<span className="text-center">자릿수</span>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{segments.map((segment, index) => (
|
||||
<SortableSegmentRow
|
||||
key={sortableIds[index]}
|
||||
segment={segment}
|
||||
index={index}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5">
|
||||
<span className="text-[10px] text-gray-500">미리보기: </span>
|
||||
<span className="text-xs font-medium text-gray-800">
|
||||
{preview || "(빈 값)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
GeneratedLocation,
|
||||
RackStructureContext,
|
||||
} from "./types";
|
||||
import { defaultFormatConfig, buildFormattedString } from "./config";
|
||||
|
||||
// 기존 위치 데이터 타입
|
||||
interface ExistingLocation {
|
||||
@@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||
return ctx;
|
||||
}, [propContext, formData, fieldMapping, getCategoryLabel]);
|
||||
|
||||
// 필수 필드 검증
|
||||
// 필수 필드 검증 (층은 선택 입력)
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.floor) missing.push("층");
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
@@ -377,9 +377,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
|
||||
useEffect(() => {
|
||||
const loadExistingLocations = async () => {
|
||||
// 필수 조건이 충족되지 않으면 기존 데이터 초기화
|
||||
// DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용
|
||||
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
||||
// 창고 코드와 구역은 필수, 층은 선택
|
||||
if (!warehouseCodeForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
setDuplicateErrors([]);
|
||||
return;
|
||||
@@ -387,14 +386,13 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||
|
||||
setIsCheckingDuplicates(true);
|
||||
try {
|
||||
// warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회
|
||||
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
|
||||
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
|
||||
const searchParams = {
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
floor: { value: floorForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
if (floorForQuery) {
|
||||
searchParams.floor = { value: floorForQuery, operator: "equals" };
|
||||
}
|
||||
|
||||
// 직접 apiClient 사용하여 정확한 형식으로 요청
|
||||
// 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리
|
||||
@@ -493,23 +491,26 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||
return { totalLocations, totalRows, maxLevel };
|
||||
}, [conditions]);
|
||||
|
||||
// 위치 코드 생성
|
||||
// 포맷 설정 (ConfigPanel에서 관리자가 설정한 값, 미설정 시 기본값)
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
// 위치 코드 생성 (세그먼트 기반 - 순서/구분자/라벨/자릿수 모두 formatConfig에 따름)
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor || "1";
|
||||
const zone = context?.zone || "A";
|
||||
const values: Record<string, string> = {
|
||||
warehouseCode: context?.warehouseCode || "WH001",
|
||||
floor: context?.floor || "",
|
||||
zone: context?.zone || "A",
|
||||
row: row.toString(),
|
||||
level: level.toString(),
|
||||
};
|
||||
|
||||
// 코드 생성 (예: WH001-1층D구역-01-1)
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||
const code = buildFormattedString(formatConfig.codeSegments, values);
|
||||
const name = buildFormattedString(formatConfig.nameSegments, values);
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context],
|
||||
[context, formatConfig],
|
||||
);
|
||||
|
||||
// 미리보기 생성
|
||||
@@ -870,7 +871,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
||||
<TableCell>{loc.location_name}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "-"}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
|
||||
<TableCell className="text-center">{loc.row_num.padStart(2, "0")}</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
||||
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
||||
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
||||
|
||||
interface RackStructureConfigPanelProps {
|
||||
config: RackStructureComponentConfig;
|
||||
@@ -69,6 +71,21 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||
|
||||
const fieldMapping = config.fieldMapping || {};
|
||||
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
const handleFormatChange = (
|
||||
key: "codeSegments" | "nameSegments",
|
||||
segments: FormatSegment[],
|
||||
) => {
|
||||
onChange({
|
||||
...config,
|
||||
formatConfig: {
|
||||
...formatConfig,
|
||||
[key]: segments,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필드 매핑 섹션 */}
|
||||
@@ -282,6 +299,29 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 포맷 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
||||
구분자/라벨을 편집할 수 있습니다
|
||||
</p>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치코드 포맷"
|
||||
segments={formatConfig.codeSegments}
|
||||
onChange={(segs) => handleFormatChange("codeSegments", segs)}
|
||||
sampleValues={SAMPLE_VALUES}
|
||||
/>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치명 포맷"
|
||||
segments={formatConfig.nameSegments}
|
||||
onChange={(segs) => handleFormatChange("nameSegments", segs)}
|
||||
sampleValues={SAMPLE_VALUES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,26 +2,107 @@
|
||||
* 렉 구조 컴포넌트 기본 설정
|
||||
*/
|
||||
|
||||
import { RackStructureComponentConfig } from "./types";
|
||||
import {
|
||||
RackStructureComponentConfig,
|
||||
FormatSegment,
|
||||
FormatSegmentType,
|
||||
LocationFormatConfig,
|
||||
} from "./types";
|
||||
|
||||
// 세그먼트 타입별 한글 표시명
|
||||
export const SEGMENT_TYPE_LABELS: Record<FormatSegmentType, string> = {
|
||||
warehouseCode: "창고코드",
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
row: "열",
|
||||
level: "단",
|
||||
};
|
||||
|
||||
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultCodeSegments: FormatSegment[] = [
|
||||
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
|
||||
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultNameSegments: FormatSegment[] = [
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
export const defaultFormatConfig: LocationFormatConfig = {
|
||||
codeSegments: defaultCodeSegments,
|
||||
nameSegments: defaultNameSegments,
|
||||
};
|
||||
|
||||
// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글)
|
||||
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
};
|
||||
|
||||
// 값에서 알려진 한글 접미사를 제거하여 순수 값만 추출
|
||||
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
|
||||
const suffix = KNOWN_SUFFIXES[type];
|
||||
if (suffix && val.endsWith(suffix)) {
|
||||
return val.slice(0, -suffix.length);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// 세그먼트 배열로 포맷된 문자열 생성
|
||||
export function buildFormattedString(
|
||||
segments: FormatSegment[],
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const activeSegments = segments.filter(
|
||||
(seg) => seg.enabled && values[seg.type],
|
||||
);
|
||||
|
||||
return activeSegments
|
||||
.map((seg, idx) => {
|
||||
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
|
||||
let val = stripKnownSuffix(seg.type, values[seg.type]);
|
||||
|
||||
// 2) showLabel이 켜져 있고 label이 있으면 붙임
|
||||
if (seg.showLabel && seg.label) {
|
||||
val += seg.label;
|
||||
}
|
||||
|
||||
if (seg.pad > 0 && !isNaN(Number(val))) {
|
||||
val = val.padStart(seg.pad, "0");
|
||||
}
|
||||
|
||||
if (idx < activeSegments.length - 1) {
|
||||
val += seg.separatorAfter;
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// 미리보기용 샘플 값
|
||||
export const SAMPLE_VALUES: Record<string, string> = {
|
||||
warehouseCode: "WH001",
|
||||
floor: "1층",
|
||||
zone: "A구역",
|
||||
row: "1",
|
||||
level: "1",
|
||||
};
|
||||
|
||||
export const defaultConfig: RackStructureComponentConfig = {
|
||||
// 기본 제한
|
||||
maxConditions: 10,
|
||||
maxRows: 99,
|
||||
maxLevels: 20,
|
||||
|
||||
// 기본 코드 패턴
|
||||
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||
|
||||
// UI 설정
|
||||
showTemplates: true,
|
||||
showPreview: true,
|
||||
showStatistics: true,
|
||||
readonly: false,
|
||||
|
||||
// 초기 조건 없음
|
||||
initialConditions: [],
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,24 @@ export interface FieldMapping {
|
||||
statusField?: string; // 사용 여부로 사용할 폼 필드명
|
||||
}
|
||||
|
||||
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
|
||||
export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
|
||||
|
||||
export interface FormatSegment {
|
||||
type: FormatSegmentType;
|
||||
enabled: boolean; // 이 세그먼트를 포함할지 여부
|
||||
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
|
||||
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
|
||||
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
|
||||
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
|
||||
}
|
||||
|
||||
// 위치코드 + 위치명 포맷 설정
|
||||
export interface LocationFormatConfig {
|
||||
codeSegments: FormatSegment[];
|
||||
nameSegments: FormatSegment[];
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
export interface RackStructureComponentConfig {
|
||||
// 기본 설정
|
||||
@@ -54,8 +72,9 @@ export interface RackStructureComponentConfig {
|
||||
fieldMapping?: FieldMapping;
|
||||
|
||||
// 위치 코드 생성 규칙
|
||||
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||
codePattern?: string; // 코드 패턴 (하위 호환용 유지)
|
||||
namePattern?: string; // 이름 패턴 (하위 호환용 유지)
|
||||
formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정
|
||||
|
||||
// UI 설정
|
||||
showTemplates?: boolean; // 템플릿 기능 표시
|
||||
@@ -93,5 +112,3 @@ export interface RackStructureComponentProps {
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user