Files
vexplor/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
kjs 64e6fd1920 feat: 수주등록 모달 및 범용 컴포넌트 개발
- 범용 컴포넌트 3종 개발 및 레지스트리 등록:
  * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트
  * EntitySearchInput: 엔티티 검색 모달 컴포넌트
  * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트

- 수주등록 전용 컴포넌트:
  * OrderCustomerSearch: 거래처 검색 (AutocompleteSearchInput 래퍼)
  * OrderItemRepeaterTable: 품목 관리 (ModalRepeaterTable 래퍼)
  * OrderRegistrationModal: 수주등록 메인 모달

- 백엔드 API:
  * Entity 검색 API (멀티테넌시 지원)
  * 수주 등록 API (자동 채번)

- 화면 편집기 통합:
  * 컴포넌트 레지스트리에 등록
  * ConfigPanel을 통한 설정 기능
  * 드래그앤드롭으로 배치 가능

- 개발 문서:
  * 수주등록_화면_개발_계획서.md (상세 설계 문서)
2025-11-14 14:43:53 +09:00

190 lines
6.4 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { X, Loader2, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { EntitySearchResult } from "../entity-search-input/types";
import { cn } from "@/lib/utils";
import { AutocompleteSearchInputConfig } from "./types";
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
config?: AutocompleteSearchInputConfig;
filterCondition?: Record<string, any>;
disabled?: boolean;
value?: any;
onChange?: (value: any, fullData?: any) => void;
className?: string;
}
export function AutocompleteSearchInputComponent({
config,
tableName: propTableName,
displayField: propDisplayField,
valueField: propValueField,
searchFields: propSearchFields,
filterCondition = {},
placeholder: propPlaceholder,
disabled = false,
value,
onChange,
showAdditionalInfo: propShowAdditionalInfo,
additionalFields: propAdditionalFields,
className,
}: AutocompleteSearchInputProps) {
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
const displayField = config?.displayField || propDisplayField || "";
const valueField = config?.valueField || propValueField || "";
const searchFields = config?.searchFields || propSearchFields || [displayField];
const placeholder = config?.placeholder || propPlaceholder || "검색...";
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
const additionalFields = config?.additionalFields || propAdditionalFields || [];
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { searchText, setSearchText, results, loading, clearSearch } = useEntitySearch({
tableName,
searchFields,
filterCondition,
});
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setInputValue(selectedData[displayField] || "");
} else if (!value) {
setInputValue("");
setSelectedData(null);
}
}, [value, displayField]);
// 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
setSearchText(newValue);
setIsOpen(true);
};
const handleSelect = (item: EntitySearchResult) => {
setSelectedData(item);
setInputValue(item[displayField] || "");
onChange?.(item[valueField], item);
setIsOpen(false);
};
const handleClear = () => {
setInputValue("");
setSelectedData(null);
onChange?.(null, null);
setIsOpen(false);
};
const handleInputFocus = () => {
// 포커스 시 항상 검색 실행 (빈 값이면 전체 목록)
if (!selectedData) {
setSearchText(inputValue || "");
setIsOpen(true);
}
};
return (
<div className={cn("relative", className)} ref={containerRef}>
{/* 입력 필드 */}
<div className="relative">
<Input
value={inputValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
/>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{inputValue && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{/* 드롭다운 결과 */}
{isOpen && (results.length > 0 || loading) && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{loading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
...
</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="py-1">
{results.map((item, index) => (
<button
key={index}
type="button"
onClick={() => handleSelect(item)}
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
>
<div className="font-medium">{item[displayField]}</div>
{additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
{additionalFields.map((field) => (
<div key={field}>
{field}: {item[field] || "-"}
</div>
))}
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
</div>
);
}