summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/common/material/material-selector.tsx320
-rw-r--r--components/signup/join-form.tsx22
2 files changed, 337 insertions, 5 deletions
diff --git a/components/common/material/material-selector.tsx b/components/common/material/material-selector.tsx
new file mode 100644
index 00000000..aa68d2b5
--- /dev/null
+++ b/components/common/material/material-selector.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import React, { useState, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+} from "@/components/ui/command";
+import { Check, ChevronsUpDown, X, Search, ChevronLeft, ChevronRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useDebounce } from "@/hooks/use-debounce";
+import { searchMaterialsForSelector, MaterialSearchItem } from "@/lib/material/material-group-service";
+
+interface MaterialSelectorProps {
+ selectedMaterials?: MaterialSearchItem[];
+ onMaterialsChange?: (materials: MaterialSearchItem[]) => void;
+ singleSelect?: boolean;
+ placeholder?: string;
+ noValuePlaceHolder?: string;
+ disabled?: boolean;
+ maxSelections?: number;
+ className?: string;
+ closeOnSelect?: boolean;
+}
+
+export function MaterialSelector({
+ selectedMaterials = [],
+ onMaterialsChange,
+ singleSelect = false,
+ placeholder = "자재를 검색하세요...",
+ noValuePlaceHolder = "자재를 검색해주세요",
+ disabled = false,
+ maxSelections,
+ className,
+ closeOnSelect = true
+}: MaterialSelectorProps) {
+
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState<MaterialSearchItem[]>([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [searchError, setSearchError] = useState<string | null>(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pagination, setPagination] = useState({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ });
+
+ // Debounce 적용된 검색어
+ const debouncedSearchQuery = useDebounce(searchQuery, 300);
+
+ // 검색 실행 - useCallback으로 메모이제이션
+ const performSearch = useCallback(async (query: string, page: number = 1) => {
+ setIsSearching(true);
+ setSearchError(null);
+
+ try {
+ const result = await searchMaterialsForSelector(query, page, 10);
+
+ if (result.success) {
+ setSearchResults(result.data);
+ setPagination(result.pagination);
+ setCurrentPage(page);
+ } else {
+ setSearchResults([]);
+ setSearchError("검색 중 오류가 발생했습니다.");
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ });
+ }
+ } catch (err) {
+ console.error("자재 검색 실패:", err);
+ setSearchResults([]);
+ setSearchError("검색 중 오류가 발생했습니다.");
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ });
+ } finally {
+ setIsSearching(false);
+ }
+ }, []);
+
+ // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만)
+ React.useEffect(() => {
+ if (debouncedSearchQuery.trim()) {
+ setCurrentPage(1);
+ performSearch(debouncedSearchQuery, 1);
+ } else {
+ // 검색어가 없으면 결과 초기화
+ setSearchResults([]);
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ });
+ }
+ }, [debouncedSearchQuery, performSearch]);
+
+ // 페이지 변경 처리 - useCallback으로 메모이제이션
+ const handlePageChange = useCallback((newPage: number) => {
+ if (newPage >= 1 && newPage <= pagination.pageCount) {
+ performSearch(debouncedSearchQuery, newPage);
+ }
+ }, [pagination.pageCount, performSearch, debouncedSearchQuery]);
+
+ // 자재 선택 처리 - useCallback으로 메모이제이션
+ const handleMaterialSelect = useCallback((material: MaterialSearchItem) => {
+ if (disabled) return;
+
+ let newSelectedMaterials: MaterialSearchItem[];
+
+ if (singleSelect) {
+ newSelectedMaterials = [material];
+ } else {
+ const isAlreadySelected = selectedMaterials.some(
+ (selected) => selected.materialGroupCode === material.materialGroupCode &&
+ selected.materialName === material.materialName
+ );
+
+ if (isAlreadySelected) {
+ newSelectedMaterials = selectedMaterials.filter(
+ (selected) => !(selected.materialGroupCode === material.materialGroupCode &&
+ selected.materialName === material.materialName)
+ );
+ } else {
+ if (maxSelections && selectedMaterials.length >= maxSelections) {
+ return; // 최대 선택 수 초과 시 추가하지 않음
+ }
+ newSelectedMaterials = [...selectedMaterials, material];
+ }
+ }
+
+ onMaterialsChange?.(newSelectedMaterials);
+
+ if (closeOnSelect && singleSelect) {
+ setOpen(false);
+ }
+ }, [disabled, singleSelect, selectedMaterials, maxSelections, onMaterialsChange, closeOnSelect]);
+
+ // 개별 자재 제거
+ const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => {
+ if (disabled) return;
+
+ const newSelectedMaterials = selectedMaterials.filter(
+ (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode &&
+ material.materialName === materialToRemove.materialName)
+ );
+ onMaterialsChange?.(newSelectedMaterials);
+ }, [disabled, selectedMaterials, onMaterialsChange]);
+
+ // 선택된 자재가 있는지 확인
+ const isMaterialSelected = useCallback((material: MaterialSearchItem) => {
+ return selectedMaterials.some(
+ (selected) => selected.materialGroupCode === material.materialGroupCode &&
+ selected.materialName === material.materialName
+ );
+ }, [selectedMaterials]);
+
+ return (
+ <div className={cn("w-full", className)}>
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between min-h-[2.5rem] h-auto"
+ disabled={disabled}
+ >
+ <div className="flex flex-wrap gap-1 flex-1 text-left">
+ {selectedMaterials.length === 0 ? (
+ <span className="text-muted-foreground">{noValuePlaceHolder}</span>
+ ) : (
+ selectedMaterials.map((material) => (
+ <Badge
+ key={`${material.materialGroupCode}-${material.materialName}`}
+ variant="secondary"
+ className="gap-1"
+ >
+ <span className="max-w-[200px] truncate">
+ {material.displayText}
+ </span>
+ {!disabled && (
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-red-500"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleRemoveMaterial(material);
+ }}
+ />
+ )}
+ </Badge>
+ ))
+ )}
+ </div>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <div className="flex items-center border-b px-3">
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+ <Input
+ placeholder={placeholder}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none border-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
+ />
+ </div>
+
+ <CommandList>
+ <ScrollArea className="h-64">
+ {!searchQuery.trim() ? (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 자재를 검색하려면 검색어를 입력해주세요.
+ </div>
+ ) : isSearching ? (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 검색 중...
+ </div>
+ ) : searchError ? (
+ <div className="p-4 text-center text-sm text-red-500">
+ {searchError}
+ </div>
+ ) : searchResults.length === 0 ? (
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ ) : (
+ <CommandGroup>
+ {searchResults.map((material) => (
+ <CommandItem
+ key={`${material.materialGroupCode}-${material.materialName}`}
+ onSelect={() => handleMaterialSelect(material)}
+ className="cursor-pointer"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ isMaterialSelected(material) ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex-1">
+ <div className="font-medium">{material.materialName}</div>
+ <div className="text-xs text-muted-foreground">
+ 코드: {material.materialGroupCode}
+ </div>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </ScrollArea>
+
+ {/* 페이지네이션 */}
+ {searchResults.length > 0 && pagination.pageCount > 1 && (
+ <div className="flex items-center justify-between border-t px-3 py-2">
+ <div className="text-xs text-muted-foreground">
+ 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}-
+ {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ className="h-6 w-6 p-0"
+ >
+ <ChevronLeft className="h-3 w-3" />
+ </Button>
+ <span className="text-xs">
+ {pagination.page} / {pagination.pageCount}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ className="h-6 w-6 p-0"
+ >
+ <ChevronRight className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ );
+}
diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx
index 9eda1a7d..4ee05c9b 100644
--- a/components/signup/join-form.tsx
+++ b/components/signup/join-form.tsx
@@ -71,6 +71,8 @@ import koLocale from "i18n-iso-countries/langs/ko.json";
import { getVendorTypes } from '@/lib/vendors/service';
import ConsentStep from './conset-step';
import { checkEmailExists } from '@/lib/vendor-users/service';
+import { MaterialSelector } from '@/components/common/material/material-selector';
+import { MaterialSearchItem } from '@/lib/material/material-group-service';
i18nIsoCountries.registerLocale(enLocale);
i18nIsoCountries.registerLocale(koLocale);
@@ -111,7 +113,7 @@ interface AccountData {
interface VendorData {
vendorName: string;
vendorTypeId?: number;
- items: string;
+ items: MaterialSearchItem[];
taxId: string;
address: string;
addressDetail: string;
@@ -473,7 +475,7 @@ export default function JoinForm() {
const [vendorData, setVendorData] = useState<VendorData>({
vendorName: "",
vendorTypeId: undefined,
- items: "",
+ items: [],
taxId: defaultTaxId,
address: "",
addressDetail: "",
@@ -944,6 +946,11 @@ function CompleteVendorForm({
onChange(prev => ({ ...prev, [field]: value }));
};
+ // 자재 변경 핸들러
+ const handleMaterialsChange = (materials: MaterialSearchItem[]) => {
+ handleInputChange('items', materials);
+ };
+
// 파일 업로드 핸들러들
const createFileUploadHandler = (setFiles: (files: File[]) => void, currentFiles: File[]) => ({
onDropAccepted: (acceptedFiles: File[]) => {
@@ -1064,6 +1071,7 @@ function CompleteVendorForm({
},
vendor: {
...data,
+ items: JSON.stringify(data.items), // 자재 배열을 JSON 문자열로 변환
phone: normalizedVendorPhone,
representativePhone: normalizedRepresentativePhone,
contacts: normalizedContacts,
@@ -1224,10 +1232,14 @@ function CompleteVendorForm({
<label className="block text-sm font-medium mb-1">
{t('supplyItems')} <span className="text-red-500">*</span>
</label>
- <Input
- value={data.items}
- onChange={(e) => handleInputChange('items', e.target.value)}
+ <MaterialSelector
+ selectedMaterials={data.items}
+ onMaterialsChange={handleMaterialsChange}
+ placeholder="type material name or code..."
+ noValuePlaceHolder="type material name or code..."
disabled={isSubmitting}
+ singleSelect={false}
+ className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
{t('supplyItemsHint')}