ERP-node/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorCompone...

646 lines
22 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;
// 🆕 사용자 정보 (DB에서 초기값 로드용)
userId?: string;
// 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";
// 🆕 DB 초기값 로드 설정
loadFromDb?: boolean; // DB에서 초기값 로드 여부
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
dbKeyField?: string; // 키 필드 (기본: user_id)
};
}
/**
* LocationSwapSelector 컴포넌트
* 출발지/도착지 선택 및 교환 기능
*/
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
const {
id,
style,
isDesignMode = false,
formData = {},
onFormDataChange,
componentConfig,
userId,
} = 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";
// 🆕 DB 초기값 로드 설정
const loadFromDb = config.loadFromDb !== false; // 기본값 true
const dbTableName = config.dbTableName || "vehicles";
const dbKeyField = config.dbKeyField || "user_id";
// 기본 옵션 (포항/광양)
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);
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
// 로컬 선택 상태 (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]);
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
useEffect(() => {
const loadFromDatabase = async () => {
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
if (isDesignMode || !loadFromDb || !userId) {
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
return;
}
// 이미 로드했으면 스킵
if (dbLoaded) {
return;
}
try {
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
const response = await apiClient.post(
`/table-management/tables/${dbTableName}/data`,
{
page: 1,
size: 1,
search: { [dbKeyField]: userId },
autoFilter: true,
}
);
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
if (vehicleData) {
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
setLocalDeparture(dbDeparture);
onFormDataChange?.(departureField, dbDeparture);
// 라벨도 업데이트
if (departureLabelField) {
const opt = options.find(o => o.value === dbDeparture);
if (opt) {
onFormDataChange?.(departureLabelField, opt.label);
}
}
}
if (dbDestination && options.some(o => o.value === dbDestination)) {
setLocalDestination(dbDestination);
onFormDataChange?.(destinationField, dbDestination);
// 라벨도 업데이트
if (destinationLabelField) {
const opt = options.find(o => o.value === dbDestination);
if (opt) {
onFormDataChange?.(destinationLabelField, opt.label);
}
}
}
}
setDbLoaded(true);
} catch (error) {
console.error("[LocationSwapSelector] DB 로드 실패:", error);
setDbLoaded(true); // 실패해도 다시 시도하지 않음
}
};
// 옵션이 로드된 후에 DB 로드 실행
if (options.length > 0) {
loadFromDatabase();
}
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
useEffect(() => {
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
if (loadFromDb && userId && !dbLoaded) {
return;
}
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, loadFromDb, userId, dbLoaded]);
// 출발지 변경
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>
);
}