diff options
Diffstat (limited to 'components')
4 files changed, 531 insertions, 21 deletions
diff --git a/components/common/material/material-group-selector-dialog-multi-without-code.tsx b/components/common/material/material-group-selector-dialog-multi-without-code.tsx new file mode 100644 index 00000000..67496d27 --- /dev/null +++ b/components/common/material/material-group-selector-dialog-multi-without-code.tsx @@ -0,0 +1,300 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { MaterialGroupSelector } from "./material-group-selector"; +import { MaterialSearchItem } from "@/lib/material/material-group-service"; +import { X } from "lucide-react"; + +/** + * 자재그룹 다중 선택 Dialog 컴포넌트 + * + * @description + * - MaterialGroupSelector를 Dialog로 래핑한 다중 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 여러 자재를 선택한 후 확인 버튼으로 완료 + * - 최대 선택 개수 제한 가능 + * + * @MaterialSearchItem_Structure + * 상태에서 관리되는 자재그룹 객체의 형태: + * ```typescript + * interface MaterialSearchItem { + * materialGroupCode: string; // 자재그룹코드 (예: "BG2001") + * materialGroupDescription: string; // 자재그룹명 (예: "DOUBLE WETDOOR HINGE") + * materialGroupUom?: string; // 단위 (예: "EA", "KG") - 선택적 + * displayText: string; // 표시용 텍스트 (code + " - " + description) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedMaterials: 현재 선택된 자재들 (배열) + * - tempSelectedMaterials: Dialog 내에서 임시로 선택된 자재들 (확인 버튼 클릭 전까지) + * + * @callback + * - onMaterialsSelect: 자재 선택 완료 시 호출되는 콜백 + * - 매개변수: MaterialSearchItem[] + * - 선택된 자재들의 배열 (빈 배열일 수도 있음) + * + * @usage + * ```tsx + * <MaterialGroupSelectorDialogMulti + * triggerLabel="자재 선택 (다중)" + * selectedMaterials={selectedMaterials} + * onMaterialsSelect={(materials) => { + * setSelectedMaterials(materials); + * console.log('선택된 자재들:', materials); + * }} + * maxSelections={5} + * placeholder="자재를 검색하세요..." + * /> + * ``` + */ + +interface MaterialGroupSelectorDialogMultiProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string; + /** 현재 선택된 자재들 */ + selectedMaterials?: MaterialSearchItem[]; + /** 자재 선택 완료 시 호출되는 콜백 */ + onMaterialsSelect?: (materials: MaterialSearchItem[]) => void; + /** 최대 선택 가능한 자재 개수 */ + maxSelections?: number; + /** 검색 입력창 placeholder */ + placeholder?: string; + /** Dialog 제목 */ + title?: string; + /** Dialog 설명 */ + description?: string; + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean; + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + /** 제외할 자재그룹코드들 */ + excludeMaterialCodes?: Set<string>; + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean; + /** 트리거 버튼에서 선택된 자재들을 표시할지 여부 */ + showSelectedInTrigger?: boolean; +} + +export function MaterialGroupSelectorDialogMulti({ + triggerLabel = "자재 선택", + selectedMaterials = [], + onMaterialsSelect, + maxSelections, + placeholder = "자재를 검색하세요...", + title = "자재 선택 (다중)", + description, + disabled = false, + triggerVariant = "outline", + excludeMaterialCodes, + showInitialData = true, + showSelectedInTrigger = true, +}: MaterialGroupSelectorDialogMultiProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false); + + // Dialog 내에서 임시로 선택된 자재들 (확인 버튼 클릭 전까지) + const [tempSelectedMaterials, setTempSelectedMaterials] = useState<MaterialSearchItem[]>([]); + + // Dialog 설명 동적 생성 + const dialogDescription = description || + (maxSelections + ? `원하는 자재를 검색하고 선택해주세요. (최대 ${maxSelections}개)` + : "원하는 자재를 검색하고 선택해주세요." + ); + + // Dialog 열림 시 현재 선택된 자재들로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + setTempSelectedMaterials([...selectedMaterials]); + } + }, [selectedMaterials]); + + // 자재 선택 처리 (Dialog 내에서) + const handleMaterialsChange = useCallback((materials: MaterialSearchItem[]) => { + setTempSelectedMaterials(materials); + }, []); + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onMaterialsSelect?.(tempSelectedMaterials); + setOpen(false); + }, [tempSelectedMaterials, onMaterialsSelect]); + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedMaterials([...selectedMaterials]); + setOpen(false); + }, [selectedMaterials]); + + // 전체 선택 해제 + const handleClearAll = useCallback(() => { + setTempSelectedMaterials([]); + }, []); + + // 개별 자재 제거 (트리거 버튼에서) + const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const newMaterials = selectedMaterials.filter( + (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode && + material.materialGroupDescription === materialToRemove.materialGroupDescription) + ); + onMaterialsSelect?.(newMaterials); + }, [selectedMaterials, onMaterialsSelect]); + + // 트리거 버튼 렌더링 + const renderTriggerContent = () => { + if (selectedMaterials.length === 0) { + return triggerLabel; + } + + if (!showSelectedInTrigger) { + return `${triggerLabel} (${selectedMaterials.length}개)`; + } + + return ( + <div className="flex flex-wrap gap-1 items-center max-w-full"> + <span className="shrink-0">{triggerLabel}:</span> + {selectedMaterials.slice(0, 2).map((material) => ( + <Badge + key={`${material.materialGroupCode}-${material.materialGroupDescription}`} + variant="secondary" + className="gap-1 pr-1 max-w-[120px]" + > + <span className="truncate text-xs"> + {material.materialGroupDescription} + </span> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => handleRemoveMaterial(material, e)} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + ))} + {selectedMaterials.length > 2 && ( + <Badge variant="outline" className="text-xs"> + +{selectedMaterials.length - 2}개 + </Badge> + )} + </div> + ); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button + variant={triggerVariant} + disabled={disabled} + className="min-h-[2.5rem] h-auto justify-start" + > + {renderTriggerContent()} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{dialogDescription}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <MaterialGroupSelector + selectedMaterials={tempSelectedMaterials} + onMaterialsChange={handleMaterialsChange} + singleSelect={false} + maxSelections={maxSelections} + placeholder={placeholder} + noValuePlaceHolder="자재를 선택해주세요" + closeOnSelect={false} + excludeMaterialCodes={excludeMaterialCodes} + showInitialData={showInitialData} + hideCode={true} + /> + + {/* 선택된 자재 개수 표시 */} + <div className="mt-2 text-sm text-muted-foreground"> + {maxSelections ? ( + `선택됨: ${tempSelectedMaterials.length}/${maxSelections}개` + ) : ( + `선택됨: ${tempSelectedMaterials.length}개` + )} + </div> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedMaterials.length > 0 && ( + <Button variant="ghost" onClick={handleClearAll}> + 전체 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 ({tempSelectedMaterials.length}개) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterials, setSelectedMaterials] = useState<MaterialSearchItem[]>([]); + * + * return ( + * <MaterialGroupSelectorDialogMulti + * triggerLabel="자재 선택" + * selectedMaterials={selectedMaterials} + * onMaterialsSelect={(materials) => { + * setSelectedMaterials(materials); + * console.log('선택된 자재들:', materials.map(m => ({ + * code: m.materialGroupCode, + * description: m.materialGroupDescription, + * uom: m.materialGroupUom + * }))); + * }} + * maxSelections={5} + * title="필수 자재 선택" + * description="프로젝트에 필요한 자재들을 선택해주세요." + * showSelectedInTrigger={true} + * /> + * ); + * ``` + * + * @advanced_usage + * ```tsx + * // 제외할 자재가 있는 경우 + * const excludedCodes = new Set(['MAT001', 'MAT002']); + * + * <MaterialGroupSelectorDialogMulti + * selectedMaterials={selectedMaterials} + * onMaterialsSelect={setSelectedMaterials} + * excludeMaterialCodes={excludedCodes} + * maxSelections={3} + * triggerVariant="default" + * showSelectedInTrigger={false} + * /> + * ``` + */ diff --git a/components/common/material/material-group-selector-dialog-single-without-code.tsx b/components/common/material/material-group-selector-dialog-single-without-code.tsx new file mode 100644 index 00000000..e3ef5eb8 --- /dev/null +++ b/components/common/material/material-group-selector-dialog-single-without-code.tsx @@ -0,0 +1,209 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { MaterialGroupSelector } from "./material-group-selector"; +import { MaterialSearchItem } from "@/lib/material/material-group-service"; + +/** + * 자재그룹 단일 선택 Dialog 컴포넌트 + * + * @description + * - MaterialGroupSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 자재를 선택하면 Dialog가 닫히며 결과를 반환 + * + * @MaterialSearchItem_Structure + * 상태에서 관리되는 자재그룹 객체의 형태: + * ```typescript + * interface MaterialSearchItem { + * materialGroupCode: string; // 자재그룹코드 (예: "BG2001") + * materialGroupDescription: string; // 자재그룹명 (예: "DOUBLE WETDOOR HINGE") + * materialGroupUom?: string; // 단위 (예: "EA", "KG") + * displayText: string; // 표시용 텍스트 (code + " - " + description) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedMaterial: 현재 선택된 자재 (단일) + * - tempSelectedMaterial: Dialog 내에서 임시로 선택된 자재 (확인 버튼 클릭 전까지) + * + * @callback + * - onMaterialSelect: 자재 선택 완료 시 호출되는 콜백 + * - 매개변수: MaterialSearchItem | null + * - 선택된 자재 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * <MaterialGroupSelectorDialogSingle + * triggerLabel="자재 선택" + * selectedMaterial={selectedMaterial} + * onMaterialSelect={(material) => { + * setSelectedMaterial(material); + * console.log('선택된 자재:', material); + * }} + * placeholder="자재를 검색하세요..." + * /> + * ``` + */ + +interface MaterialGroupSelectorDialogSingleProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string; + /** 현재 선택된 자재 */ + selectedMaterial?: MaterialSearchItem | null; + /** 자재 선택 완료 시 호출되는 콜백 */ + onMaterialSelect?: (material: MaterialSearchItem | null) => void; + /** 검색 입력창 placeholder */ + placeholder?: string; + /** Dialog 제목 */ + title?: string; + /** Dialog 설명 */ + description?: string; + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean; + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + /** 제외할 자재그룹코드들 */ + excludeMaterialCodes?: Set<string>; + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean; +} + +export function MaterialGroupSelectorDialogSingle({ + triggerLabel = "자재 선택", + selectedMaterial = null, + onMaterialSelect, + placeholder = "자재를 검색하세요...", + title = "자재 선택", + description = "원하는 자재를 검색하고 선택해주세요.", + disabled = false, + triggerVariant = "outline", + excludeMaterialCodes, + showInitialData = true, +}: MaterialGroupSelectorDialogSingleProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false); + + // Dialog 내에서 임시로 선택된 자재 (확인 버튼 클릭 전까지) + const [tempSelectedMaterial, setTempSelectedMaterial] = useState<MaterialSearchItem | null>(null); + + // Dialog 열림 시 현재 선택된 자재로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + setTempSelectedMaterial(selectedMaterial || null); + } + }, [selectedMaterial]); + + // 자재 선택 처리 (Dialog 내에서) + const handleMaterialChange = useCallback((materials: MaterialSearchItem[]) => { + setTempSelectedMaterial(materials.length > 0 ? materials[0] : null); + }, []); + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onMaterialSelect?.(tempSelectedMaterial); + setOpen(false); + }, [tempSelectedMaterial, onMaterialSelect]); + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedMaterial(selectedMaterial || null); + setOpen(false); + }, [selectedMaterial]); + + // 선택 해제 + const handleClear = useCallback(() => { + setTempSelectedMaterial(null); + }, []); + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant={triggerVariant} disabled={disabled}> + {selectedMaterial ? ( + <span className="truncate"> + {selectedMaterial.displayText} + </span> + ) : ( + triggerLabel + )} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <MaterialGroupSelector + selectedMaterials={tempSelectedMaterial ? [tempSelectedMaterial] : []} + onMaterialsChange={handleMaterialChange} + singleSelect={true} + placeholder={placeholder} + noValuePlaceHolder="자재를 선택해주세요" + closeOnSelect={false} + excludeMaterialCodes={excludeMaterialCodes} + showInitialData={showInitialData} + hideCode={true} + /> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedMaterial && ( + <Button variant="ghost" onClick={handleClear}> + 선택 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterial, setSelectedMaterial] = useState<MaterialSearchItem | null>(null); + * + * return ( + * <MaterialGroupSelectorDialogSingle + * triggerLabel="자재 선택" + * selectedMaterial={selectedMaterial} + * onMaterialSelect={(material) => { + * setSelectedMaterial(material); + * if (material) { + * console.log('선택된 자재:', { + * code: material.materialGroupCode, + * description: material.materialGroupDescription, + * uom: material.materialGroupUom + * }); + * } else { + * console.log('자재 선택이 해제되었습니다.'); + * } + * }} + * title="자재 선택" + * description="필요한 자재를 검색하고 선택해주세요." + * /> + * ); + * ``` + */
\ No newline at end of file diff --git a/components/common/material/material-group-selector.tsx b/components/common/material/material-group-selector.tsx index f0276810..98625caa 100644 --- a/components/common/material/material-group-selector.tsx +++ b/components/common/material/material-group-selector.tsx @@ -33,6 +33,7 @@ interface MaterialGroupSelectorProps { excludeMaterialCodes?: Set<string>; // 제외할 자재그룹코드들 showInitialData?: boolean; // 초기 클릭시 자재그룹들을 로드할지 여부 maxSelections?: number; // 최대 선택 가능한 자재 개수 (1이면 단일 선택, undefined면 제한 없음) + hideCode?: boolean; // 자재그룹코드 표시 여부 (보호 목적) } export function MaterialGroupSelector({ @@ -46,7 +47,8 @@ export function MaterialGroupSelector({ closeOnSelect = true, excludeMaterialCodes, showInitialData = true, - maxSelections + maxSelections, + hideCode = false }: MaterialGroupSelectorProps) { const [open, setOpen] = useState(false); @@ -72,10 +74,10 @@ export function MaterialGroupSelector({ const performSearch = useCallback(async (query: string, page: number = 1) => { setIsSearching(true); setSearchError(null); - + try { - const result = await searchMaterialsForSelector(query, page, 10); - + const result = await searchMaterialsForSelector(query, page, 10, hideCode); + if (result.success) { setSearchResults(result.data); setPagination(result.pagination); @@ -107,7 +109,7 @@ export function MaterialGroupSelector({ } finally { setIsSearching(false); } - }, []); + }, [hideCode]); // Popover 열림시 초기 데이터 로드 React.useEffect(() => { @@ -343,14 +345,16 @@ export function MaterialGroupSelector({ </span> )} </div> - <div className="text-xs text-muted-foreground"> - 자재그룹코드: {material.materialGroupCode} - {material.materialGroupUom && ( - <span className="ml-2"> - | UOM: {material.materialGroupUom} - </span> - )} - </div> + {!hideCode && ( + <div className="text-xs text-muted-foreground"> + 자재그룹코드: {material.materialGroupCode} + {material.materialGroupUom && ( + <span className="ml-2"> + | UOM: {material.materialGroupUom} + </span> + )} + </div> + )} </div> </CommandItem> ); diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 2dd07959..6885279a 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -71,7 +71,7 @@ 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 { MaterialGroupSelector } from '@/components/common/material/material-group-selector'; +import { MaterialGroupSelectorDialogMulti } from '@/components/common/material/material-group-selector-dialog-multi-without-code'; import { MaterialSearchItem } from '@/lib/material/material-group-service'; i18nIsoCountries.registerLocale(enLocale); @@ -1232,15 +1232,12 @@ function CompleteVendorForm({ <label className="block text-sm font-medium mb-1"> {t('supplyItems')} <span className="text-red-500">*</span> </label> - <MaterialGroupSelector + <MaterialGroupSelectorDialogMulti selectedMaterials={data.items} - onMaterialsChange={handleMaterialsChange} - placeholder="type material name or code..." - noValuePlaceHolder="type material name or code..." + onMaterialsSelect={handleMaterialsChange} + placeholder="type material name..." disabled={isSubmitting} - singleSelect={false} - className="w-full" - showInitialData={false} + showSelectedInTrigger={true} /> <p className="text-xs text-gray-500 mt-1"> {t('supplyItemsHint')} |
