diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-17 18:04:10 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-17 18:04:10 +0900 |
| commit | 7433eea5b4bbc0899e255b88e1a7e91f26e9d95b (patch) | |
| tree | cad02c119fd41545e24a98734488962c78ed895d | |
| parent | 6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 (diff) | |
(김준회) 자재그룹 선택기 오류수정, 공용사용을 위한 다이얼로그 컴포넌트 구현, data-table 오류 수정
| -rw-r--r-- | app/[lng]/evcp/(evcp)/material-groups/page.tsx | 8 | ||||
| -rw-r--r-- | app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts | 49 | ||||
| -rw-r--r-- | components/common/material/material-group-selector-dialog-multi.tsx | 299 | ||||
| -rw-r--r-- | components/common/material/material-group-selector-dialog-single.tsx | 208 | ||||
| -rw-r--r-- | components/common/material/material-group-selector.tsx | 80 | ||||
| -rw-r--r-- | components/data-table/data-table-filter-list.tsx | 20 | ||||
| -rw-r--r-- | components/data-table/data-table-grobal-filter.tsx | 15 | ||||
| -rw-r--r-- | db/schema/MDG/mdg.ts | 5 | ||||
| -rw-r--r-- | db/schema/items.ts | 12 | ||||
| -rw-r--r-- | lib/material-groups/services.ts | 67 | ||||
| -rw-r--r-- | lib/material-groups/table/material-group-table-columns.tsx | 23 | ||||
| -rw-r--r-- | lib/material-groups/table/material-group-table.tsx | 16 | ||||
| -rw-r--r-- | lib/material-groups/validations.ts | 4 | ||||
| -rw-r--r-- | lib/material/material-group-service.ts | 26 | ||||
| -rw-r--r-- | lib/material/vendor-material/add-confirmed-material.tsx | 123 |
15 files changed, 714 insertions, 241 deletions
diff --git a/app/[lng]/evcp/(evcp)/material-groups/page.tsx b/app/[lng]/evcp/(evcp)/material-groups/page.tsx index 468e3412..3d019182 100644 --- a/app/[lng]/evcp/(evcp)/material-groups/page.tsx +++ b/app/[lng]/evcp/(evcp)/material-groups/page.tsx @@ -1,6 +1,6 @@ /** * 자재그룹 테이블 - * materialSearchView를 사용하여 MDG 자재마스터의 고유한 자재그룹 조회 + * MATERIAL_GROUP_MASTER 테이블을 직접 사용하여 MDG 자재마스터의 자재그룹 조회 * 수정/추가 기능은 불필요 (읽기 전용) */ @@ -63,10 +63,10 @@ export default async function MaterialGroupPage(props: MaterialGroupPageProps) { <React.Suspense fallback={ <DataTableSkeleton - columnCount={2} + columnCount={3} searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["15rem", "25rem"]} + filterableColumnCount={3} + cellWidths={["15rem", "25rem", "10rem"]} shrinkZero /> } diff --git a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts index 80bc85d3..cea8d40b 100644 --- a/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts +++ b/app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts @@ -6,8 +6,7 @@ import { MATERIAL_MASTER_PART_MATL_PLNT, MATERIAL_MASTER_PART_MATL_UNIT, MATERIAL_MASTER_PART_MATL_CLASSASGN, - MATERIAL_MASTER_PART_MATL_CHARASGN, - MATERIAL_GROUP_MASTER + MATERIAL_MASTER_PART_MATL_CHARASGN } from "@/db/schema/MDG/mdg"; import { @@ -33,7 +32,6 @@ type MatlPlntData = typeof MATERIAL_MASTER_PART_MATL_PLNT.$inferInsert; type MatlUnitData = typeof MATERIAL_MASTER_PART_MATL_UNIT.$inferInsert; type MatlClassAsgnData = typeof MATERIAL_MASTER_PART_MATL_CLASSASGN.$inferInsert; type MatlCharAsgnData = typeof MATERIAL_MASTER_PART_MATL_CHARASGN.$inferInsert; -type MaterialGroupData = typeof MATERIAL_GROUP_MASTER.$inferInsert; // XML에서 받는 데이터 구조 (스키마와 동일한 구조, string 타입) type MatlXML = ToXMLFields<Omit<MatlData, 'id' | 'createdAt' | 'updatedAt'>> & { @@ -58,7 +56,6 @@ interface ProcessedMaterialData { units: MatlUnitData[]; classAssignments: MatlClassAsgnData[]; characteristicAssignments: MatlCharAsgnData[]; - materialGroup?: MaterialGroupData; // 자재그룹코드 데이터 (옵셔널) } export async function GET(request: NextRequest) { @@ -141,12 +138,6 @@ export async function POST(request: NextRequest) { * - 별도 필수 필드 없음 (스키마에서 notNull() 제거 예정) * - 전체 데이터셋 기반 삭제 후 재삽입 처리 * - * 3. 자재그룹코드 마스터 (MATERIAL_GROUP_MASTER) - 신규 추가 - * - MATKL(자재그룹코드), MAKTX(자재그룹명) 분리 저장 - * - MATKL이 unique 필드로 충돌 시 upsert 처리 - * - DESC 테이블에서 SPRAS='E'인 MAKTX만 매칭 - * - 중복 제거를 통한 성능 최적화 - * * XML 패턴: * - MATERIAL 인터페이스는 XML에 MATNR이 이미 포함된 패턴 * - 하위 테이블에도 MATNR 필드가 있어서 XML 값 우선 사용됨 @@ -200,29 +191,13 @@ function transformMatlData(matlData: MatlXML[]): ProcessedMaterialData[] { fkData ); - // 3단계: 자재그룹코드 마스터 데이터 생성 (MATKL 존재 시) - let materialGroup: MaterialGroupData | undefined; - - if (material.MATKL) { - // DESC 테이블에서 SPRAS='E'인 MAKTX 찾기 - const englishDescription = descriptions.find(desc => desc.SPRAS === 'E'); - - if (englishDescription && englishDescription.MAKTX) { - materialGroup = { - MATKL: material.MATKL, - MAKTX: englishDescription.MAKTX - }; - } - } - return { material, descriptions, plants, units, classAssignments, - characteristicAssignments, - materialGroup + characteristicAssignments }; }); } @@ -233,10 +208,7 @@ function transformMatlData(matlData: MatlXML[]): ProcessedMaterialData[] { * * 저장 전략: * 1. 최상위 테이블: MATNR 기준 upsert (충돌 시 업데이트) - * 2. 자재그룹코드 마스터: MATKL 기준 upsert (충돌 시 업데이트) - * - 중복된 MATKL 제거하여 성능 최적화 - * - 영어(SPRAS='E') DESC만 처리 - * 3. 하위 테이블들: FK(MATNR) 기준 전체 삭제 후 재삽입 + * 2. 하위 테이블들: FK(MATNR) 기준 전체 삭제 후 재삽입 * - 송신 XML이 전체 데이터셋을 포함하므로 부분 업데이트 불필요 * - 데이터 일관성과 단순성 확보 * @@ -260,24 +232,11 @@ async function saveToDatabase(processedMaterials: ProcessedMaterialData[]) { const classAssignments = processedMaterials.flatMap((m) => m.classAssignments); const characteristicAssignments = processedMaterials.flatMap((m) => m.characteristicAssignments); - // 3) 자재그룹코드 데이터 수집 (중복 제거) - const materialGroups = processedMaterials - .filter((m) => m.materialGroup) - .map((m) => m.materialGroup!) - .filter((group, index, self) => - self.findIndex(g => g.MATKL === group.MATKL) === index // MATKL 기준 중복 제거 - ); // 3) 부모 테이블 UPSERT (배치) await bulkUpsert(tx, MATERIAL_MASTER_PART_MATL, materialRows, 'MATNR'); - // 4) 자재그룹코드 마스터 UPSERT (별도 처리) - if (materialGroups.length > 0) { - console.log(`자재그룹코드 마스터 저장: ${materialGroups.length}개 그룹`); - await bulkUpsert(tx, MATERIAL_GROUP_MASTER, materialGroups, 'MATKL'); - } - - // 5) 하위 테이블 교체 (배치) + // 4) 하위 테이블 교체 (배치) await Promise.all([ bulkReplaceSubTableData(tx, MATERIAL_MASTER_PART_MATL_DESC, descriptions, MATERIAL_MASTER_PART_MATL_DESC.MATNR, matnrs), bulkReplaceSubTableData(tx, MATERIAL_MASTER_PART_MATL_PLNT, plants, MATERIAL_MASTER_PART_MATL_PLNT.MATNR, matnrs), 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 + * <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} + /> + + {/* 선택된 자재 개수 표시 */} + <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.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 + * <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} + /> + </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 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<string>; // 제외할 자재그룹코드들 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) => ( <Badge - key={`${material.materialGroupCode}-${material.materialGroupDesc}`} + key={`${material.materialGroupCode}-${material.materialGroupDescription}`} variant="secondary" className="gap-1 pr-1" > - <span className="max-w-[200px] truncate"> + <span className=""> {material.displayText} </span> {!disabled && ( @@ -257,8 +262,17 @@ export function MaterialGroupSelector({ /> </div> - <CommandList> - <div className="h-64 overflow-y-auto"> + {/* 스크롤 컨테이너 + 고정 페이지네이션 */} + <div className="max-h-[50vh] flex flex-col"> + <CommandList + className="flex-1 overflow-y-auto overflow-x-hidden min-h-0" + // shadcn CommandList 버그 처리 - 스크롤 이벤트 전파 차단 + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > {!searchQuery.trim() && !showInitialData ? ( <div className="p-4 text-center text-sm text-muted-foreground"> 자재를 검색하려면 검색어를 입력해주세요. @@ -278,27 +292,31 @@ export function MaterialGroupSelector({ ) : searchResults.length === 0 ? ( <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> ) : ( - <CommandGroup> + <CommandGroup className="overflow-visible"> {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 ( <CommandItem - key={`${material.materialGroupCode}-${material.materialGroupDesc}`} + key={`${material.materialGroupCode}-${material.materialGroupDescription}`} onSelect={() => { - 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" )} > <div className="mr-2 h-4 w-4 flex items-center justify-center"> {isExcluded ? ( <span className="text-xs text-muted-foreground">✓</span> + ) : isMaxReached ? ( + <span className="text-xs text-muted-foreground">-</span> ) : ( <Check className={cn( @@ -311,17 +329,27 @@ export function MaterialGroupSelector({ <div className="flex-1"> <div className={cn( "font-medium", - isExcluded && "text-muted-foreground" + isDisabled && "text-muted-foreground" )}> - {material.materialGroupDesc} + {material.materialGroupDescription} {isExcluded && ( <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded"> 이미 등록됨 </span> )} + {isMaxReached && ( + <span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"> + 선택 제한 ({maxSelections}개) + </span> + )} </div> <div className="text-xs text-muted-foreground"> 자재그룹코드: {material.materialGroupCode} + {material.materialGroupUom && ( + <span className="ml-2"> + | UOM: {material.materialGroupUom} + </span> + )} </div> </div> </CommandItem> @@ -329,11 +357,11 @@ export function MaterialGroupSelector({ })} </CommandGroup> )} - </div> + </CommandList> - {/* 페이지네이션 */} + {/* 고정 페이지네이션 - 항상 밑에 표시 */} {searchResults.length > 0 && pagination.pageCount > 1 && ( - <div className="flex items-center justify-between border-t px-3 py-2"> + <div className="flex items-center justify-between border-t px-3 py-2 flex-shrink-0"> <div className="text-xs text-muted-foreground"> 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 @@ -363,7 +391,7 @@ export function MaterialGroupSelector({ </div> </div> )} - </CommandList> + </div> </Command> </PopoverContent> </Popover> 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<TData>({ }) ) + // 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<TData>[] | ((p: Filter<TData>[]) => Filter<TData>[])) => { 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<TData>({ }, [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, diff --git a/db/schema/MDG/mdg.ts b/db/schema/MDG/mdg.ts index 7dd2db88..f4f68a20 100644 --- a/db/schema/MDG/mdg.ts +++ b/db/schema/MDG/mdg.ts @@ -1039,8 +1039,9 @@ export const MATERIAL_MASTER_PART_MATL_UNIT = mdgSchema.table('MATERIAL_MASTER_P // [별도 테이블] 자재그룹코드 마스터 - MATKL, MAKTX 중복 최소화 export const MATERIAL_GROUP_MASTER = mdgSchema.table('MATERIAL_GROUP_MASTER', { - MATKL: varchar('MATKL', { length: 1000 }).primaryKey(), // Material Group Code (자재그룹코드) - 기본 키 - MAKTX: varchar('MAKTX', { length: 1000 }), // Material Group Description (자재그룹명) - 영어(SPRAS='E') 기준 + materialGroupCode: varchar('material_group_code', { length: 1000 }).primaryKey(), // Material Group Code (자재그룹코드) - 기본 키 + materialGroupDescription: varchar('material_group_description', { length: 1000 }), // Material Group Description (자재그룹명) - 영어 + materialGroupUom: varchar('material_group_uom', { length: 1000 }), // Material Group UOM (UOM = Unit of Measure) createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), diff --git a/db/schema/items.ts b/db/schema/items.ts index e87f256e..e50ff491 100644 --- a/db/schema/items.ts +++ b/db/schema/items.ts @@ -88,16 +88,4 @@ export type ItemOffshoreHull = typeof itemOffshoreHull.$inferSelect; //각 테이블별 컬럼 변경(itemid -> itemCode) -// 자재그룹 검색용 뷰 - 새로운 MATERIAL_GROUP_MASTER 테이블 기반 -export const materialSearchView = pgView("material_search_view").as((qb) => { - return qb - .select({ - materialGroupCode: MATERIAL_GROUP_MASTER.MATKL, - materialGroupDesc: MATERIAL_GROUP_MASTER.MAKTX - }) - .from(MATERIAL_GROUP_MASTER) - .orderBy(MATERIAL_GROUP_MASTER.MATKL); -}); - -export type MaterialSearchView = typeof materialSearchView.$inferSelect; diff --git a/lib/material-groups/services.ts b/lib/material-groups/services.ts index be683077..3ee9886d 100644 --- a/lib/material-groups/services.ts +++ b/lib/material-groups/services.ts @@ -3,10 +3,10 @@ import { and, asc, desc, ilike, or, sql, eq } from 'drizzle-orm'; import db from '@/db/db'; import { filterColumns } from "@/lib/filter-columns"; -import { materialSearchView } from "@/db/schema/items"; +import { MATERIAL_GROUP_MASTER } from "@/db/schema/MDG/mdg"; -// 자재그룹 뷰의 컬럼 타입 정의 -type MaterialGroupColumn = keyof typeof materialSearchView.$inferSelect; +// 자재그룹 테이블의 컬럼 타입 정의 +type MaterialGroupColumn = keyof typeof MATERIAL_GROUP_MASTER.$inferSelect; export interface GetMaterialGroupsInput { page: number; @@ -16,13 +16,16 @@ export interface GetMaterialGroupsInput { id: MaterialGroupColumn; desc: boolean; }>; - filters?: any[]; + filters?: Array<{ + id: string; + value: unknown; + }>; joinOperator: 'and' | 'or'; } /** - * 자재그룹 목록을 조회합니다. - * materialSearchView를 사용하여 MATKL(자재그룹코드)와 ATWTB(자재그룹 설명)의 고유한 조합을 조회 + * 자재그룹 목록을 조회 + * 자재그룹코드/자재그룹명/UOM 조회 */ export async function getMaterialGroups(input: GetMaterialGroupsInput) { const safePerPage = Math.min(input.perPage, 100); @@ -32,8 +35,8 @@ export async function getMaterialGroups(input: GetMaterialGroupsInput) { // 고급 필터링 const advancedWhere = filterColumns({ - table: materialSearchView, - filters: (input.filters || []) as any, + table: MATERIAL_GROUP_MASTER, + filters: input.filters || [], joinOperator: input.joinOperator, }); @@ -42,8 +45,9 @@ export async function getMaterialGroups(input: GetMaterialGroupsInput) { if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(materialSearchView.materialGroupCode, s), // 자재그룹코드 - ilike(materialSearchView.materialGroupDesc, s), // 자재그룹명 + ilike(MATERIAL_GROUP_MASTER.materialGroupCode, s), // 자재그룹코드 + ilike(MATERIAL_GROUP_MASTER.materialGroupDescription, s), // 자재그룹명 + ilike(MATERIAL_GROUP_MASTER.materialGroupUom, s), // UOM ); } @@ -52,19 +56,20 @@ export async function getMaterialGroups(input: GetMaterialGroupsInput) { // 정렬 처리 - 타입 안전하게 처리 const orderBy = input.sort.length > 0 ? input.sort.map((item) => { - const column = materialSearchView[item.id]; + const column = MATERIAL_GROUP_MASTER[item.id]; return item.desc ? desc(column) : asc(column); }) - : [asc(materialSearchView.materialGroupCode)]; + : [asc(MATERIAL_GROUP_MASTER.materialGroupCode)]; // 데이터 조회 const { data, total } = await db.transaction(async (tx) => { const data = await tx .select({ - materialGroupCode: materialSearchView.materialGroupCode, - materialGroupDesc: materialSearchView.materialGroupDesc, + materialGroupCode: MATERIAL_GROUP_MASTER.materialGroupCode, + materialGroupDescription: MATERIAL_GROUP_MASTER.materialGroupDescription, + materialGroupUom: MATERIAL_GROUP_MASTER.materialGroupUom, }) - .from(materialSearchView) + .from(MATERIAL_GROUP_MASTER) .where(finalWhere) .orderBy(...orderBy) .offset(offset) @@ -74,7 +79,7 @@ export async function getMaterialGroups(input: GetMaterialGroupsInput) { .select({ count: sql<number>`count(*)` }) - .from(materialSearchView) + .from(MATERIAL_GROUP_MASTER) .where(finalWhere); const total = Number(totalResult[0]?.count) || 0; @@ -100,8 +105,8 @@ export async function getMaterialGroupsInfinite(input: GetMaterialGroupsInfinite try { // 고급 필터링 const advancedWhere = filterColumns({ - table: materialSearchView, - filters: (input.filters || []) as any, + table: MATERIAL_GROUP_MASTER, + filters: input.filters || [], joinOperator: input.joinOperator || "and", }); @@ -110,8 +115,9 @@ export async function getMaterialGroupsInfinite(input: GetMaterialGroupsInfinite if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(materialSearchView.materialGroupCode, s), - ilike(materialSearchView.materialGroupDesc, s), + ilike(MATERIAL_GROUP_MASTER.materialGroupCode, s), + ilike(MATERIAL_GROUP_MASTER.materialGroupDescription, s), + ilike(MATERIAL_GROUP_MASTER.materialGroupUom, s), ); } @@ -120,18 +126,19 @@ export async function getMaterialGroupsInfinite(input: GetMaterialGroupsInfinite // 정렬 처리 - 타입 안전하게 처리 const orderBy = input.sort.length > 0 ? input.sort.map((item) => { - const column = materialSearchView[item.id]; + const column = MATERIAL_GROUP_MASTER[item.id]; return item.desc ? desc(column) : asc(column); }) - : [asc(materialSearchView.materialGroupCode)]; + : [asc(MATERIAL_GROUP_MASTER.materialGroupCode)]; // 전체 데이터 조회 (클라이언트에서 가상화 처리) const data = await db .select({ - materialGroupCode: materialSearchView.materialGroupCode, - materialGroupDesc: materialSearchView.materialGroupDesc, + materialGroupCode: MATERIAL_GROUP_MASTER.materialGroupCode, + materialGroupDescription: MATERIAL_GROUP_MASTER.materialGroupDescription, + materialGroupUom: MATERIAL_GROUP_MASTER.materialGroupUom, }) - .from(materialSearchView) + .from(MATERIAL_GROUP_MASTER) .where(finalWhere) .orderBy(...orderBy); @@ -148,9 +155,13 @@ export async function getMaterialGroupsInfinite(input: GetMaterialGroupsInfinite export async function getMaterialGroupDetail(materialGroupCode: string) { try { const materialGroup = await db - .select() - .from(materialSearchView) - .where(eq(materialSearchView.materialGroupCode, materialGroupCode)) + .select({ + materialGroupCode: MATERIAL_GROUP_MASTER.materialGroupCode, + materialGroupDescription: MATERIAL_GROUP_MASTER.materialGroupDescription, + materialGroupUom: MATERIAL_GROUP_MASTER.materialGroupUom, + }) + .from(MATERIAL_GROUP_MASTER) + .where(eq(MATERIAL_GROUP_MASTER.materialGroupCode, materialGroupCode)) .limit(1); return materialGroup.length > 0 ? materialGroup[0] : null; diff --git a/lib/material-groups/table/material-group-table-columns.tsx b/lib/material-groups/table/material-group-table-columns.tsx index 08e730e3..f41ed887 100644 --- a/lib/material-groups/table/material-group-table-columns.tsx +++ b/lib/material-groups/table/material-group-table-columns.tsx @@ -8,7 +8,8 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- // MaterialGroup 타입 정의 (서비스에서 반환되는 타입과 일치) type MaterialGroup = { materialGroupCode: string | null; - materialGroupDesc: string | null; + materialGroupDescription: string | null; + materialGroupUom?: string | null; } /** @@ -36,12 +37,12 @@ export function getColumns(): ColumnDef<MaterialGroup>[] { enableHiding: false, }, { - accessorKey: "materialGroupDesc", + accessorKey: "materialGroupDescription", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="자재그룹명" /> ), cell: ({ row }) => { - const value = row.getValue("materialGroupDesc") as string | null + const value = row.getValue("materialGroupDescription") as string | null return ( <div className="max-w-[400px] truncate"> {value || "-"} @@ -51,6 +52,22 @@ export function getColumns(): ColumnDef<MaterialGroup>[] { enableSorting: true, enableHiding: false, }, + { + accessorKey: "materialGroupUom", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="UOM" /> + ), + cell: ({ row }) => { + const value = row.getValue("materialGroupUom") as string | null + return ( + <div className="max-w-[80px] truncate"> + {value || "-"} + </div> + ) + }, + enableSorting: true, + enableHiding: false, + }, ] // ---------------------------------------------------------------- diff --git a/lib/material-groups/table/material-group-table.tsx b/lib/material-groups/table/material-group-table.tsx index 766b5054..a5617e9b 100644 --- a/lib/material-groups/table/material-group-table.tsx +++ b/lib/material-groups/table/material-group-table.tsx @@ -20,7 +20,8 @@ import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" // MaterialGroup 타입 정의 (서비스에서 반환되는 타입과 일치) type MaterialGroup = { materialGroupCode: string | null; - materialGroupDesc: string | null; + materialGroupDescription: string | null; + materialGroupUom?: string | null; } interface MaterialGroupTableProps { @@ -50,9 +51,13 @@ export function MaterialGroupTable({ promises }: MaterialGroupTableProps) { label: "자재그룹코드", }, { - id: "materialGroupDesc", + id: "materialGroupDescription", label: "자재그룹명", }, + { + id: "materialGroupUom", + label: "UOM", + }, ] const advancedFilterFields: DataTableAdvancedFilterField<MaterialGroup>[] = [ @@ -62,10 +67,15 @@ export function MaterialGroupTable({ promises }: MaterialGroupTableProps) { type: "text", }, { - id: "materialGroupDesc", + id: "materialGroupDescription", label: "자재그룹명", type: "text", }, + { + id: "materialGroupUom", + label: "UOM", + type: "text", + }, ] // 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환) diff --git a/lib/material-groups/validations.ts b/lib/material-groups/validations.ts index c379c833..f2acfaee 100644 --- a/lib/material-groups/validations.ts +++ b/lib/material-groups/validations.ts @@ -8,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { materialSearchView } from "@/db/schema/items" +import { MATERIAL_GROUP_MASTER } from "@/db/schema/MDG/mdg" // MaterialGroup 검색 파라미터 캐시 정의 export const searchParamsCache = createSearchParamsCache({ @@ -17,7 +17,7 @@ export const searchParamsCache = createSearchParamsCache({ perPage: parseAsInteger.withDefault(10), // 확장된 타입으로 정렬 파서 사용 - sort: getSortingStateParser<typeof materialSearchView>().withDefault([ + sort: getSortingStateParser<typeof MATERIAL_GROUP_MASTER>().withDefault([ { id: "materialGroupCode", desc: false }, ]), diff --git a/lib/material/material-group-service.ts b/lib/material/material-group-service.ts index ae0ca725..bf58d243 100644 --- a/lib/material/material-group-service.ts +++ b/lib/material/material-group-service.ts @@ -2,11 +2,12 @@ import { sql, SQL } from "drizzle-orm"; import db from "@/db/db"; -import { materialSearchView } from "@/db/schema/items"; +import { MATERIAL_GROUP_MASTER } from "@/db/schema/MDG/mdg"; export interface MaterialSearchItem { materialGroupCode: string; - materialGroupDesc: string; + materialGroupDescription: string; + materialGroupUom?: string; displayText: string; // 애플리케이션 레벨에서 계산된 필드 } @@ -39,25 +40,29 @@ export async function searchMaterialsForSelector( if (query.trim()) { const searchPattern = `%${query.trim()}%`; searchWhere = sql`( - ${materialSearchView.materialGroupCode} ILIKE ${searchPattern} OR - ${materialSearchView.materialGroupDesc} ILIKE ${searchPattern} + ${MATERIAL_GROUP_MASTER.materialGroupCode} ILIKE ${searchPattern} OR + ${MATERIAL_GROUP_MASTER.materialGroupDescription} ILIKE ${searchPattern} )`; } const { data, total } = await db.transaction(async (tx) => { // 데이터 조회 const data = await tx - .select() - .from(materialSearchView) + .select({ + materialGroupCode: MATERIAL_GROUP_MASTER.materialGroupCode, + materialGroupDescription: MATERIAL_GROUP_MASTER.materialGroupDescription, + materialGroupUom: MATERIAL_GROUP_MASTER.materialGroupUom, + }) + .from(MATERIAL_GROUP_MASTER) .where(searchWhere) - .orderBy(materialSearchView.materialGroupCode, materialSearchView.materialGroupDesc) + .orderBy(MATERIAL_GROUP_MASTER.materialGroupCode, MATERIAL_GROUP_MASTER.materialGroupDescription) .limit(perPage) .offset(offset); // 총 개수 조회 const countResult = await tx .select({ count: sql<number>`count(*)` }) - .from(materialSearchView) + .from(MATERIAL_GROUP_MASTER) .where(searchWhere); const total = countResult[0]?.count || 0; @@ -65,8 +70,9 @@ export async function searchMaterialsForSelector( return { data: data.map((row) => ({ materialGroupCode: row.materialGroupCode, - materialGroupDesc: row.materialGroupDesc, - displayText: `${row.materialGroupCode || ''} - ${row.materialGroupDesc || ''}`, // 애플리케이션 레벨에서 생성 + materialGroupDescription: row.materialGroupDescription, + materialGroupUom: row.materialGroupUom, + displayText: `${row.materialGroupCode || ''} - ${row.materialGroupDescription || ''}`, // 애플리케이션 레벨에서 생성 })), total, }; diff --git a/lib/material/vendor-material/add-confirmed-material.tsx b/lib/material/vendor-material/add-confirmed-material.tsx index 5e9415f4..78940c00 100644 --- a/lib/material/vendor-material/add-confirmed-material.tsx +++ b/lib/material/vendor-material/add-confirmed-material.tsx @@ -3,20 +3,8 @@ import * as React from "react"; import { useState } from "react"; import { useSession } from "next-auth/react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; import { toast } from "sonner"; -import { Plus, Loader2 } from "lucide-react"; -import { MaterialGroupSelector } from "@/components/common/material/material-group-selector"; +import { MaterialGroupSelectorDialogMulti } from "@/components/common/material/material-group-selector-dialog-multi"; import { MaterialSearchItem } from "@/lib/material/material-group-service"; import { addConfirmedMaterial, VendorPossibleMaterial } from "../vendor-possible-material-service"; @@ -32,43 +20,22 @@ export function AddConfirmedMaterial({ onMaterialAdded, }: AddConfirmedMaterialProps) { const { data: session } = useSession(); - const [open, setOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedMaterials, setSelectedMaterials] = useState<MaterialSearchItem[]>([]); // 이미 등록된 자재그룹코드들의 Set const existingMaterialCodes = new Set( - existingConfirmedMaterials.map(material => material.itemCode).filter(Boolean) + existingConfirmedMaterials.map(material => material.itemCode).filter((code): code is string => Boolean(code)) ); - // 자재 선택 시 중복 체크 - const handleMaterialsChange = (materials: MaterialSearchItem[]) => { - // 이미 등록된 자재가 있는지 확인 - const duplicatedMaterials = materials.filter(material => - existingMaterialCodes.has(material.materialGroupCode) - ); - - if (duplicatedMaterials.length > 0) { - const duplicatedCodes = duplicatedMaterials.map(m => m.materialGroupCode).join(', '); - toast.error(`이미 등록된 자재그룹코드입니다: ${duplicatedCodes}`); - - // 중복되지 않은 자재만 선택 - const validMaterials = materials.filter(material => - !existingMaterialCodes.has(material.materialGroupCode) - ); - setSelectedMaterials(validMaterials); - } else { - setSelectedMaterials(materials); - } - }; - - const handleSubmit = async () => { + // 자재 선택 완료 시 처리 + const handleMaterialsSelect = async (materials: MaterialSearchItem[]) => { if (!session?.user) { toast.error("로그인이 필요합니다."); return; } - if (selectedMaterials.length === 0) { + if (materials.length === 0) { toast.error("추가할 자재를 선택해주세요."); return; } @@ -77,10 +44,10 @@ export function AddConfirmedMaterial({ try { // 선택된 자재들을 각각 추가 - for (const material of selectedMaterials) { + for (const material of materials) { const materialData = { itemCode: material.materialGroupCode, - itemName: material.materialName, + itemName: material.materialGroupDescription, }; await addConfirmedMaterial( @@ -91,11 +58,10 @@ export function AddConfirmedMaterial({ ); } - toast.success(`${selectedMaterials.length}개의 자재가 확정정보에 추가되었습니다.`); + toast.success(`${materials.length}개의 자재가 확정정보에 추가되었습니다.`); // 폼 리셋 setSelectedMaterials([]); - setOpen(false); // 부모 컴포넌트에 추가 완료 알림 onMaterialAdded?.(); @@ -119,65 +85,18 @@ export function AddConfirmedMaterial({ }; return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button size="sm" className="gap-2"> - <Plus className="h-4 w-4" /> - 추가 등록 - </Button> - </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>확정정보 자재 추가</DialogTitle> - <DialogDescription> - 구매담당자 권한으로 확정 공급품목을 추가합니다. 상세 정보는 I/F를 통해 업데이트됩니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 자재 선택 */} - <div className="space-y-2"> - <Label>자재 선택 *</Label> - <MaterialGroupSelector - selectedMaterials={selectedMaterials} - onMaterialsChange={handleMaterialsChange} - singleSelect={false} - placeholder="자재그룹코드 또는 자재명으로 검색..." - noValuePlaceHolder="자재를 선택해주세요" - maxSelections={10} - closeOnSelect={false} - excludeMaterialCodes={existingMaterialCodes} - /> - {existingMaterialCodes.size > 0 && ( - <p className="text-xs text-muted-foreground"> - 💡 이미 등록된 자재그룹코드는 자동으로 제외됩니다. - </p> - )} - <p className="text-sm text-muted-foreground"> - 최대 10개까지 선택 가능합니다. 등록자는 현재 로그인한 사용자로 자동 설정됩니다. - </p> - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setOpen(false)} - disabled={isLoading} - > - 취소 - </Button> - <Button - type="button" - onClick={handleSubmit} - disabled={isLoading || selectedMaterials.length === 0} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "추가 중..." : "추가"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + <MaterialGroupSelectorDialogMulti + triggerLabel={isLoading ? "추가 중..." : "➕ 추가 등록"} + selectedMaterials={selectedMaterials} + onMaterialsSelect={handleMaterialsSelect} + placeholder="자재그룹코드 또는 자재명으로 검색..." + title="확정정보 자재 추가" + description="구매담당자 권한으로 확정 공급품목을 추가합니다. 상세 정보는 I/F를 통해 업데이트됩니다." + disabled={isLoading} + triggerVariant="default" + excludeMaterialCodes={existingMaterialCodes} + showInitialData={true} + showSelectedInTrigger={false} + /> ); } |
