feat: 수주등록 모달 및 범용 컴포넌트 개발
- 범용 컴포넌트 3종 개발 및 레지스트리 등록: * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트 * EntitySearchInput: 엔티티 검색 모달 컴포넌트 * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트 - 수주등록 전용 컴포넌트: * OrderCustomerSearch: 거래처 검색 (AutocompleteSearchInput 래퍼) * OrderItemRepeaterTable: 품목 관리 (ModalRepeaterTable 래퍼) * OrderRegistrationModal: 수주등록 메인 모달 - 백엔드 API: * Entity 검색 API (멀티테넌시 지원) * 수주 등록 API (자동 채번) - 화면 편집기 통합: * 컴포넌트 레지스트리에 등록 * ConfigPanel을 통한 설정 기능 * 드래그앤드롭으로 배치 가능 - 개발 문서: * 수주등록_화면_개발_계획서.md (상세 설계 문서)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user