summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-17 18:04:10 +0900
committerjoonhoekim <26rote@gmail.com>2025-09-17 18:04:10 +0900
commit7433eea5b4bbc0899e255b88e1a7e91f26e9d95b (patch)
treecad02c119fd41545e24a98734488962c78ed895d
parent6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 (diff)
(김준회) 자재그룹 선택기 오류수정, 공용사용을 위한 다이얼로그 컴포넌트 구현, data-table 오류 수정
-rw-r--r--app/[lng]/evcp/(evcp)/material-groups/page.tsx8
-rw-r--r--app/api/(S-ERP)/(MDG)/IF_MDZ_EVCP_MATERIAL_MASTER_PART/route.ts49
-rw-r--r--components/common/material/material-group-selector-dialog-multi.tsx299
-rw-r--r--components/common/material/material-group-selector-dialog-single.tsx208
-rw-r--r--components/common/material/material-group-selector.tsx80
-rw-r--r--components/data-table/data-table-filter-list.tsx20
-rw-r--r--components/data-table/data-table-grobal-filter.tsx15
-rw-r--r--db/schema/MDG/mdg.ts5
-rw-r--r--db/schema/items.ts12
-rw-r--r--lib/material-groups/services.ts67
-rw-r--r--lib/material-groups/table/material-group-table-columns.tsx23
-rw-r--r--lib/material-groups/table/material-group-table.tsx16
-rw-r--r--lib/material-groups/validations.ts4
-rw-r--r--lib/material/material-group-service.ts26
-rw-r--r--lib/material/vendor-material/add-confirmed-material.tsx123
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}
+ />
);
}