summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-02 08:44:17 +0000
committerjoonhoekim <26rote@gmail.com>2025-09-02 08:44:17 +0000
commit8da223a416ec7d2be5743f312ed1d8c6d64949e2 (patch)
tree6333679b326e3508913774f3d5afaabca1f4f198
parent6eb06a925811cfefb34b6c286f6bdfe2f214ac2b (diff)
(김준회) 협력업체 관리 메뉴에서, 공급품목(패키지) 제거, MDG 자재마스터 기반 벤더별 공급품목 메뉴 구현 (정의서+강미경프로 요구대로 구현)
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx10
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/materials/page.tsx43
-rw-r--r--lib/material/vendor-material/add-confirmed-material.tsx183
-rw-r--r--lib/material/vendor-material/columns.tsx154
-rw-r--r--lib/material/vendor-material/confirmed-materials-table.tsx77
-rw-r--r--lib/material/vendor-material/simple-vendor-materials-wrapper.tsx48
-rw-r--r--lib/material/vendor-material/simple-vendor-materials.tsx372
-rw-r--r--lib/material/vendor-material/vendor-input-materials-table.tsx64
-rw-r--r--lib/material/vendor-material/vendor-materials-client.tsx39
9 files changed, 957 insertions, 33 deletions
diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx
index 0817b309..17f22de9 100644
--- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx
@@ -42,12 +42,12 @@ export default async function SettingsLayout({
title: "신용평가",
href: `/${lng}/evcp/vendors/${id}/info/credit`,
},
+ // {
+ // title: "공급품목(패키지)",
+ // href: `/${lng}/evcp/vendors/${id}/info/items`,
+ // },
{
- title: "공급품목(패키지)",
- href: `/${lng}/evcp/vendors/${id}/info/items`,
- },
- {
- title: "공급품목(자재그룹)",
+ title: "공급품목",
href: `/${lng}/evcp/vendors/${id}/info/materials`,
},
{
diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/materials/page.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/materials/page.tsx
index 0ebb66ba..3115ae30 100644
--- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/materials/page.tsx
+++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/materials/page.tsx
@@ -1,9 +1,6 @@
import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsMaterialCache } from "@/lib/vendors/validations"
-import { getVendorMaterials } from "@/lib/vendors/service"
-import { VendorMaterialsTable } from "@/lib/vendors/materials-table/item-table"
+import { getAllConfirmedMaterials, getAllVendorInputMaterials } from "@/lib/material/vendor-possible-material-service"
+import { SimpleVendorMaterialsWrapper } from "@/lib/material/vendor-material/simple-vendor-materials-wrapper"
interface IndexPageProps {
// Next.js 13 App Router에서 기본으로 주어지는 객체들
@@ -11,32 +8,19 @@ interface IndexPageProps {
lng: string
id: string
}
- searchParams: Promise<SearchParams>
}
-export default async function SettingsAccountPage(props: IndexPageProps) {
+export default async function VendorMaterialsPage(props: IndexPageProps) {
const resolvedParams = await props.params
- const lng = resolvedParams.lng
const id = resolvedParams.id
-
const idAsNumber = Number(id)
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsMaterialCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
+ // 전체 데이터를 병렬로 조회 (클라이언트에서 필터링/정렬 처리)
+ const [confirmedMaterials, vendorInputMaterials] = await Promise.all([
+ getAllConfirmedMaterials(idAsNumber), // 확정정보 전체
+ getAllVendorInputMaterials(idAsNumber) // 업체입력정보 전체
+ ]);
- const promises = Promise.all([
- getVendorMaterials({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
- // 4) 렌더링
return (
<div className="space-y-6">
<div>
@@ -44,13 +28,16 @@ export default async function SettingsAccountPage(props: IndexPageProps) {
공급품목(자재 그룹)
</h3>
<p className="text-sm text-muted-foreground">
- {/* 딜리버리가 가능한 공급품목(자재 그룹)을 확인할 수 있습니다. */}
+ 업체의 공급 가능한 자재 그룹 정보를 확인할 수 있습니다.
</p>
</div>
<Separator />
- <div>
- <VendorMaterialsTable promises={promises} vendorId={idAsNumber}/>
- </div>
+
+ <SimpleVendorMaterialsWrapper
+ vendorId={idAsNumber}
+ initialConfirmedMaterials={confirmedMaterials}
+ initialVendorInputMaterials={vendorInputMaterials}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/material/vendor-material/add-confirmed-material.tsx b/lib/material/vendor-material/add-confirmed-material.tsx
new file mode 100644
index 00000000..bc232a1b
--- /dev/null
+++ b/lib/material/vendor-material/add-confirmed-material.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+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 { MaterialSelector } from "@/components/common/material/material-selector";
+import { MaterialSearchItem } from "@/lib/material/material-group-service";
+import { addConfirmedMaterial, VendorPossibleMaterial } from "../vendor-possible-material-service";
+
+interface AddConfirmedMaterialProps {
+ vendorId: number;
+ existingConfirmedMaterials: VendorPossibleMaterial[];
+ onMaterialAdded?: () => void;
+}
+
+export function AddConfirmedMaterial({
+ vendorId,
+ existingConfirmedMaterials,
+ 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)
+ );
+
+ // 자재 선택 시 중복 체크
+ 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 () => {
+ if (!session?.user) {
+ toast.error("로그인이 필요합니다.");
+ return;
+ }
+
+ if (selectedMaterials.length === 0) {
+ toast.error("추가할 자재를 선택해주세요.");
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // 선택된 자재들을 각각 추가
+ for (const material of selectedMaterials) {
+ const materialData = {
+ itemCode: material.materialGroupCode,
+ itemName: material.materialName,
+ };
+
+ await addConfirmedMaterial(
+ vendorId,
+ materialData,
+ Number(session.user.id),
+ session.user.name || "알 수 없음"
+ );
+ }
+
+ toast.success(`${selectedMaterials.length}개의 자재가 확정정보에 추가되었습니다.`);
+
+ // 폼 리셋
+ setSelectedMaterials([]);
+ setOpen(false);
+
+ // 부모 컴포넌트에 추가 완료 알림
+ onMaterialAdded?.();
+
+ } catch (error) {
+ console.error("자재 추가 실패:", error);
+
+ // 에러 메시지를 더 구체적으로 표시
+ if (error instanceof Error) {
+ if (error.message.includes("이미 확정정보에 등록되어 있습니다")) {
+ toast.error(error.message);
+ } else {
+ toast.error(`자재 추가 실패: ${error.message}`);
+ }
+ } else {
+ toast.error("자재 추가 중 알 수 없는 오류가 발생했습니다.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ 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>
+ <MaterialSelector
+ 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>
+ );
+}
diff --git a/lib/material/vendor-material/columns.tsx b/lib/material/vendor-material/columns.tsx
new file mode 100644
index 00000000..8f706d63
--- /dev/null
+++ b/lib/material/vendor-material/columns.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { VendorPossibleMaterial } from "../vendor-possible-material-service";
+import { formatDate } from "@/lib/utils";
+
+// 확정정보 테이블 컬럼
+export const confirmedMaterialsColumns: ColumnDef<VendorPossibleMaterial>[] = [
+ {
+ accessorKey: "vendorTypeNameEn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체유형" />
+ ),
+ cell: ({ row }) => {
+ const vendorTypeNameEn = row.getValue("vendorTypeNameEn") as string;
+ return <span>{vendorTypeNameEn || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ cell: ({ row }) => {
+ const itemCode = row.getValue("itemCode") as string;
+ return <span className="font-mono">{itemCode || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹명" />
+ ),
+ cell: ({ row }) => {
+ const itemName = row.getValue("itemName") as string;
+ return <span>{itemName || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "recentPoNo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최근 Po No." />
+ ),
+ cell: ({ row }) => {
+ const recentPoNo = row.getValue("recentPoNo") as string;
+ return <span className="font-mono">{recentPoNo || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "recentPoDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최근 Po일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("recentPoDate") as Date;
+ return <span>{date ? formatDate(date) : "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "recentDeliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최근 납품일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("recentDeliveryDate") as Date;
+ return <span>{date ? formatDate(date) : "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date;
+ return <span>{formatDate(date)}</span>;
+ },
+ },
+ {
+ accessorKey: "registerUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자명" />
+ ),
+ cell: ({ row }) => {
+ const userName = row.getValue("registerUserName") as string;
+ return <span>{userName || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "recentOrderDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최근발주일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("recentOrderDate") as Date;
+ return <span>{date ? formatDate(date) : "-"}</span>;
+ },
+ },
+];
+
+// 업체입력정보 테이블 컬럼
+export const vendorInputMaterialsColumns: ColumnDef<VendorPossibleMaterial>[] = [
+ {
+ accessorKey: "vendorTypeNameEn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체유형" />
+ ),
+ cell: ({ row }) => {
+ const vendorTypeNameEn = row.getValue("vendorTypeNameEn") as string;
+ return <span>{vendorTypeNameEn || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ cell: ({ row }) => {
+ const itemCode = row.getValue("itemCode") as string;
+ return <span className="font-mono">{itemCode || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹명" />
+ ),
+ cell: ({ row }) => {
+ const itemName = row.getValue("itemName") as string;
+ return <span>{itemName || "-"}</span>;
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date;
+ return <span>{formatDate(date)}</span>;
+ },
+ },
+ {
+ accessorKey: "registerUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자명" />
+ ),
+ cell: ({ row }) => {
+ const userName = row.getValue("registerUserName") as string;
+ return <span>{userName || "-"}</span>;
+ },
+ },
+];
diff --git a/lib/material/vendor-material/confirmed-materials-table.tsx b/lib/material/vendor-material/confirmed-materials-table.tsx
new file mode 100644
index 00000000..11282a24
--- /dev/null
+++ b/lib/material/vendor-material/confirmed-materials-table.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import * as React from "react";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableToolbar } from "@/components/data-table/data-table-toolbar";
+import { Button } from "@/components/ui/button";
+import { Plus } from "lucide-react";
+import { confirmedMaterialsColumns } from "./columns";
+import { useDataTable } from "@/hooks/use-data-table";
+import { getConfirmedMaterials, VendorPossibleMaterial } from "../vendor-possible-material-service";
+
+interface ConfirmedMaterialsTableProps {
+ vendorId: number;
+ data: VendorPossibleMaterial[];
+ pageCount: number;
+}
+
+export function ConfirmedMaterialsTable({
+ vendorId,
+ data,
+ pageCount,
+}: ConfirmedMaterialsTableProps) {
+ const { table } = useDataTable({
+ data,
+ columns: confirmedMaterialsColumns,
+ pageCount,
+ filterFields: [
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ placeholder: "자재그룹 검색...",
+ },
+ {
+ id: "itemName",
+ label: "자재그룹명",
+ placeholder: "자재그룹명 검색...",
+ },
+ ],
+ });
+
+ const handleAddMaterial = () => {
+ // TODO: 확정정보 추가 다이얼로그 열기
+ console.log("확정정보 추가 버튼 클릭");
+ };
+
+ return (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">확정정보</h3>
+ <Button onClick={handleAddMaterial} className="gap-2">
+ <Plus className="h-4 w-4" />
+ 추가등록
+ </Button>
+ </div>
+
+ <DataTable table={table}>
+ <DataTableToolbar
+ table={table}
+ filterFields={[
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ placeholder: "자재그룹 검색...",
+ },
+ {
+ id: "itemName",
+ label: "자재그룹명",
+ placeholder: "자재그룹명 검색...",
+ },
+ ]}
+ >
+ {/* 컬럼 선택 기능 제거 - DataTableViewOptions 없음 */}
+ </DataTableToolbar>
+ </DataTable>
+ </div>
+ );
+}
diff --git a/lib/material/vendor-material/simple-vendor-materials-wrapper.tsx b/lib/material/vendor-material/simple-vendor-materials-wrapper.tsx
new file mode 100644
index 00000000..7c0c7068
--- /dev/null
+++ b/lib/material/vendor-material/simple-vendor-materials-wrapper.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import { useState, useCallback } from "react";
+import { SimpleVendorMaterials } from "./simple-vendor-materials";
+import { VendorPossibleMaterial, getAllConfirmedMaterials, getAllVendorInputMaterials } from "../vendor-possible-material-service";
+
+interface SimpleVendorMaterialsWrapperProps {
+ vendorId: number;
+ initialConfirmedMaterials: VendorPossibleMaterial[];
+ initialVendorInputMaterials: VendorPossibleMaterial[];
+}
+
+export function SimpleVendorMaterialsWrapper({
+ vendorId,
+ initialConfirmedMaterials,
+ initialVendorInputMaterials,
+}: SimpleVendorMaterialsWrapperProps) {
+ const [confirmedMaterials, setConfirmedMaterials] = useState(initialConfirmedMaterials);
+ const [vendorInputMaterials, setVendorInputMaterials] = useState(initialVendorInputMaterials);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleDataRefresh = useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ const [newConfirmedMaterials, newVendorInputMaterials] = await Promise.all([
+ getAllConfirmedMaterials(vendorId),
+ getAllVendorInputMaterials(vendorId)
+ ]);
+
+ setConfirmedMaterials(newConfirmedMaterials);
+ setVendorInputMaterials(newVendorInputMaterials);
+ } catch (error) {
+ console.error("데이터 새로고침 실패:", error);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [vendorId]);
+
+ return (
+ <SimpleVendorMaterials
+ vendorId={vendorId}
+ confirmedMaterials={confirmedMaterials}
+ vendorInputMaterials={vendorInputMaterials}
+ onDataRefresh={handleDataRefresh}
+ />
+ );
+}
diff --git a/lib/material/vendor-material/simple-vendor-materials.tsx b/lib/material/vendor-material/simple-vendor-materials.tsx
new file mode 100644
index 00000000..73fdfd18
--- /dev/null
+++ b/lib/material/vendor-material/simple-vendor-materials.tsx
@@ -0,0 +1,372 @@
+"use client";
+
+import * as React from "react";
+import { useState, useMemo } from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { ArrowUpDown, Search } from "lucide-react";
+import { VendorPossibleMaterial } from "../vendor-possible-material-service";
+import { AddConfirmedMaterial } from "./add-confirmed-material";
+// formatDate 함수 제거 - YYYY-MM-DD 형식으로 직접 포맷
+
+interface SimpleVendorMaterialsProps {
+ vendorId: number;
+ confirmedMaterials: VendorPossibleMaterial[];
+ vendorInputMaterials: VendorPossibleMaterial[];
+ onDataRefresh?: () => void;
+}
+
+type SortField = "itemCode" | "itemName" | "createdAt" | "recentPoDate" | "recentDeliveryDate" | "vendorTypeNameEn" | "recentPoNo" | "registerUserName";
+type SortDirection = "asc" | "desc";
+
+interface SortState {
+ field: SortField | null;
+ direction: SortDirection;
+}
+
+export function SimpleVendorMaterials({
+ vendorId,
+ confirmedMaterials,
+ vendorInputMaterials,
+ onDataRefresh,
+}: SimpleVendorMaterialsProps) {
+ // 날짜를 YYYY-MM-DD 형식으로 포맷하는 함수
+ const formatDateToYMD = (date: Date | null | undefined): string => {
+ if (!date) return "-";
+ return date.toISOString().split('T')[0];
+ };
+ const [confirmedSearch, setConfirmedSearch] = useState("");
+ const [vendorInputSearch, setVendorInputSearch] = useState("");
+ const [confirmedSort, setConfirmedSort] = useState<SortState>({ field: null, direction: "asc" });
+ const [vendorInputSort, setVendorInputSort] = useState<SortState>({ field: null, direction: "asc" });
+
+ // 검색 필터링 함수
+ const filterMaterials = (materials: VendorPossibleMaterial[], searchTerm: string) => {
+ if (!searchTerm.trim()) return materials;
+
+ const lowercaseSearch = searchTerm.toLowerCase();
+ return materials.filter(material =>
+ (material.itemCode?.toLowerCase().includes(lowercaseSearch)) ||
+ (material.itemName?.toLowerCase().includes(lowercaseSearch))
+ );
+ };
+
+ // 정렬 함수
+ const sortMaterials = (materials: VendorPossibleMaterial[], sortState: SortState) => {
+ if (!sortState.field) return materials;
+
+ return [...materials].sort((a, b) => {
+ let aValue: string | Date;
+ let bValue: string | Date;
+
+ switch (sortState.field) {
+ case "itemCode":
+ aValue = a.itemCode || "";
+ bValue = b.itemCode || "";
+ break;
+ case "itemName":
+ aValue = a.itemName || "";
+ bValue = b.itemName || "";
+ break;
+ case "createdAt":
+ aValue = a.createdAt;
+ bValue = b.createdAt;
+ break;
+ case "recentPoDate":
+ aValue = a.recentPoDate || new Date(0);
+ bValue = b.recentPoDate || new Date(0);
+ break;
+ case "recentDeliveryDate":
+ aValue = a.recentDeliveryDate || new Date(0);
+ bValue = b.recentDeliveryDate || new Date(0);
+ break;
+ case "vendorTypeNameEn":
+ aValue = a.vendorTypeNameEn || "";
+ bValue = b.vendorTypeNameEn || "";
+ break;
+ case "recentPoNo":
+ aValue = a.recentPoNo || "";
+ bValue = b.recentPoNo || "";
+ break;
+ case "registerUserName":
+ aValue = a.registerUserName || "";
+ bValue = b.registerUserName || "";
+ break;
+ default:
+ return 0;
+ }
+
+ if (aValue < bValue) return sortState.direction === "asc" ? -1 : 1;
+ if (aValue > bValue) return sortState.direction === "asc" ? 1 : -1;
+ return 0;
+ });
+ };
+
+ // 필터링 및 정렬된 데이터
+ const filteredAndSortedConfirmed = useMemo(() => {
+ const filtered = filterMaterials(confirmedMaterials, confirmedSearch);
+ return sortMaterials(filtered, confirmedSort);
+ }, [confirmedMaterials, confirmedSearch, confirmedSort]);
+
+ const filteredAndSortedVendorInput = useMemo(() => {
+ const filtered = filterMaterials(vendorInputMaterials, vendorInputSearch);
+ return sortMaterials(filtered, vendorInputSort);
+ }, [vendorInputMaterials, vendorInputSearch, vendorInputSort]);
+
+ // 정렬 핸들러
+ const handleSort = (
+ field: SortField,
+ currentSort: SortState,
+ setSort: React.Dispatch<React.SetStateAction<SortState>>
+ ) => {
+ setSort(prev => ({
+ field,
+ direction: prev.field === field && prev.direction === "asc" ? "desc" : "asc"
+ }));
+ };
+
+ // 정렬 버튼 컴포넌트
+ const SortButton = ({
+ field,
+ children,
+ onSort
+ }: {
+ field: SortField;
+ children: React.ReactNode;
+ onSort: (field: SortField) => void;
+ }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 px-2"
+ onClick={() => onSort(field)}
+ >
+ {children}
+ <ArrowUpDown className="ml-2 h-4 w-4" />
+ </Button>
+ );
+
+ return (
+ <div className="space-y-8">
+ {/* 확정정보 테이블 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>확정정보</CardTitle>
+ <CardDescription>
+ 구매담당자가 확정한 공급 품목 정보입니다.
+ </CardDescription>
+ </div>
+ <AddConfirmedMaterial
+ vendorId={vendorId}
+ existingConfirmedMaterials={confirmedMaterials}
+ onMaterialAdded={onDataRefresh}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="자재그룹코드 또는 자재그룹명으로 검색..."
+ value={confirmedSearch}
+ onChange={(e) => setConfirmedSearch(e.target.value)}
+ className="max-w-sm"
+ />
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="rounded-md border max-h-96 overflow-auto">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead>
+ <SortButton
+ field="vendorTypeNameEn"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 업체유형
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="itemCode"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 자재그룹코드
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="itemName"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 자재그룹명
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="recentPoNo"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 최근 Po No.
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="recentPoDate"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 최근 Po일
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="recentDeliveryDate"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 최근 납품일
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="createdAt"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 등록일
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="registerUserName"
+ onSort={(field) => handleSort(field, confirmedSort, setConfirmedSort)}
+ >
+ 등록자명
+ </SortButton>
+ </TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredAndSortedConfirmed.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={8} className="text-center text-muted-foreground">
+ {confirmedSearch ? "검색 결과가 없습니다." : "확정된 공급품목이 없습니다."}
+ </TableCell>
+ </TableRow>
+ ) : (
+ filteredAndSortedConfirmed.map((material) => (
+ <TableRow key={material.id}>
+ <TableCell>{material.vendorTypeNameEn || "-"}</TableCell>
+ <TableCell className="font-mono">{material.itemCode || "-"}</TableCell>
+ <TableCell>{material.itemName || "-"}</TableCell>
+ <TableCell className="font-mono">{material.recentPoNo || "-"}</TableCell>
+ <TableCell>{formatDateToYMD(material.recentPoDate)}</TableCell>
+ <TableCell>{formatDateToYMD(material.recentDeliveryDate)}</TableCell>
+ <TableCell>{formatDateToYMD(material.createdAt)}</TableCell>
+ <TableCell>{material.registerUserName || "-"}</TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ <div className="mt-2 text-sm text-muted-foreground">
+ 총 {filteredAndSortedConfirmed.length}건
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 업체입력정보 테이블 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>업체입력정보</CardTitle>
+ <CardDescription>
+ 업체에서 입력한 공급 가능 품목 정보입니다.
+ </CardDescription>
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="자재그룹코드 또는 자재그룹명으로 검색..."
+ value={vendorInputSearch}
+ onChange={(e) => setVendorInputSearch(e.target.value)}
+ className="max-w-sm"
+ />
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="rounded-md border max-h-96 overflow-auto">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead>
+ <SortButton
+ field="vendorTypeNameEn"
+ onSort={(field) => handleSort(field, vendorInputSort, setVendorInputSort)}
+ >
+ 업체유형
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="itemCode"
+ onSort={(field) => handleSort(field, vendorInputSort, setVendorInputSort)}
+ >
+ 자재그룹코드
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="itemName"
+ onSort={(field) => handleSort(field, vendorInputSort, setVendorInputSort)}
+ >
+ 자재그룹명
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="createdAt"
+ onSort={(field) => handleSort(field, vendorInputSort, setVendorInputSort)}
+ >
+ 등록일
+ </SortButton>
+ </TableHead>
+ <TableHead>
+ <SortButton
+ field="registerUserName"
+ onSort={(field) => handleSort(field, vendorInputSort, setVendorInputSort)}
+ >
+ 등록자명
+ </SortButton>
+ </TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredAndSortedVendorInput.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={5} className="text-center text-muted-foreground">
+ {vendorInputSearch ? "검색 결과가 없습니다." : "업체입력 정보가 없습니다."}
+ </TableCell>
+ </TableRow>
+ ) : (
+ filteredAndSortedVendorInput.map((material) => (
+ <TableRow key={material.id}>
+ <TableCell>{material.vendorTypeNameEn || "-"}</TableCell>
+ <TableCell className="font-mono">{material.itemCode || "-"}</TableCell>
+ <TableCell>{material.itemName || "-"}</TableCell>
+ <TableCell>{formatDateToYMD(material.createdAt)}</TableCell>
+ <TableCell>{material.registerUserName || "-"}</TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ <div className="mt-2 text-sm text-muted-foreground">
+ 총 {filteredAndSortedVendorInput.length}건
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
diff --git a/lib/material/vendor-material/vendor-input-materials-table.tsx b/lib/material/vendor-material/vendor-input-materials-table.tsx
new file mode 100644
index 00000000..8d774bee
--- /dev/null
+++ b/lib/material/vendor-material/vendor-input-materials-table.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import * as React from "react";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableToolbar } from "@/components/data-table/data-table-toolbar";
+import { vendorInputMaterialsColumns } from "./columns";
+import { useDataTable } from "@/hooks/use-data-table";
+import { VendorPossibleMaterial } from "../vendor-possible-material-service";
+
+interface VendorInputMaterialsTableProps {
+ vendorId: number;
+ data: VendorPossibleMaterial[];
+ pageCount: number;
+}
+
+export function VendorInputMaterialsTable({
+ vendorId,
+ data,
+ pageCount,
+}: VendorInputMaterialsTableProps) {
+ const { table } = useDataTable({
+ data,
+ columns: vendorInputMaterialsColumns,
+ pageCount,
+ filterFields: [
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ placeholder: "자재그룹 검색...",
+ },
+ {
+ id: "itemName",
+ label: "자재그룹명",
+ placeholder: "자재그룹명 검색...",
+ },
+ ],
+ });
+
+ return (
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">업체입력정보</h3>
+
+ <DataTable table={table}>
+ <DataTableToolbar
+ table={table}
+ filterFields={[
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ placeholder: "자재그룹 검색...",
+ },
+ {
+ id: "itemName",
+ label: "자재그룹명",
+ placeholder: "자재그룹명 검색...",
+ },
+ ]}
+ >
+ {/* 컬럼 선택 기능 제거 - DataTableViewOptions 없음 */}
+ </DataTableToolbar>
+ </DataTable>
+ </div>
+ );
+}
diff --git a/lib/material/vendor-material/vendor-materials-client.tsx b/lib/material/vendor-material/vendor-materials-client.tsx
new file mode 100644
index 00000000..412dc4b9
--- /dev/null
+++ b/lib/material/vendor-material/vendor-materials-client.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import * as React from "react";
+import { Separator } from "@/components/ui/separator";
+import { ConfirmedMaterialsTable } from "./confirmed-materials-table";
+import { VendorInputMaterialsTable } from "./vendor-input-materials-table";
+import { VendorMaterialsResult } from "../vendor-possible-material-service";
+
+interface VendorMaterialsClientProps {
+ vendorId: number;
+ confirmedMaterials: VendorMaterialsResult;
+ vendorInputMaterials: VendorMaterialsResult;
+}
+
+export function VendorMaterialsClient({
+ vendorId,
+ confirmedMaterials,
+ vendorInputMaterials,
+}: VendorMaterialsClientProps) {
+ return (
+ <div className="space-y-8">
+ {/* 확정정보 테이블 */}
+ <ConfirmedMaterialsTable
+ vendorId={vendorId}
+ data={confirmedMaterials.data}
+ pageCount={confirmedMaterials.pageCount}
+ />
+
+ <Separator />
+
+ {/* 업체입력정보 테이블 */}
+ <VendorInputMaterialsTable
+ vendorId={vendorId}
+ data={vendorInputMaterials.data}
+ pageCount={vendorInputMaterials.pageCount}
+ />
+ </div>
+ );
+}