551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
interface LocationOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface DataSourceConfig {
|
|
type: "table" | "code" | "static";
|
|
tableName?: string;
|
|
valueField?: string;
|
|
labelField?: string;
|
|
codeCategory?: string;
|
|
staticOptions?: LocationOption[];
|
|
}
|
|
|
|
export interface LocationSwapSelectorProps {
|
|
// 기본 props
|
|
id?: string;
|
|
style?: React.CSSProperties;
|
|
isDesignMode?: boolean;
|
|
|
|
// 데이터 소스 설정
|
|
dataSource?: DataSourceConfig;
|
|
|
|
// 필드 매핑
|
|
departureField?: string;
|
|
destinationField?: string;
|
|
departureLabelField?: string;
|
|
destinationLabelField?: string;
|
|
|
|
// UI 설정
|
|
departureLabel?: string;
|
|
destinationLabel?: string;
|
|
showSwapButton?: boolean;
|
|
swapButtonPosition?: "center" | "right";
|
|
variant?: "card" | "inline" | "minimal";
|
|
|
|
// 폼 데이터
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (field: string, value: any) => void;
|
|
|
|
// componentConfig (화면 디자이너에서 전달)
|
|
componentConfig?: {
|
|
dataSource?: DataSourceConfig;
|
|
departureField?: string;
|
|
destinationField?: string;
|
|
departureLabelField?: string;
|
|
destinationLabelField?: string;
|
|
departureLabel?: string;
|
|
destinationLabel?: string;
|
|
showSwapButton?: boolean;
|
|
swapButtonPosition?: "center" | "right";
|
|
variant?: "card" | "inline" | "minimal";
|
|
};
|
|
}
|
|
|
|
/**
|
|
* LocationSwapSelector 컴포넌트
|
|
* 출발지/도착지 선택 및 교환 기능
|
|
*/
|
|
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
|
|
const {
|
|
id,
|
|
style,
|
|
isDesignMode = false,
|
|
formData = {},
|
|
onFormDataChange,
|
|
componentConfig,
|
|
} = props;
|
|
|
|
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
|
const config = componentConfig || {};
|
|
const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] };
|
|
const departureField = config.departureField || props.departureField || "departure";
|
|
const destinationField = config.destinationField || props.destinationField || "destination";
|
|
const departureLabelField = config.departureLabelField || props.departureLabelField;
|
|
const destinationLabelField = config.destinationLabelField || props.destinationLabelField;
|
|
const departureLabel = config.departureLabel || props.departureLabel || "출발지";
|
|
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
|
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
|
const variant = config.variant || props.variant || "card";
|
|
|
|
// 기본 옵션 (포항/광양)
|
|
const DEFAULT_OPTIONS: LocationOption[] = [
|
|
{ value: "pohang", label: "포항" },
|
|
{ value: "gwangyang", label: "광양" },
|
|
];
|
|
|
|
// 상태
|
|
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isSwapping, setIsSwapping] = useState(false);
|
|
|
|
// 로컬 선택 상태 (Select 컴포넌트용)
|
|
const [localDeparture, setLocalDeparture] = useState<string>("");
|
|
const [localDestination, setLocalDestination] = useState<string>("");
|
|
|
|
// 옵션 로드
|
|
useEffect(() => {
|
|
const loadOptions = async () => {
|
|
console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
|
|
|
|
// 정적 옵션 처리 (기본값)
|
|
// type이 없거나 static이거나, table인데 tableName이 없는 경우
|
|
const shouldUseStatic =
|
|
!dataSource.type ||
|
|
dataSource.type === "static" ||
|
|
(dataSource.type === "table" && !dataSource.tableName) ||
|
|
(dataSource.type === "code" && !dataSource.codeCategory);
|
|
|
|
if (shouldUseStatic) {
|
|
const staticOpts = dataSource.staticOptions || [];
|
|
// 정적 옵션이 설정되어 있고, value가 유효한 경우 사용
|
|
// (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용)
|
|
const isValidOptions = staticOpts.length > 0 &&
|
|
staticOpts[0]?.value &&
|
|
staticOpts[0].value !== departureField &&
|
|
staticOpts[0].value !== destinationField;
|
|
|
|
if (isValidOptions) {
|
|
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
|
|
setOptions(staticOpts);
|
|
} else {
|
|
// 기본값 (포항/광양)
|
|
console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS });
|
|
setOptions(DEFAULT_OPTIONS);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (dataSource.type === "code" && dataSource.codeCategory) {
|
|
// 코드 관리에서 가져오기
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get(`/code-management/codes`, {
|
|
params: { categoryCode: dataSource.codeCategory },
|
|
});
|
|
if (response.data.success && response.data.data) {
|
|
const codeOptions = response.data.data.map((code: any) => ({
|
|
value: code.code_value || code.codeValue || code.code,
|
|
label: code.code_name || code.codeName || code.name,
|
|
}));
|
|
setOptions(codeOptions);
|
|
}
|
|
} catch (error) {
|
|
console.error("코드 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (dataSource.type === "table" && dataSource.tableName) {
|
|
// 테이블에서 가져오기
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
|
|
params: { page: 1, pageSize: 1000 },
|
|
});
|
|
if (response.data.success && response.data.data) {
|
|
// data가 배열인지 또는 data.rows인지 확인
|
|
const rows = Array.isArray(response.data.data)
|
|
? response.data.data
|
|
: response.data.data.rows || [];
|
|
const tableOptions = rows.map((row: any) => ({
|
|
value: String(row[dataSource.valueField || "id"] || ""),
|
|
label: String(row[dataSource.labelField || "name"] || ""),
|
|
}));
|
|
setOptions(tableOptions);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 데이터 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadOptions();
|
|
}, [dataSource, isDesignMode]);
|
|
|
|
// formData에서 초기값 동기화
|
|
useEffect(() => {
|
|
const depVal = formData[departureField];
|
|
const destVal = formData[destinationField];
|
|
|
|
if (depVal && options.some(o => o.value === depVal)) {
|
|
setLocalDeparture(depVal);
|
|
}
|
|
if (destVal && options.some(o => o.value === destVal)) {
|
|
setLocalDestination(destVal);
|
|
}
|
|
}, [formData, departureField, destinationField, options]);
|
|
|
|
// 출발지 변경
|
|
const handleDepartureChange = (selectedValue: string) => {
|
|
console.log("[LocationSwapSelector] 출발지 변경:", {
|
|
selectedValue,
|
|
departureField,
|
|
hasOnFormDataChange: !!onFormDataChange,
|
|
options
|
|
});
|
|
|
|
// 로컬 상태 업데이트
|
|
setLocalDeparture(selectedValue);
|
|
|
|
// 부모에게 전달
|
|
if (onFormDataChange) {
|
|
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
|
|
onFormDataChange(departureField, selectedValue);
|
|
// 라벨 필드도 업데이트
|
|
if (departureLabelField) {
|
|
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
|
if (selectedOption) {
|
|
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`);
|
|
onFormDataChange(departureLabelField, selectedOption.label);
|
|
}
|
|
}
|
|
} else {
|
|
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
|
}
|
|
};
|
|
|
|
// 도착지 변경
|
|
const handleDestinationChange = (selectedValue: string) => {
|
|
console.log("[LocationSwapSelector] 도착지 변경:", {
|
|
selectedValue,
|
|
destinationField,
|
|
hasOnFormDataChange: !!onFormDataChange,
|
|
options
|
|
});
|
|
|
|
// 로컬 상태 업데이트
|
|
setLocalDestination(selectedValue);
|
|
|
|
// 부모에게 전달
|
|
if (onFormDataChange) {
|
|
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
|
|
onFormDataChange(destinationField, selectedValue);
|
|
// 라벨 필드도 업데이트
|
|
if (destinationLabelField) {
|
|
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
|
if (selectedOption) {
|
|
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
|
|
onFormDataChange(destinationLabelField, selectedOption.label);
|
|
}
|
|
}
|
|
} else {
|
|
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
|
}
|
|
};
|
|
|
|
// 출발지/도착지 교환
|
|
const handleSwap = () => {
|
|
setIsSwapping(true);
|
|
|
|
// 로컬 상태 교환
|
|
const tempDeparture = localDeparture;
|
|
const tempDestination = localDestination;
|
|
|
|
setLocalDeparture(tempDestination);
|
|
setLocalDestination(tempDeparture);
|
|
|
|
// 부모에게 전달
|
|
if (onFormDataChange) {
|
|
onFormDataChange(departureField, tempDestination);
|
|
onFormDataChange(destinationField, tempDeparture);
|
|
|
|
// 라벨도 교환
|
|
if (departureLabelField && destinationLabelField) {
|
|
const depOption = options.find(o => o.value === tempDestination);
|
|
const destOption = options.find(o => o.value === tempDeparture);
|
|
onFormDataChange(departureLabelField, depOption?.label || "");
|
|
onFormDataChange(destinationLabelField, destOption?.label || "");
|
|
}
|
|
}
|
|
|
|
// 애니메이션 효과
|
|
setTimeout(() => setIsSwapping(false), 300);
|
|
};
|
|
|
|
// 스타일에서 width, height 추출
|
|
const { width, height, ...restStyle } = style || {};
|
|
|
|
// 선택된 라벨 가져오기
|
|
const getDepartureLabel = () => {
|
|
const opt = options.find(o => o.value === localDeparture);
|
|
return opt?.label || "";
|
|
};
|
|
|
|
const getDestinationLabel = () => {
|
|
const opt = options.find(o => o.value === localDestination);
|
|
return opt?.label || "";
|
|
};
|
|
|
|
// 디버그 로그
|
|
console.log("[LocationSwapSelector] 렌더:", {
|
|
localDeparture,
|
|
localDestination,
|
|
options: options.map(o => `${o.value}:${o.label}`),
|
|
});
|
|
|
|
// Card 스타일 (이미지 참고)
|
|
if (variant === "card") {
|
|
return (
|
|
<div
|
|
id={id}
|
|
className="h-full w-full"
|
|
style={restStyle}
|
|
>
|
|
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
|
{/* 출발지 */}
|
|
<div className="flex flex-1 flex-col items-center">
|
|
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
|
|
<Select
|
|
value={localDeparture || undefined}
|
|
onValueChange={handleDepartureChange}
|
|
disabled={loading}
|
|
>
|
|
<SelectTrigger className={cn(
|
|
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
|
isSwapping && "animate-pulse"
|
|
)}>
|
|
{localDeparture ? (
|
|
<span>{getDepartureLabel()}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">선택</span>
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" sideOffset={4}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value === localDestination}
|
|
>
|
|
{option.label}
|
|
{option.value === localDestination && " (도착지)"}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 교환 버튼 */}
|
|
{showSwapButton && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleSwap}
|
|
className={cn(
|
|
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
|
isSwapping && "rotate-180"
|
|
)}
|
|
>
|
|
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* 도착지 */}
|
|
<div className="flex flex-1 flex-col items-center">
|
|
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
|
|
<Select
|
|
value={localDestination || undefined}
|
|
onValueChange={handleDestinationChange}
|
|
disabled={loading}
|
|
>
|
|
<SelectTrigger className={cn(
|
|
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
|
isSwapping && "animate-pulse"
|
|
)}>
|
|
{localDestination ? (
|
|
<span>{getDestinationLabel()}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">선택</span>
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" sideOffset={4}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value === localDeparture}
|
|
>
|
|
{option.label}
|
|
{option.value === localDeparture && " (출발지)"}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Inline 스타일
|
|
if (variant === "inline") {
|
|
return (
|
|
<div
|
|
id={id}
|
|
className="flex h-full w-full items-center gap-2"
|
|
style={restStyle}
|
|
>
|
|
<div className="flex-1">
|
|
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
|
|
<Select
|
|
value={localDeparture || undefined}
|
|
onValueChange={handleDepartureChange}
|
|
disabled={loading}
|
|
>
|
|
<SelectTrigger className="h-10">
|
|
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">선택</span>}
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" sideOffset={4}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value === localDestination}
|
|
>
|
|
{option.label}
|
|
{option.value === localDestination && " (도착지)"}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{showSwapButton && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleSwap}
|
|
className="mt-5 h-10 w-10"
|
|
>
|
|
<ArrowLeftRight className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
|
|
<Select
|
|
value={localDestination || undefined}
|
|
onValueChange={handleDestinationChange}
|
|
disabled={loading}
|
|
>
|
|
<SelectTrigger className="h-10">
|
|
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">선택</span>}
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" sideOffset={4}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value === localDeparture}
|
|
>
|
|
{option.label}
|
|
{option.value === localDeparture && " (출발지)"}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Minimal 스타일
|
|
return (
|
|
<div
|
|
id={id}
|
|
className="flex h-full w-full items-center gap-1"
|
|
style={restStyle}
|
|
>
|
|
<Select
|
|
value={localDeparture || undefined}
|
|
onValueChange={handleDepartureChange}
|
|
disabled={loading}
|
|
>
|
|
<SelectTrigger className="h-8 flex-1 text-sm">
|
|
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" sideOffset={4}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value === localDestination}
|
|
>
|
|
{option.label}
|
|
{option.value === localDestination && " (도착지)"}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{showSwapButton && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleSwap}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<ArrowLeftRight className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<Select
|
|
value={localDestination || undefined}
|
|
onValueChange={handleDestinationChange}
|
|
disabled={loading}
|
|
>
|
|
<SelectTrigger className="h-8 flex-1 text-sm">
|
|
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" sideOffset={4}>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value === localDeparture}
|
|
>
|
|
{option.label}
|
|
{option.value === localDeparture && " (출발지)"}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|