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