feat: 수주등록 모달 및 범용 컴포넌트 개발

- 범용 컴포넌트 3종 개발 및 레지스트리 등록:
  * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트
  * EntitySearchInput: 엔티티 검색 모달 컴포넌트
  * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트

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

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

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

- 개발 문서:
  * 수주등록_화면_개발_계획서.md (상세 설계 문서)
This commit is contained in:
kjs
2025-11-14 14:43:53 +09:00
parent 075869c89c
commit 64e6fd1920
46 changed files with 6086 additions and 8 deletions

View File

@@ -0,0 +1,126 @@
"use client";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { EntitySearchModal } from "./EntitySearchModal";
import { EntitySearchInputProps, EntitySearchResult } from "./types";
import { cn } from "@/lib/utils";
export function EntitySearchInputComponent({
tableName,
displayField,
valueField,
searchFields = [displayField],
mode = "combo",
placeholder = "검색...",
disabled = false,
filterCondition = {},
value,
onChange,
modalTitle = "검색",
modalColumns = [],
showAdditionalInfo = false,
additionalFields = [],
className,
}: EntitySearchInputProps) {
const [modalOpen, setModalOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setDisplayValue(selectedData[displayField] || "");
} else {
setDisplayValue("");
setSelectedData(null);
}
}, [value, displayField]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
};
const handleClear = () => {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
};
const handleOpenModal = () => {
if (!disabled) {
setModalOpen(true);
}
};
return (
<div className={cn("space-y-2", className)}>
{/* 입력 필드 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
value={displayValue}
onChange={(e) => setDisplayValue(e.target.value)}
placeholder={placeholder}
disabled={disabled}
readOnly={mode === "modal" || mode === "combo"}
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
/>
{displayValue && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Search className="h-4 w-4" />
</Button>
)}
</div>
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="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>
)}
{/* 검색 모달 */}
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
/>
</div>
);
}

View File

