From 7433eea5b4bbc0899e255b88e1a7e91f26e9d95b Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 17 Sep 2025 18:04:10 +0900 Subject: (김준회) 자재그룹 선택기 오류수정, 공용사용을 위한 다이얼로그 컴포넌트 구현, data-table 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../material-group-selector-dialog-multi.tsx | 299 +++++++++++++++++++++ .../material-group-selector-dialog-single.tsx | 208 ++++++++++++++ .../common/material/material-group-selector.tsx | 80 ++++-- components/data-table/data-table-filter-list.tsx | 20 +- components/data-table/data-table-grobal-filter.tsx | 15 +- 5 files changed, 592 insertions(+), 30 deletions(-) create mode 100644 components/common/material/material-group-selector-dialog-multi.tsx create mode 100644 components/common/material/material-group-selector-dialog-single.tsx (limited to 'components') diff --git a/components/common/material/material-group-selector-dialog-multi.tsx b/components/common/material/material-group-selector-dialog-multi.tsx new file mode 100644 index 00000000..22a2ef25 --- /dev/null +++ b/components/common/material/material-group-selector-dialog-multi.tsx @@ -0,0 +1,299 @@ +"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 + * { + * 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; + /** 초기 데이터 표시 여부 */ + 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([]); + + // 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 ( +
+ {triggerLabel}: + {selectedMaterials.slice(0, 2).map((material) => ( + + + {material.materialGroupDescription} + + {!disabled && ( + + )} + + ))} + {selectedMaterials.length > 2 && ( + + +{selectedMaterials.length - 2}개 + + )} +
+ ); + }; + + return ( + + + + + + + + {title} + {dialogDescription} + + +
+ + + {/* 선택된 자재 개수 표시 */} +
+ {maxSelections ? ( + `선택됨: ${tempSelectedMaterials.length}/${maxSelections}개` + ) : ( + `선택됨: ${tempSelectedMaterials.length}개` + )} +
+
+ + + + {tempSelectedMaterials.length > 0 && ( + + )} + + +
+
+ ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterials, setSelectedMaterials] = useState([]); + * + * return ( + * { + * 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']); + * + * + * ``` + */ diff --git a/components/common/material/material-group-selector-dialog-single.tsx b/components/common/material/material-group-selector-dialog-single.tsx new file mode 100644 index 00000000..bb039d0a --- /dev/null +++ b/components/common/material/material-group-selector-dialog-single.tsx @@ -0,0 +1,208 @@ +"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 + * { + * 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; + /** 초기 데이터 표시 여부 */ + 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(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 ( + + + + + + + + {title} + {description} + + +
+ +
+ + + + {tempSelectedMaterial && ( + + )} + + +
+
+ ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterial, setSelectedMaterial] = useState(null); + * + * return ( + * { + * 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 69a929f2..f0276810 100644 --- a/components/common/material/material-group-selector.tsx +++ b/components/common/material/material-group-selector.tsx @@ -28,11 +28,11 @@ interface MaterialGroupSelectorProps { placeholder?: string; noValuePlaceHolder?: string; disabled?: boolean; - maxSelections?: number; className?: string; closeOnSelect?: boolean; excludeMaterialCodes?: Set; // 제외할 자재그룹코드들 showInitialData?: boolean; // 초기 클릭시 자재그룹들을 로드할지 여부 + maxSelections?: number; // 최대 선택 가능한 자재 개수 (1이면 단일 선택, undefined면 제한 없음) } export function MaterialGroupSelector({ @@ -42,11 +42,11 @@ export function MaterialGroupSelector({ placeholder = "자재를 검색하세요...", noValuePlaceHolder = "자재를 검색해주세요", disabled = false, - maxSelections, className, closeOnSelect = true, excludeMaterialCodes, - showInitialData = true + showInitialData = true, + maxSelections }: MaterialGroupSelectorProps) { const [open, setOpen] = useState(false); @@ -153,22 +153,27 @@ export function MaterialGroupSelector({ let newSelectedMaterials: MaterialSearchItem[]; - if (singleSelect) { + // maxSelections가 1이면 단일 선택 모드로 동작 + const isSingleSelectMode = singleSelect || maxSelections === 1; + + if (isSingleSelectMode) { newSelectedMaterials = [material]; } else { const isAlreadySelected = selectedMaterials.some( (selected) => selected.materialGroupCode === material.materialGroupCode && - selected.materialGroupDesc === material.materialGroupDesc + selected.materialGroupDescription === material.materialGroupDescription ); if (isAlreadySelected) { newSelectedMaterials = selectedMaterials.filter( (selected) => !(selected.materialGroupCode === material.materialGroupCode && - selected.materialGroupDesc === material.materialGroupDesc) + selected.materialGroupDescription === material.materialGroupDescription) ); } else { + // 최대 선택 개수 확인 if (maxSelections && selectedMaterials.length >= maxSelections) { - return; // 최대 선택 수 초과 시 추가하지 않음 + // 최대 개수에 도달한 경우 선택하지 않음 + return; } newSelectedMaterials = [...selectedMaterials, material]; } @@ -176,10 +181,10 @@ export function MaterialGroupSelector({ onMaterialsChange?.(newSelectedMaterials); - if (closeOnSelect && singleSelect) { + if (closeOnSelect && isSingleSelectMode) { setOpen(false); } - }, [disabled, singleSelect, selectedMaterials, maxSelections, onMaterialsChange, closeOnSelect]); + }, [disabled, singleSelect, maxSelections, selectedMaterials, onMaterialsChange, closeOnSelect]); // 개별 자재 제거 const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => { @@ -187,7 +192,7 @@ export function MaterialGroupSelector({ const newSelectedMaterials = selectedMaterials.filter( (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode && - material.materialGroupDesc === materialToRemove.materialGroupDesc) + material.materialGroupDescription === materialToRemove.materialGroupDescription) ); onMaterialsChange?.(newSelectedMaterials); }, [disabled, selectedMaterials, onMaterialsChange]); @@ -196,7 +201,7 @@ export function MaterialGroupSelector({ const isMaterialSelected = useCallback((material: MaterialSearchItem) => { return selectedMaterials.some( (selected) => selected.materialGroupCode === material.materialGroupCode && - selected.materialGroupDesc === material.materialGroupDesc + selected.materialGroupDescription === material.materialGroupDescription ); }, [selectedMaterials]); @@ -217,11 +222,11 @@ export function MaterialGroupSelector({ ) : ( selectedMaterials.map((material) => ( - + {material.displayText} {!disabled && ( @@ -257,8 +262,17 @@ export function MaterialGroupSelector({ /> - -
+ {/* 스크롤 컨테이너 + 고정 페이지네이션 */} +
+ { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > {!searchQuery.trim() && !showInitialData ? (
자재를 검색하려면 검색어를 입력해주세요. @@ -278,27 +292,31 @@ export function MaterialGroupSelector({ ) : searchResults.length === 0 ? ( 검색 결과가 없습니다. ) : ( - + {searchResults.map((material) => { const isExcluded = excludeMaterialCodes?.has(material.materialGroupCode); const isSelected = isMaterialSelected(material); - + const isMaxReached = maxSelections && selectedMaterials.length >= maxSelections && !isSelected; + const isDisabled = isExcluded || isMaxReached; + return ( { - if (!isExcluded) { + if (!isDisabled) { handleMaterialSelect(material); } }} className={cn( "cursor-pointer", - isExcluded && "opacity-50 cursor-not-allowed bg-muted" + isDisabled && "opacity-50 cursor-not-allowed bg-muted" )} >
{isExcluded ? ( + ) : isMaxReached ? ( + - ) : (
- {material.materialGroupDesc} + {material.materialGroupDescription} {isExcluded && ( 이미 등록됨 )} + {isMaxReached && ( + + 선택 제한 ({maxSelections}개) + + )}
자재그룹코드: {material.materialGroupCode} + {material.materialGroupUom && ( + + | UOM: {material.materialGroupUom} + + )}
@@ -329,11 +357,11 @@ export function MaterialGroupSelector({ })}
)} -
+
- {/* 페이지네이션 */} + {/* 고정 페이지네이션 - 항상 밑에 표시 */} {searchResults.length > 0 && pagination.pageCount > 1 && ( -
+
총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 @@ -363,7 +391,7 @@ export function MaterialGroupSelector({
)} - +
diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx index ea4b1f90..c79c9cfd 100644 --- a/components/data-table/data-table-filter-list.tsx +++ b/components/data-table/data-table-filter-list.tsx @@ -124,14 +124,27 @@ export function DataTableFilterList({ }) ) + // Page state to reset when filters change + const [, setPage] = useQueryState("page", { + parse: (str) => str ? parseInt(str) : 1, + serialize: (val) => val.toString(), + eq: (a, b) => a === b, + clearOnDefault: true, + shallow, + }) + const safeSetFilters = React.useCallback( (next: Filter[] | ((p: Filter[]) => Filter[])) => { setFilters((prev) => { const value = typeof next === "function" ? next(prev) : next + if (!deepEqual(prev, value)) { + // Reset page to 1 when filters change + void setPage(1); + } return deepEqual(prev, value) ? prev : value // <─ 달라진 게 없으면 그대로 }) }, - [setFilters] + [setFilters, setPage] ) @@ -145,11 +158,12 @@ export function DataTableFilterList({ }, [externalFilters, setFilters, safeSetFilters]); React.useEffect(() => { - if (externalJoinOperator) { + if (externalJoinOperator && externalJoinOperator !== joinOperator) { console.log("=== 외부 조인 연산자 적용 ===", externalJoinOperator); + void setPage(1); // Reset page when join operator changes setJoinOperator(externalJoinOperator); } - }, [externalJoinOperator, setJoinOperator]); + }, [externalJoinOperator, setJoinOperator, joinOperator, setPage]); // ✅ 필터 변경 시 부모에게 알림 diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx index fa0c809c..5714d92a 100644 --- a/components/data-table/data-table-grobal-filter.tsx +++ b/components/data-table/data-table-grobal-filter.tsx @@ -19,12 +19,25 @@ export function DataTableGlobalFilter() { shallow: false, }) + // Page state to reset when search changes + const [, setPage] = useQueryState("page", { + parse: (str) => str ? parseInt(str) : 1, + serialize: (val) => val.toString(), + eq: (a, b) => a === b, + clearOnDefault: true, + shallow: false, + }) + // Local tempValue to update instantly on user keystroke const [tempValue, setTempValue] = React.useState(searchValue ?? "") // Debounced callback that sets the URL param after `delay` ms const debouncedSetSearch = useDebouncedCallback((value: string) => { - if (value !== searchValue) setSearchValue(value.trim() === "" ? null : value); + if (value !== searchValue) { + // Reset page to 1 when search changes + void setPage(1); + setSearchValue(value.trim() === "" ? null : value); + } }, 300) // When user types, update local `tempValue` immediately, -- cgit v1.2.3