feat: 수주등록 모달 및 범용 컴포넌트 개발
- 범용 컴포넌트 3종 개발 및 레지스트리 등록: * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트 * EntitySearchInput: 엔티티 검색 모달 컴포넌트 * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트 - 수주등록 전용 컴포넌트: * OrderCustomerSearch: 거래처 검색 (AutocompleteSearchInput 래퍼) * OrderItemRepeaterTable: 품목 관리 (ModalRepeaterTable 래퍼) * OrderRegistrationModal: 수주등록 메인 모달 - 백엔드 API: * Entity 검색 API (멀티테넌시 지원) * 수주 등록 API (자동 채번) - 화면 편집기 통합: * 컴포넌트 레지스트리에 등록 * ConfigPanel을 통한 설정 기능 * 드래그앤드롭으로 배치 가능 - 개발 문서: * 수주등록_화면_개발_계획서.md (상세 설계 문서)
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
"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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||
import { ItemSelectionModalProps } from "./types";
|
||||
|
||||
export function ItemSelectionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourceTable,
|
||||
sourceColumns,
|
||||
sourceSearchFields = [],
|
||||
multiSelect = true,
|
||||
filterCondition = {},
|
||||
modalTitle,
|
||||
alreadySelected = [],
|
||||
uniqueField,
|
||||
onSelect,
|
||||
}: ItemSelectionModalProps) {
|
||||
const [localSearchText, setLocalSearchText] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const { results, loading, error, search, clearSearch } = useEntitySearch({
|
||||
tableName: sourceTable,
|
||||
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
|
||||
filterCondition,
|
||||
});
|
||||
|
||||
// 모달 열릴 때 초기 검색
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
search("", 1); // 빈 검색어로 전체 목록 조회
|
||||
setSelectedItems([]);
|
||||
} else {
|
||||
clearSearch();
|
||||
setLocalSearchText("");
|
||||
setSelectedItems([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(localSearchText, 1);
|
||||
};
|
||||
|
||||
const handleToggleItem = (item: any) => {
|
||||
if (!multiSelect) {
|
||||
setSelectedItems([item]);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelected = selectedItems.some((selected) =>
|
||||
uniqueField
|
||||
? selected[uniqueField] === item[uniqueField]
|
||||
: selected === item
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
setSelectedItems(
|
||||
selectedItems.filter((selected) =>
|
||||
uniqueField
|
||||
? selected[uniqueField] !== item[uniqueField]
|
||||
: selected !== item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, item]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selectedItems);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 이미 추가된 항목인지 확인
|
||||
const isAlreadyAdded = (item: any): boolean => {
|
||||
if (!uniqueField) return false;
|
||||
return alreadySelected.some(
|
||||
(selected) => selected[uniqueField] === item[uniqueField]
|
||||
);
|
||||
};
|
||||
|
||||
// 선택된 항목인지 확인
|
||||
const isSelected = (item: any): boolean => {
|
||||
return selectedItems.some((selected) =>
|
||||
uniqueField
|
||||
? selected[uniqueField] === item[uniqueField]
|
||||
: selected === item
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
항목을 검색하고 선택하세요
|
||||
{multiSelect && " (다중 선택 가능)"}
|
||||
</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>
|
||||
|
||||
{/* 선택된 항목 수 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="text-sm text-primary">
|
||||
{selectedItems.length}개 항목 선택됨
|
||||
</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>
|
||||
{multiSelect && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
선택
|
||||
</th>
|
||||
)}
|
||||
{sourceColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && results.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
|
||||
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={sourceColumns.length + (multiSelect ? 1 : 0)}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
검색 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
results.map((item, index) => {
|
||||
const alreadyAdded = isAlreadyAdded(item);
|
||||
const selected = isSelected(item);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`border-t transition-colors ${
|
||||
alreadyAdded
|
||||
? "bg-muted/50 opacity-50"
|
||||
: selected
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-accent cursor-pointer"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!alreadyAdded) {
|
||||
handleToggleItem(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{multiSelect && (
|
||||
<td className="px-4 py-2">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
disabled={alreadyAdded}
|
||||
onCheckedChange={() => {
|
||||
if (!alreadyAdded) {
|
||||
handleToggleItem(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{sourceColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedItems.length === 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
추가 ({selectedItems.length})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user