@@ -0,0 +1,498 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { EntitySearchInputConfig } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
interface EntitySearchInputConfigPanelProps {
config: EntitySearchInputConfig;
onConfigChange: (config: EntitySearchInputConfig) => void;
}
export function EntitySearchInputConfigPanel({
config,
onConfigChange,
}: EntitySearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.tableName) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const addSearchField = () => {
const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.searchFields || [])];
fields[index] = value;
updateConfig({ searchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.searchFields || [])];
fields.splice(index, 1);
updateConfig({ searchFields: fields });
};
const addModalColumn = () => {
const columns = localConfig.modalColumns || [];
updateConfig({ modalColumns: [...columns, ""] });
};
const updateModalColumn = (index: number, value: string) => {
const columns = [...(localConfig.modalColumns || [])];
columns[index] = value;
updateConfig({ modalColumns: columns });
};
const removeModalColumn = (index: number) => {
const columns = [...(localConfig.modalColumns || [])];
columns.splice(index, 1);
updateConfig({ modalColumns: columns });
};
const addAdditionalField = () => {
const fields = localConfig.additionalFields || [];
updateConfig({ additionalFields: [...fields, ""] });
};
const updateAdditionalField = (index: number, value: string) => {
const fields = [...(localConfig.additionalFields || [])];
fields[index] = value;
updateConfig({ additionalFields: fields });
};
const removeAdditionalField = (index: number) => {
const fields = [...(localConfig.additionalFields || [])];
fields.splice(index, 1);
updateConfig({ additionalFields: fields });
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.tableName
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<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>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || table.tableName}-${table.tableName}`}
onSelect={() => {
updateConfig({ tableName: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && table.displayName !== table.tableName && (
<span className="text-[10px] text-gray-500">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.displayField
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<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>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => {
updateConfig({ displayField: column.columnName });
setOpenDisplayFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && column.displayName !== column.columnName && (
<span className="text-[10px] text-gray-500">{column.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openValueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.valueField
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<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>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => {
updateConfig({ valueField: column.columnName });
setOpenValueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && column.displayName !== column.columnName && (
<span className="text-[10px] text-gray-500">{column.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">UI </Label>
<Select
value={localConfig.mode || "combo"}
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
updateConfig({ mode: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="combo"> ( + )</SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="autocomplete"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={localConfig.placeholder || ""}
onChange={(e) => updateConfig({ placeholder: e.target.value })}
placeholder="검색..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{(localConfig.mode === "modal" || localConfig.mode === "combo") && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalTitle || ""}
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
placeholder="검색 및 선택"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addModalColumn}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.modalColumns || []).map((column, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={column}
onValueChange={(value) => updateModalColumn(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeModalColumn(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.searchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showAdditionalInfo || false}
onCheckedChange={(checked) =>
updateConfig({ showAdditionalInfo: checked })
}
/>
</div>
</div>
{localConfig.showAdditionalInfo && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addAdditionalField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.additionalFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateAdditionalField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeAdditionalField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import React, { useEffect } from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { EntitySearchInputDefinition } from "./index";
export function EntitySearchInputRenderer() {
useEffect(() => {
ComponentRegistry.registerComponent(EntitySearchInputDefinition);
console.log("✅ EntitySearchInput 컴포넌트 등록 완료");
return () => {
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
};
}, []);
return null;
}

View File

@@ -0,0 +1,223 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "./useEntitySearch";
import { EntitySearchResult } from "./types";
interface EntitySearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
modalTitle?: string;
modalColumns?: string[];
onSelect: (value: any, fullData: EntitySearchResult) => void;
}
export function EntitySearchModal({
open,
onOpenChange,
tableName,
displayField,
valueField,
searchFields = [displayField],
filterCondition = {},
modalTitle = "검색",
modalColumns = [],
onSelect,
}: EntitySearchModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const {
results,
loading,
error,
pagination,
search,
clearSearch,
loadMore,
} = useEntitySearch({
tableName,
searchFields,
filterCondition,
});
// 모달 열릴 때 초기 검색
useEffect(() => {
if (open) {
search("", 1); // 빈 검색어로 전체 목록 조회
} else {
clearSearch();
setLocalSearchText("");
}
}, [open]);
const handleSearch = () => {
search(localSearchText, 1);
};
const handleSelect = (item: EntitySearchResult) => {
onSelect(item[valueField], item);
onOpenChange(false);
};
// 표시할 컬럼 결정
const displayColumns = modalColumns.length > 0 ? modalColumns : [displayField];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{/* 검색 입력 */}
<div className="flex gap-2">
<Input
placeholder="검색어를 입력하세요"
value={localSearchText}
onChange={(e) => setLocalSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
disabled={loading}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
{/* 오류 메시지 */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
{/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
{displayColumns.map((col) => (
<th
key={col}
className="px-4 py-2 text-left font-medium text-muted-foreground"
>
{col}
</th>
))}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
</th>
</tr>
</thead>
<tbody>
{loading && results.length === 0 ? (
<tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p>
</td>
</tr>
) : results.length === 0 ? (
<tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
results.map((item, index) => (
<tr
key={item[valueField] || index}
className="border-t hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleSelect(item)}
>
{displayColumns.map((col, colIndex) => (
<td key={`${item[valueField] || index}-${col}-${colIndex}`} className="px-4 py-2">
{item[col] || "-"}
</td>
))}
<td className="px-4 py-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
handleSelect(item);
}}
className="h-7 text-xs"
>
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 정보 */}
{results.length > 0 && (
<div className="flex justify-between items-center text-xs sm:text-sm text-muted-foreground">
<span>
{pagination.total} {results.length}
</span>
{pagination.page * pagination.limit < pagination.total && (
<Button
variant="outline"
size="sm"
onClick={loadMore}
disabled={loading}
className="h-7 text-xs"
>
</Button>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,14 @@
export interface EntitySearchInputConfig {
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
mode?: "autocomplete" | "modal" | "combo";
placeholder?: string;
modalTitle?: string;
modalColumns?: string[];
showAdditionalInfo?: boolean;
additionalFields?: string[];
}

View File

@@ -0,0 +1,53 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
import { EntitySearchInputConfigPanel } from "./EntitySearchInputConfigPanel";
/**
* EntitySearchInput 컴포넌트 정의
* 모달 기반 엔티티 검색 입력
*/
export const EntitySearchInputDefinition = createComponentDefinition({
id: "entity-search-input",
name: "엔티티 검색 입력 (모달)",
nameEng: "Entity Search Input",
description: "모달을 통한 엔티티 검색 및 선택 (거래처, 품목 등)",
category: ComponentCategory.INPUT,
webType: "entity",
component: EntitySearchInputComponent,
defaultConfig: {
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
mode: "combo",
placeholder: "검색...",
modalTitle: "검색 및 선택",
modalColumns: ["customer_code", "customer_name", "address"],
showAdditionalInfo: false,
additionalFields: [],
},
defaultSize: { width: 300, height: 40 },
configPanel: EntitySearchInputConfigPanel,
icon: "Search",
tags: ["검색", "모달", "엔티티", "거래처", "품목"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { EntitySearchInputConfig } from "./config";
// 컴포넌트 내보내기
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
export { EntitySearchModal } from "./EntitySearchModal";
export { useEntitySearch } from "./useEntitySearch";
export type {
EntitySearchInputProps,
EntitySearchResult,
EntitySearchResponse,
} from "./types";

View File

@@ -0,0 +1,52 @@
/**
* EntitySearchInput 컴포넌트 타입 정의
* 엔티티 테이블에서 데이터를 검색하고 선택하는 입력 컴포넌트
*/
export interface EntitySearchInputProps {
// 데이터 소스
tableName: string; // 검색할 테이블명 (예: "customer_mng")
displayField: string; // 표시할 필드 (예: "customer_name")
valueField: string; // 값으로 사용할 필드 (예: "customer_code")
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
// UI 모드
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
placeholder?: string;
disabled?: boolean;
// 필터링
filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시
// 선택된 값
value?: any;
onChange?: (value: any, fullData?: any) => void;
// 모달 설정 (mode가 "modal" 또는 "combo"일 때)
modalTitle?: string;
modalColumns?: string[]; // 모달에 표시할 컬럼들
// 추가 표시 정보
showAdditionalInfo?: boolean; // 선택 후 추가 정보 표시 (예: 주소)
additionalFields?: string[]; // 추가로 표시할 필드들
// 스타일
className?: string;
}
export interface EntitySearchResult {
[key: string]: any;
}
export interface EntitySearchResponse {
success: boolean;
data: EntitySearchResult[];
pagination?: {
total: number;
page: number;
limit: number;
};
error?: string;
}

View File

@@ -0,0 +1,110 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { EntitySearchResult, EntitySearchResponse } from "./types";
interface UseEntitySearchProps {
tableName: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
}
export function useEntitySearch({
tableName,
searchFields = [],
filterCondition = {},
}: UseEntitySearchProps) {
const [searchText, setSearchText] = useState("");
const [results, setResults] = useState<EntitySearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 20,
});
// searchFields와 filterCondition을 ref로 관리하여 useCallback 의존성 문제 해결
const searchFieldsRef = useRef(searchFields);
const filterConditionRef = useRef(filterCondition);
useEffect(() => {
searchFieldsRef.current = searchFields;
filterConditionRef.current = filterCondition;
}, [searchFields, filterCondition]);
const search = useCallback(
async (text: string, page: number = 1) => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
searchText: text,
searchFields: searchFieldsRef.current.join(","),
filterCondition: JSON.stringify(filterConditionRef.current),
page: page.toString(),
limit: pagination.limit.toString(),
});
const response = await apiClient.get<EntitySearchResponse>(
`/entity-search/${tableName}?${params.toString()}`
);
if (response.data.success) {
setResults(response.data.data);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
setError(response.data.error || "검색에 실패했습니다");
}
} catch (err: any) {
console.error("Entity search error:", err);
setError(err.response?.data?.message || "검색 중 오류가 발생했습니다");
} finally {
setLoading(false);
}
},
[tableName, pagination.limit]
);
// 디바운스된 검색
useEffect(() => {
// searchText가 명시적으로 설정되지 않은 경우(null/undefined)만 건너뛰기
if (searchText === null || searchText === undefined) {
return;
}
const timer = setTimeout(() => {
// 빈 문자열("")도 검색 (전체 목록 조회)
search(searchText.trim(), 1);
}, 300); // 300ms 디바운스
return () => clearTimeout(timer);
}, [searchText, search]);
const clearSearch = useCallback(() => {
setSearchText("");
setResults([]);
setError(null);
}, []);
const loadMore = useCallback(() => {
if (pagination.page * pagination.limit < pagination.total) {
search(searchText, pagination.page + 1);
}
}, [search, searchText, pagination]);
return {
searchText,
setSearchText,
results,
loading,
error,
pagination,
search,
clearSearch,
loadMore,
};
}