summaryrefslogtreecommitdiff
path: root/lib/contact-possible-items
diff options
context:
space:
mode:
Diffstat (limited to 'lib/contact-possible-items')
-rw-r--r--lib/contact-possible-items/service.ts190
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table-columns.tsx301
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx92
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table.tsx102
-rw-r--r--lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx111
-rw-r--r--lib/contact-possible-items/validations.ts59
6 files changed, 855 insertions, 0 deletions
diff --git a/lib/contact-possible-items/service.ts b/lib/contact-possible-items/service.ts
new file mode 100644
index 00000000..f4b89368
--- /dev/null
+++ b/lib/contact-possible-items/service.ts
@@ -0,0 +1,190 @@
+"use server"
+
+import db from "@/db/db"
+import { techSalesContactPossibleItems } from "@/db/schema/techSales"
+import { techVendors, techVendorContacts, techVendorPossibleItems } from "@/db/schema/techVendors"
+import { eq, desc, ilike, count, or } from "drizzle-orm"
+import { revalidatePath } from "next/cache"
+import { unstable_noStore } from "next/cache"
+import { GetContactPossibleItemsSchema } from "./validations"
+
+// 담당자별 아이템 상세 타입 정의 (뷰 기반)
+export interface ContactPossibleItemDetail {
+ id: number
+ contactId: number
+ vendorPossibleItemId: number
+ createdAt: Date
+ updatedAt: Date
+
+ // 벤더 정보
+ vendorId: number
+ vendorName: string | null
+ vendorCode: string | null
+ vendorEmail: string | null
+ vendorPhone: string | null
+ vendorCountry: string | null
+ vendorStatus: string | null
+ techVendorType: string | null
+
+ // 연락처 정보
+ contactName: string | null
+ contactPosition: string | null
+ contactEmail: string | null
+ contactPhone: string | null
+ contactCountry: string | null
+ isPrimary: boolean | null
+
+ // 아이템 정보
+ itemCode: string | null
+ workType: string | null
+ shipTypes: string | null
+ itemList: string | null
+ subItemList: string | null
+}
+
+/**
+ * 담당자별 아이템 목록 조회 (뷰 사용)
+ */
+export async function getContactPossibleItems(input: GetContactPossibleItemsSchema) {
+ unstable_noStore()
+
+ try {
+ const offset = (input.page - 1) * input.per_page
+
+ console.log("=== getContactPossibleItems DEBUG ===")
+ console.log("Input:", input)
+ console.log("Offset:", offset)
+
+ // 검색 조건
+ let whereCondition
+ if (input.search) {
+ const searchTerm = `%${input.search}%`
+ whereCondition = or(
+ ilike(techVendorPossibleItems.itemCode, searchTerm),
+ ilike(techVendorPossibleItems.itemList, searchTerm),
+ ilike(techVendors.vendorName, searchTerm),
+ ilike(techVendorContacts.contactName, searchTerm)
+ )
+ console.log("Search term:", searchTerm)
+ } else {
+ console.log("No search condition")
+ }
+
+ // 원본 테이블들을 직접 조인해서 데이터 조회
+ console.log("Executing data query...")
+ const items = await db
+ .select({
+ // 기본 매핑 정보
+ id: techSalesContactPossibleItems.id,
+ contactId: techSalesContactPossibleItems.contactId,
+ vendorPossibleItemId: techSalesContactPossibleItems.vendorPossibleItemId,
+ createdAt: techSalesContactPossibleItems.createdAt,
+ updatedAt: techSalesContactPossibleItems.updatedAt,
+
+ // 벤더 정보
+ vendorId: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ vendorEmail: techVendors.email,
+ vendorPhone: techVendors.phone,
+ vendorCountry: techVendors.country,
+ vendorStatus: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+
+ // 연락처 정보
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ contactCountry: techVendorContacts.contactCountry,
+ isPrimary: techVendorContacts.isPrimary,
+
+ // 벤더 가능 아이템 정보
+ itemCode: techVendorPossibleItems.itemCode,
+ workType: techVendorPossibleItems.workType,
+ shipTypes: techVendorPossibleItems.shipTypes,
+ itemList: techVendorPossibleItems.itemList,
+ subItemList: techVendorPossibleItems.subItemList,
+ })
+ .from(techSalesContactPossibleItems)
+ .leftJoin(techVendorContacts, eq(techSalesContactPossibleItems.contactId, techVendorContacts.id))
+ .leftJoin(techVendorPossibleItems, eq(techSalesContactPossibleItems.vendorPossibleItemId, techVendorPossibleItems.id))
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(whereCondition)
+ .orderBy(desc(techSalesContactPossibleItems.createdAt))
+ .offset(offset)
+ .limit(input.per_page)
+
+ console.log("Items found:", items.length)
+ console.log("First 3 items:", items.slice(0, 3))
+
+ // 전체 개수 조회 (동일한 조인과 검색 조건 적용)
+ console.log("Executing count query...")
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(techSalesContactPossibleItems)
+ .leftJoin(techVendorContacts, eq(techSalesContactPossibleItems.contactId, techVendorContacts.id))
+ .leftJoin(techVendorPossibleItems, eq(techSalesContactPossibleItems.vendorPossibleItemId, techVendorPossibleItems.id))
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(whereCondition)
+
+ console.log("Total count:", total)
+
+ const pageCount = Math.ceil(total / input.per_page)
+
+ console.log("Final result:", { dataLength: items.length, pageCount, total })
+ console.log("=== END DEBUG ===")
+
+ return {
+ data: items as ContactPossibleItemDetail[],
+ pageCount,
+ total,
+ }
+ } catch (err) {
+ console.error("=== ERROR in getContactPossibleItems ===")
+ console.error("Error fetching contact possible items:", err)
+ console.error("Input was:", input)
+ console.error("=== END ERROR ===")
+ return {
+ data: [],
+ pageCount: 0,
+ total: 0,
+ }
+ }
+}
+
+/**
+ * 담당자별 아이템 삭제
+ */
+export async function deleteContactPossibleItem(id: number) {
+ try {
+ await db
+ .delete(techSalesContactPossibleItems)
+ .where(eq(techSalesContactPossibleItems.id, id))
+
+ revalidatePath("/evcp/contact-possible-items")
+ return { success: true }
+ } catch (error) {
+ console.error("담당자별 아이템 삭제 오류:", error)
+ return { success: false, error: "담당자별 아이템 삭제에 실패했습니다." }
+ }
+}
+
+/**
+ * 여러 담당자별 아이템 삭제
+ */
+export async function deleteContactPossibleItems(ids: number[]) {
+ try {
+ await db
+ .delete(techSalesContactPossibleItems)
+ .where(
+ or(...ids.map(id => eq(techSalesContactPossibleItems.id, id)))
+ )
+
+ revalidatePath("/evcp/contact-possible-items")
+ return { success: true }
+ } catch (error) {
+ console.error("담당자별 아이템 일괄 삭제 오류:", error)
+ return { success: false, error: "담당자별 아이템 삭제에 실패했습니다." }
+ }
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx b/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx
new file mode 100644
index 00000000..a3b198ae
--- /dev/null
+++ b/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx
@@ -0,0 +1,301 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { ContactPossibleItemDetail } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ContactPossibleItemDetail> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ContactPossibleItemDetail>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ContactPossibleItemDetail> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ContactPossibleItemDetail> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ const baseColumns: ColumnDef<ContactPossibleItemDetail>[] = [
+ // 벤더 정보
+ {
+ id: "vendorInfo",
+ header: "벤더 정보",
+ columns: [
+ {
+ accessorKey: "vendorCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => row.original.vendorCode ?? "",
+ },
+ {
+ accessorKey: "vendorName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => row.original.vendorName ?? "",
+ },
+ {
+ accessorKey: "vendorCountry",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 국가" />
+ ),
+ cell: ({ row }) => {
+ const country = row.original.vendorCountry
+ return country || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "techVendorType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 타입" />
+ ),
+ cell: ({ row }) => {
+ const type = row.original.techVendorType
+ return type || <span className="text-muted-foreground">-</span>
+ },
+ },
+ ]
+ },
+ // 담당자 정보
+ {
+ id: "contactInfo",
+ header: "담당자 정보",
+ columns: [
+ {
+ accessorKey: "contactName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자명" />
+ ),
+ cell: ({ row }) => {
+ const contactName = row.original.contactName
+ return contactName || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactPosition",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="직책" />
+ ),
+ cell: ({ row }) => {
+ const position = row.original.contactPosition
+ return position || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactEmail",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자 이메일" />
+ ),
+ cell: ({ row }) => {
+ const contactEmail = row.original.contactEmail
+ return contactEmail || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactPhone",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자 전화번호" />
+ ),
+ cell: ({ row }) => {
+ const contactPhone = row.original.contactPhone
+ return contactPhone || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactCountry",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자 국가" />
+ ),
+ cell: ({ row }) => {
+ const contactCountry = row.original.contactCountry
+ return contactCountry || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "isPrimary",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="주담당자" />
+ ),
+ cell: ({ row }) => {
+ const isPrimary = row.original.isPrimary
+ return isPrimary ? "예" : "아니오"
+ },
+ },
+ ]
+ },
+ // 아이템 정보
+ {
+ id: "itemInfo",
+ header: "아이템 정보",
+ columns: [
+ {
+ accessorKey: "itemCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 코드" />
+ ),
+ cell: ({ row }) => row.original.itemCode ?? "",
+ },
+ {
+ accessorKey: "itemList",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 리스트" />
+ ),
+ cell: ({ row }) => row.original.itemList ?? "",
+ },
+ {
+ accessorKey: "workType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => row.original.workType ?? "",
+ },
+ {
+ accessorKey: "shipTypes",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => row.original.shipTypes ?? "",
+ },
+ {
+ accessorKey: "subItemList",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템 리스트" />
+ ),
+ cell: ({ row }) => row.original.subItemList ?? "",
+ },
+ ]
+ },
+
+ // 시스템 정보
+ {
+ id: "systemInfo",
+ header: "시스템 정보",
+ columns: [
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDate(dateVal)
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.getValue("updatedAt") as Date
+ return formatDate(dateVal)
+ },
+ },
+ ]
+ },
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, baseColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...baseColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx b/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
new file mode 100644
index 00000000..4125399b
--- /dev/null
+++ b/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import { ContactPossibleItemDetail } from "../service"
+
+interface ContactPossibleItemsTableToolbarActionsProps {
+ table: Table<ContactPossibleItemDetail>
+}
+
+export function ContactPossibleItemsTableToolbarActions({
+ table,
+}: ContactPossibleItemsTableToolbarActionsProps) {
+
+ const handleExport = () => {
+ // 현재 테이블의 모든 데이터를 Excel로 내보내기
+ const data = table.getFilteredRowModel().rows.map(row => ({
+ "벤더 코드": row.original.vendorCode,
+ "벤더명": row.original.vendorName,
+ "벤더 국가": row.original.vendorCountry,
+ "벤더 타입": row.original.techVendorType,
+ "담당자명": row.original.contactName,
+ "담당자 직책": row.original.contactPosition,
+ "담당자 이메일": row.original.contactEmail,
+ "담당자 전화번호": row.original.contactPhone,
+ "담당자 국가": row.original.contactCountry,
+ "주담당자": row.original.isPrimary ? "예" : "아니오",
+ "아이템 코드": row.original.itemCode,
+ "아이템명": row.original.itemList,
+ "공종": row.original.workType,
+ "선종": row.original.shipTypes,
+ "서브아이템": row.original.subItemList,
+ "생성일": new Date(row.original.createdAt).toLocaleDateString("ko-KR"),
+ "수정일": new Date(row.original.updatedAt).toLocaleDateString("ko-KR"),
+ }))
+
+ downloadExcel(data, "contact_possible_items.xlsx")
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ Excel 내보내기
+ </Button>
+ </div>
+ )
+}
+
+// Excel 파일 다운로드
+function downloadExcel(data: Array<Record<string, unknown>>, filename: string) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Data")
+
+ if (data.length > 0) {
+ // 헤더 추가
+ const headers = Object.keys(data[0])
+ worksheet.addRow(headers)
+
+ // 데이터 추가
+ data.forEach(row => {
+ worksheet.addRow(Object.values(row))
+ })
+
+ // 스타일 적용
+ worksheet.getRow(1).font = { bold: true }
+ worksheet.columns.forEach(column => {
+ column.width = 15
+ })
+ }
+
+ // 파일 다운로드
+ workbook.xlsx.writeBuffer().then(buffer => {
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ })
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ a.click()
+ window.URL.revokeObjectURL(url)
+ })
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/contact-possible-items-table.tsx b/lib/contact-possible-items/table/contact-possible-items-table.tsx
new file mode 100644
index 00000000..3828e26c
--- /dev/null
+++ b/lib/contact-possible-items/table/contact-possible-items-table.tsx
@@ -0,0 +1,102 @@
+"use client"
+
+import React from "react"
+import { DataTable } from "@/components/data-table/data-table"
+import { ContactPossibleItemsTableToolbarActions } from "./contact-possible-items-table-toolbar-actions"
+import { getColumns } from "./contact-possible-items-table-columns"
+import { ContactPossibleItemDetail } from "../service"
+import { DeleteContactPossibleItemsDialog } from "./delete-contact-possible-items-dialog"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { type DataTableAdvancedFilterField } from "@/types/table"
+
+// 필터 필드 정의
+const advancedFilterFields: DataTableAdvancedFilterField<ContactPossibleItemDetail>[] = [
+ {
+ id: "contactName",
+ label: "담당자명",
+ type: "text",
+ placeholder: "담당자명으로 검색...",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ placeholder: "벤더명으로 검색...",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더코드",
+ type: "text",
+ placeholder: "벤더코드로 검색...",
+ },
+ {
+ id: "itemCode",
+ label: "아이템코드",
+ type: "text",
+ placeholder: "아이템코드로 검색...",
+ },
+ {
+ id: "workType",
+ label: "공종",
+ type: "text",
+ placeholder: "공종으로 검색...",
+ },
+]
+
+interface ContactPossibleItemsTableProps {
+ contactPossibleItemsPromise: Promise<{
+ data: ContactPossibleItemDetail[]
+ pageCount: number
+ total: number
+ }>
+}
+
+export function ContactPossibleItemsTable({
+ contactPossibleItemsPromise,
+}: ContactPossibleItemsTableProps) {
+ const { data, pageCount, total } = React.use(contactPossibleItemsPromise)
+
+ const [rowAction, setRowAction] = React.useState<any | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ rowCount: total,
+ })
+
+ return (
+ <div className="w-full space-y-2.5 overflow-auto">
+ {/* 메인 테이블 */}
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ContactPossibleItemsTableToolbarActions
+ table={table}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteContactPossibleItemsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ contactPossibleItems={
+ rowAction?.type === "delete" && rowAction.row
+ ? [rowAction.row.original]
+ : []
+ }
+ showTrigger={false}
+ onSuccess={() => setRowAction(null)}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx b/lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx
new file mode 100644
index 00000000..7c2fc459
--- /dev/null
+++ b/lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx
@@ -0,0 +1,111 @@
+"use client"
+
+import * as React from "react"
+import { Loader2, Trash2Icon } from "lucide-react"
+
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import { toast } from "@/hooks/use-toast"
+
+import { deleteContactPossibleItems, type ContactPossibleItemDetail } from "../service"
+
+interface DeleteContactPossibleItemsDialogProps {
+ contactPossibleItems: ContactPossibleItemDetail[]
+ showTrigger?: boolean
+ trigger?: React.ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function DeleteContactPossibleItemsDialog({
+ contactPossibleItems,
+ showTrigger = true,
+ trigger,
+ open,
+ onOpenChange,
+ onSuccess,
+}: DeleteContactPossibleItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ const ids = contactPossibleItems.map((item) => item.id)
+ const result = await deleteContactPossibleItems(ids)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: `${contactPossibleItems.length}개의 담당자별 아이템이 삭제되었습니다.`,
+ })
+ onSuccess?.()
+ onOpenChange?.(false)
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "담당자별 아이템 삭제에 실패했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch {
+ toast({
+ title: "오류",
+ description: "담당자별 아이템 삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ const isMultiple = contactPossibleItems.length > 1
+
+ return (
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
+ {showTrigger && (
+ <AlertDialogTrigger asChild>
+ {trigger ?? (
+ <Button variant="outline" size="sm">
+ <Trash2Icon className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({contactPossibleItems.length})
+ </Button>
+ )}
+ </AlertDialogTrigger>
+ )}
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ {isMultiple
+ ? `${contactPossibleItems.length}개의 담당자별 아이템을 삭제하시겠습니까?`
+ : "담당자별 아이템을 삭제하시겠습니까?"}
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한 담당자별 아이템{isMultiple ? "들이" : "이"} 영구적으로 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isDeletePending}>취소</AlertDialogCancel>
+ <Button
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/validations.ts b/lib/contact-possible-items/validations.ts
new file mode 100644
index 00000000..609be0df
--- /dev/null
+++ b/lib/contact-possible-items/validations.ts
@@ -0,0 +1,59 @@
+import { createSearchParamsCache, parseAsInteger, parseAsString } from "nuqs/server"
+import { z } from "zod"
+
+// 검색 파라미터 스키마 (뷰 기반으로 수정)
+export const searchParamsSchema = z.object({
+ page: z.coerce.number().default(1),
+ per_page: z.coerce.number().default(10),
+ sort: z.string().optional(),
+ search: z.string().optional(), // 통합 검색
+ contactName: z.string().optional(),
+ vendorName: z.string().optional(),
+ itemCode: z.string().optional(),
+ vendorCode: z.string().optional(),
+ workType: z.string().optional(),
+ from: z.string().optional(),
+ to: z.string().optional(),
+})
+
+// searchParams 캐시 생성
+export const searchParamsCache = createSearchParamsCache({
+ page: parseAsInteger.withDefault(1),
+ per_page: parseAsInteger.withDefault(10),
+ sort: parseAsString.withDefault(""),
+ search: parseAsString.withDefault(""), // 통합 검색 추가
+ contactName: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ workType: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+})
+
+export type SearchParamsCache = typeof searchParamsCache
+
+// 담당자별 아이템 생성용 스키마 (FK만 사용)
+export const contactPossibleItemSchema = z.object({
+ contactId: z.number().min(1, "담당자를 선택해주세요"),
+ vendorPossibleItemId: z.number().min(1, "벤더 가능 아이템을 선택해주세요"),
+})
+
+export type ContactPossibleItemSchema = z.infer<typeof contactPossibleItemSchema>
+
+// 조회용 스키마 (searchParamsCache와 일치하도록 수정)
+export const getContactPossibleItemsSchema = z.object({
+ page: z.number().default(1),
+ per_page: z.number().default(10),
+ sort: z.string().optional(),
+ search: z.string().optional(),
+ contactName: z.string().optional(),
+ vendorName: z.string().optional(),
+ itemCode: z.string().optional(),
+ vendorCode: z.string().optional(),
+ workType: z.string().optional(),
+ from: z.string().optional(),
+ to: z.string().optional(),
+})
+
+export type GetContactPossibleItemsSchema = z.infer<typeof getContactPossibleItemsSchema> \ No newline at end of file