summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/contact-possible-items/service.ts205
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table.tsx203
-rw-r--r--lib/contact-possible-items/validations.ts101
-rw-r--r--lib/techsales-rfq/service.ts240
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx22
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx27
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx22
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx1
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx46
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx31
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx76
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx38
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx1
13 files changed, 758 insertions, 255 deletions
diff --git a/lib/contact-possible-items/service.ts b/lib/contact-possible-items/service.ts
index 960df17e..72b93e16 100644
--- a/lib/contact-possible-items/service.ts
+++ b/lib/contact-possible-items/service.ts
@@ -1,12 +1,12 @@
"use server"
-import db from "@/db/db"
+import { revalidatePath } from 'next/cache'
+import { eq, and, or, desc, asc, count, ilike, SQL, gte, lte } from 'drizzle-orm'
+import db from '@/db/db'
import { techSalesContactPossibleItems } from "@/db/schema/techSales"
import { techVendors, techVendorContacts, techVendorPossibleItems } from "@/db/schema/techVendors"
import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"
-import { eq, desc, ilike, count, or } from "drizzle-orm"
-import { revalidatePath } from "next/cache"
-import { unstable_noStore } from "next/cache"
+import { filterColumns } from '@/lib/filter-columns'
import { GetContactPossibleItemsSchema } from "./validations"
// 담당자별 아이템 상세 타입 정의 (뷰 기반)
@@ -45,35 +45,130 @@ export interface ContactPossibleItemDetail {
}
/**
- * 담당자별 아이템 목록 조회 (뷰 사용)
+ * 담당자별 아이템 목록 조회 (간단한 필터, 정렬, 검색 지원)
*/
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
+ const offset = (input.page - 1) * input.perPage
+
+ // ✅ 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: techSalesContactPossibleItems,
+ filters: input.filters as any,
+ joinOperator: input.joinOperator || 'and',
+ })
+ }
+
+ // ✅ 2) 기본 필터 조건들
+ const basicConditions: SQL<unknown>[] = []
+
+ if (input.vendorName) {
+ basicConditions.push(ilike(techVendors.vendorName, `%${input.vendorName}%`))
+ }
+
+ if (input.contactName) {
+ basicConditions.push(ilike(techVendorContacts.contactName, `%${input.contactName}%`))
+ }
+
+ if (input.vendorCode) {
+ basicConditions.push(ilike(techVendors.vendorCode, `%${input.vendorCode}%`))
+ }
+
+ // 아이템 코드와 작업 유형은 조인된 테이블에서 검색
+ if (input.itemCode) {
+ const itemCodeConditions = [
+ ilike(itemShipbuilding.itemCode, `%${input.itemCode}%`),
+ ilike(itemOffshoreTop.itemCode, `%${input.itemCode}%`),
+ ilike(itemOffshoreHull.itemCode, `%${input.itemCode}%`)
+ ]
+ basicConditions.push(or(...itemCodeConditions))
+ }
+
+ if (input.workType) {
+ const workTypeConditions = [
+ ilike(itemShipbuilding.workType, `%${input.workType}%`),
+ ilike(itemOffshoreTop.workType, `%${input.workType}%`),
+ ilike(itemOffshoreHull.workType, `%${input.workType}%`)
+ ]
+ basicConditions.push(or(...workTypeConditions))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // ✅ 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
if (input.search) {
- const searchTerm = `%${input.search}%`
- whereCondition = or(
- ilike(techVendors.vendorName, searchTerm),
- ilike(techVendorContacts.contactName, searchTerm)
- )
- console.log("Search term:", searchTerm)
- } else {
- console.log("No search condition")
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(techVendors.vendorName, s),
+ ilike(techVendorContacts.contactName, s),
+ ilike(techVendors.vendorCode, s),
+ ilike(techVendors.email, s),
+ ilike(techVendorContacts.contactEmail, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ // ✅ 4) 최종 WHERE 조건
+ const whereConditions: SQL<unknown>[] = []
+ if (advancedWhere) whereConditions.push(advancedWhere)
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // ✅ 5) 전체 개수 조회 (단순 조인으로 변경)
+ const totalResult = 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(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
}
- // 새로운 스키마에 맞게 수정 - 아이템 정보를 별도로 조회해야 함
- console.log("Executing data query...")
-
- // 1단계: 기본 매핑 정보 조회
+ console.log("Total contact possible items:", total)
+
+ // ✅ 6) 정렬 및 페이징
+ const orderByColumns: any[] = []
+
+ for (const sort of input.sort) {
+ const column = sort.id
+
+ // techSalesContactPossibleItems 테이블의 컬럼들
+ if (column in techSalesContactPossibleItems) {
+ const contactItemColumn = techSalesContactPossibleItems[column as keyof typeof techSalesContactPossibleItems]
+ orderByColumns.push(sort.desc ? desc(contactItemColumn) : asc(contactItemColumn))
+ }
+ // techVendors 테이블의 컬럼들
+ else if (column === 'vendorName' || column === 'vendorCode' || column === 'email') {
+ const vendorColumn = techVendors[column as keyof typeof techVendors]
+ orderByColumns.push(sort.desc ? desc(vendorColumn) : asc(vendorColumn))
+ }
+ // techVendorContacts 테이블의 컬럼들
+ else if (column === 'contactName' || column === 'contactEmail') {
+ const contactColumn = techVendorContacts[column as keyof typeof techVendorContacts]
+ orderByColumns.push(sort.desc ? desc(contactColumn) : asc(contactColumn))
+ }
+ // 조인된 테이블의 컬럼들 (실제로는 계산된 값이므로 기본 정렬 사용)
+ else if (column === 'itemCode' || column === 'workType') {
+ // 조인된 테이블의 컬럼은 직접 정렬할 수 없으므로 기본 정렬 사용
+ orderByColumns.push(desc(techSalesContactPossibleItems.createdAt))
+ }
+ }
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(techSalesContactPossibleItems.createdAt))
+ }
+
+ // ✅ 7) 메인 쿼리 (단순 조인으로 변경)
const basicItems = await db
.select({
// 기본 매핑 정보
@@ -82,7 +177,7 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
vendorPossibleItemId: techSalesContactPossibleItems.vendorPossibleItemId,
createdAt: techSalesContactPossibleItems.createdAt,
updatedAt: techSalesContactPossibleItems.updatedAt,
-
+
// 벤더 정보
vendorId: techVendors.id,
vendorName: techVendors.vendorName,
@@ -92,7 +187,7 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
vendorCountry: techVendors.country,
vendorStatus: techVendors.status,
techVendorType: techVendors.techVendorType,
-
+
// 연락처 정보
contactName: techVendorContacts.contactName,
contactPosition: techVendorContacts.contactPosition,
@@ -101,7 +196,7 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
contactPhone: techVendorContacts.contactPhone,
contactCountry: techVendorContacts.contactCountry,
isPrimary: techVendorContacts.isPrimary,
-
+
// 벤더 가능 아이템 ID 정보
shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId,
offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId,
@@ -111,12 +206,12 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
.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))
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
.offset(offset)
- .limit(input.per_page)
+ .limit(input.perPage)
- // 2단계: 각 아이템의 상세 정보를 별도로 조회하여 합치기
+ // ✅ 8) 각 아이템의 상세 정보를 별도로 조회하여 합치기
const items = await Promise.all(basicItems.map(async (item) => {
let itemCode = null;
let workType = null;
@@ -135,7 +230,7 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
.from(itemShipbuilding)
.where(eq(itemShipbuilding.id, item.shipbuildingItemId))
.limit(1);
-
+
if (shipItem.length > 0) {
itemCode = shipItem[0].itemCode;
workType = shipItem[0].workType;
@@ -153,7 +248,7 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
.from(itemOffshoreTop)
.where(eq(itemOffshoreTop.id, item.offshoreTopItemId))
.limit(1);
-
+
if (topItem.length > 0) {
itemCode = topItem[0].itemCode;
workType = topItem[0].workType;
@@ -171,7 +266,7 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
.from(itemOffshoreHull)
.where(eq(itemOffshoreHull.id, item.offshoreHullItemId))
.limit(1);
-
+
if (hullItem.length > 0) {
itemCode = hullItem[0].itemCode;
workType = hullItem[0].workType;
@@ -190,41 +285,13 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche
};
}))
- 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)
+ // ✅ 9) 페이지 수 계산
+ const pageCount = Math.ceil(total / input.perPage)
- console.log("Final result:", { dataLength: items.length, pageCount, total })
- console.log("=== END DEBUG ===")
-
- return {
- data: items as ContactPossibleItemDetail[],
- pageCount,
- total,
- }
+ 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,
- }
+ return { data: [], pageCount: 0, total: 0 }
}
}
diff --git a/lib/contact-possible-items/table/contact-possible-items-table.tsx b/lib/contact-possible-items/table/contact-possible-items-table.tsx
index 3828e26c..a46f71ea 100644
--- a/lib/contact-possible-items/table/contact-possible-items-table.tsx
+++ b/lib/contact-possible-items/table/contact-possible-items-table.tsx
@@ -1,102 +1,103 @@
-"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>
- )
+"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 {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getContactPossibleItems>>
+ ]
+ >
+}
+
+export function ContactPossibleItemsTable({
+ promises,
+}: ContactPossibleItemsTableProps) {
+ const [{ data, pageCount, total }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<any | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ rowCount: total,
+ enableAdvancedFilter: true,
+ })
+
+ 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/validations.ts b/lib/contact-possible-items/validations.ts
index 609be0df..e5c301cb 100644
--- a/lib/contact-possible-items/validations.ts
+++ b/lib/contact-possible-items/validations.ts
@@ -1,59 +1,42 @@
-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
+import { techSalesContactPossibleItems } from "@/db/schema/techSales"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesContactPossibleItems.$inferSelect>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 기본 필터
+ vendorName: parseAsString.withDefault(""),
+ contactName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+ workType: parseAsString.withDefault(""),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+export type GetContactPossibleItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
+// 담당자별 아이템 생성용 스키마 (FK만 사용)
+export const contactPossibleItemSchema = z.object({
+ contactId: z.number().min(1, "담당자를 선택해주세요"),
+ vendorPossibleItemId: z.number().min(1, "벤더 가능 아이템을 선택해주세요"),
+})
+
+export type ContactPossibleItemSchema = z.infer<typeof contactPossibleItemSchema> \ No newline at end of file
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 3736bf76..deb2981a 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -1720,6 +1720,68 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) {
}
/**
+ * 기술영업 RFQ 삭제 (벤더 추가 이전에만 가능)
+ */
+export async function deleteTechSalesRfq(rfqId: number) {
+ unstable_noStore();
+ try {
+ return await db.transaction(async (tx) => {
+ // RFQ 정보 조회 및 상태 확인
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, rfqId),
+ columns: { id: true, status: true, rfqType: true }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ // 벤더 추가 이전 상태에서만 삭제 가능
+ if (rfq.status !== "RFQ Created") {
+ throw new Error("벤더가 추가된 RFQ는 삭제할 수 없습니다.");
+ }
+
+ // 관련 RFQ 아이템들 삭제
+ await tx.delete(techSalesRfqItems)
+ .where(eq(techSalesRfqItems.rfqId, rfqId));
+
+ // 관련 첨부파일들 삭제 (파일 시스템에서도 삭제)
+ const attachments = await tx.query.techSalesAttachments.findMany({
+ where: eq(techSalesAttachments.techSalesRfqId, rfqId),
+ columns: { id: true, filePath: true }
+ });
+
+ for (const attachment of attachments) {
+ await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachment.id));
+
+ // 파일 시스템에서 파일 삭제
+ try {
+ deleteFile(attachment.filePath);
+ } catch (fileError) {
+ console.warn("파일 삭제 실패:", fileError);
+ }
+ }
+
+ // RFQ 삭제
+ const deletedRfq = await tx.delete(techSalesRfqs)
+ .where(eq(techSalesRfqs.id, rfqId))
+ .returning();
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
+
+ return { data: deletedRfq[0], error: null };
+ });
+ } catch (err) {
+ console.error("기술영업 RFQ 삭제 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
* 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
*/
export async function processTechSalesRfqAttachments(params: {
@@ -2377,6 +2439,7 @@ export async function createTechSalesShipRfq(input: {
itemIds: number[]; // 조선 아이템 ID 배열
dueDate: Date;
description?: string;
+ remark?: string;
createdBy: number;
}) {
unstable_noStore();
@@ -2401,6 +2464,7 @@ export async function createTechSalesShipRfq(input: {
rfqCode: rfqCode[0],
biddingProjectId: input.biddingProjectId,
description: input.description,
+ remark: input.remark,
dueDate: input.dueDate,
status: "RFQ Created",
rfqType: "SHIP",
@@ -2440,6 +2504,7 @@ export async function createTechSalesHullRfq(input: {
itemIds: number[]; // Hull 아이템 ID 배열
dueDate: Date;
description?: string;
+ remark?: string;
createdBy: number;
}) {
unstable_noStore();
@@ -2466,6 +2531,7 @@ export async function createTechSalesHullRfq(input: {
rfqCode: hullRfqCode[0],
biddingProjectId: input.biddingProjectId,
description: input.description,
+ remark: input.remark,
dueDate: input.dueDate,
status: "RFQ Created",
rfqType: "HULL",
@@ -2505,6 +2571,7 @@ export async function createTechSalesTopRfq(input: {
itemIds: number[]; // TOP 아이템 ID 배열
dueDate: Date;
description?: string;
+ remark?: string;
createdBy: number;
}) {
unstable_noStore();
@@ -2531,6 +2598,7 @@ export async function createTechSalesTopRfq(input: {
rfqCode: topRfqCode[0],
biddingProjectId: input.biddingProjectId,
description: input.description,
+ remark: input.remark,
dueDate: input.dueDate,
status: "RFQ Created",
rfqType: "TOP",
@@ -3959,6 +4027,178 @@ export async function getTechVendorsContacts(vendorIds: number[]) {
}
/**
+ * RFQ와 연결된 벤더의 contact 정보 조회 (techSalesContactPossibleItems 기준)
+ */
+export async function getTechVendorsContactsWithPossibleItems(vendorIds: number[], rfqId?: number) {
+ unstable_noStore();
+ try {
+ // RFQ ID가 있으면 해당 RFQ의 아이템들을 먼저 조회
+ let rfqItems: number[] = [];
+ if (rfqId) {
+ const rfqItemResults = await db
+ .select({
+ id: techSalesRfqItems.id,
+ })
+ .from(techSalesRfqItems)
+ .where(eq(techSalesRfqItems.rfqId, rfqId));
+
+ rfqItems = rfqItemResults.map(item => item.id);
+ }
+
+ // 벤더와 contact 정보 조회 (기존과 동일)
+ const contactsWithVendor = await db
+ .select({
+ contactId: techVendorContacts.id,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactTitle: techVendorContacts.contactTitle,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ isPrimary: techVendorContacts.isPrimary,
+ vendorId: techVendorContacts.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode
+ })
+ .from(techVendorContacts)
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(inArray(techVendorContacts.vendorId, vendorIds))
+ .orderBy(
+ asc(techVendorContacts.vendorId),
+ desc(techVendorContacts.isPrimary),
+ asc(techVendorContacts.contactName)
+ );
+
+ // techSalesContactPossibleItems 테이블에서 RFQ 아이템과 연결된 담당자들 조회
+ let selectedContactIds: Set<number> = new Set();
+ if (rfqId && vendorIds.length > 0) {
+ console.log(`[DEBUG] RFQ ID: ${rfqId}, Vendor IDs: ${vendorIds.join(', ')}`);
+
+ // 선택된 벤더들이 가진 possible items 중 현재 RFQ의 아이템들과 매칭되는 것들을 찾기
+ // 1. 먼저 현재 RFQ의 아이템들을 조회
+ const rfqItems = await db
+ .select({
+ id: techSalesRfqItems.id,
+ itemShipbuildingId: techSalesRfqItems.itemShipbuildingId,
+ itemOffshoreTopId: techSalesRfqItems.itemOffshoreTopId,
+ itemOffshoreHullId: techSalesRfqItems.itemOffshoreHullId,
+ itemType: techSalesRfqItems.itemType,
+ })
+ .from(techSalesRfqItems)
+ .where(eq(techSalesRfqItems.rfqId, rfqId));
+
+ console.log(`[DEBUG] RFQ Items count: ${rfqItems.length}`);
+ rfqItems.forEach(item => {
+ console.log(`[DEBUG] RFQ Item: ${item.itemType} - ${item.itemShipbuildingId || item.itemOffshoreTopId || item.itemOffshoreHullId}`);
+ });
+
+ if (rfqItems.length > 0) {
+ // 2. 선택된 벤더들이 가진 possible items 조회
+ const vendorPossibleItems = await db
+ .select({
+ id: techVendorPossibleItems.id,
+ vendorId: techVendorPossibleItems.vendorId,
+ shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId,
+ offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId,
+ offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId,
+ })
+ .from(techVendorPossibleItems)
+ .where(inArray(techVendorPossibleItems.vendorId, vendorIds));
+
+ console.log(`[DEBUG] Vendor Possible Items count: ${vendorPossibleItems.length}`);
+ vendorPossibleItems.forEach(item => {
+ console.log(`[DEBUG] Vendor Item ${item.id}: ${item.shipbuildingItemId || item.offshoreTopItemId || item.offshoreHullItemId} (Vendor: ${item.vendorId})`);
+ });
+
+ // 3. RFQ 아이템과 벤더 possible items 간 매칭
+ const matchedPossibleItemIds: number[] = [];
+
+ for (const rfqItem of rfqItems) {
+ for (const vendorItem of vendorPossibleItems) {
+ // RFQ 아이템 타입별로 매칭 확인
+ if (rfqItem.itemType === "SHIP" && rfqItem.itemShipbuildingId === vendorItem.shipbuildingItemId) {
+ matchedPossibleItemIds.push(vendorItem.id);
+ console.log(`[DEBUG] Matched SHIP: RFQ Item ${rfqItem.id} -> Vendor Item ${vendorItem.id}`);
+ } else if (rfqItem.itemType === "TOP" && rfqItem.itemOffshoreTopId === vendorItem.offshoreTopItemId) {
+ matchedPossibleItemIds.push(vendorItem.id);
+ console.log(`[DEBUG] Matched TOP: RFQ Item ${rfqItem.id} -> Vendor Item ${vendorItem.id}`);
+ } else if (rfqItem.itemType === "HULL" && rfqItem.itemOffshoreHullId === vendorItem.offshoreHullItemId) {
+ matchedPossibleItemIds.push(vendorItem.id);
+ console.log(`[DEBUG] Matched HULL: RFQ Item ${rfqItem.id} -> Vendor Item ${vendorItem.id}`);
+ }
+ }
+ }
+
+ console.log(`[DEBUG] Matched Possible Item IDs: ${matchedPossibleItemIds.join(', ')}`);
+
+ if (matchedPossibleItemIds.length > 0) {
+ // 4. 매칭된 possible items와 연결된 contact들 조회
+ const selectedContacts = await db
+ .select({
+ contactId: techSalesContactPossibleItems.contactId,
+ })
+ .from(techSalesContactPossibleItems)
+ .where(inArray(techSalesContactPossibleItems.vendorPossibleItemId, matchedPossibleItemIds));
+
+ console.log(`[DEBUG] Selected Contacts count: ${selectedContacts.length}`);
+ selectedContacts.forEach(contact => {
+ console.log(`[DEBUG] Selected Contact ID: ${contact.contactId}`);
+ });
+
+ selectedContactIds = new Set(selectedContacts.map(sc => sc.contactId));
+ }
+ }
+ }
+
+ // 벤더별로 그룹화하고 선택 상태 추가
+ const contactsByVendor = contactsWithVendor.reduce((acc, row) => {
+ const vendorId = row.vendorId;
+ if (!acc[vendorId]) {
+ acc[vendorId] = {
+ vendor: {
+ id: vendorId,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || ''
+ },
+ contacts: []
+ };
+ }
+ acc[vendorId].contacts.push({
+ id: row.contactId,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition,
+ contactTitle: row.contactTitle,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone,
+ isPrimary: row.isPrimary,
+ isSelectedForRfq: selectedContactIds.has(row.contactId) // RFQ 아이템과 연결되어 있는지 여부
+ });
+ return acc;
+ }, {} as Record<number, {
+ vendor: {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ };
+ contacts: Array<{
+ id: number;
+ contactName: string;
+ contactPosition: string | null;
+ contactTitle: string | null;
+ contactEmail: string;
+ contactPhone: string | null;
+ isPrimary: boolean;
+ isSelectedForRfq?: boolean;
+ }>;
+ }>);
+
+ return { data: contactsByVendor, error: null };
+ } catch (err) {
+ console.error("벤더 contact 조회 오류:", err);
+ return { data: {}, error: getErrorMessage(err) };
+ }
+}
+
+/**
* quotation별 발송된 담당자 정보 조회
*/
export async function getQuotationContacts(quotationId: number) {
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
index 5870c785..d79205b6 100644
--- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -74,6 +74,7 @@ const createHullRfqSchema = z.object({
required_error: "마감일을 선택해주세요.",
}),
description: z.string().optional(),
+ remark: z.string().optional(),
})
// 폼 데이터 타입
@@ -194,6 +195,7 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
itemIds: [],
dueDate: undefined,
description: "",
+ remark: "",
}
})
@@ -261,6 +263,7 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
itemIds: data.itemIds,
dueDate: data.dueDate,
description: data.description,
+ remark: data.remark,
createdBy: Number(session.user.id),
})
@@ -379,6 +382,25 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
</FormItem>
)}
/>
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Context</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Context를 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
<Separator className="my-4" />
{/* 마감일 설정 */}
<FormField
diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index 114bd04d..b851f7e8 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -66,6 +66,7 @@ const createShipRfqSchema = z.object({
required_error: "마감일을 선택해주세요.",
}),
description: z.string().optional(),
+ remark: z.string().optional(),
})
// 폼 데이터 타입
@@ -197,6 +198,7 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
itemIds: [],
dueDate: undefined,
description: "",
+ remark: "",
}
})
@@ -226,9 +228,9 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
return filtered
}, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
- // 사용 가능한 선종 목록 가져오기
+ // 사용 가능한 선종 목록 가져오기 (OPTION 제외)
const availableShipTypes = React.useMemo(() => {
- return shipTypes
+ return shipTypes.filter(shipType => shipType !== "OPTION")
}, [shipTypes])
// 프로젝트 선택 처리
@@ -274,6 +276,7 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
itemIds: data.itemIds,
dueDate: data.dueDate,
description: data.description,
+ remark: data.remark,
createdBy: Number(session.user.id),
})
@@ -396,7 +399,25 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
</FormItem>
)}
/>
-
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Context</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Context를 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
<Separator className="my-4" />
{/* 선종 선택 */}
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
index 49fb35ca..d7171c09 100644
--- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -66,6 +66,7 @@ const createTopRfqSchema = z.object({
required_error: "마감일을 선택해주세요.",
}),
description: z.string().optional(),
+ remark: z.string().optional(),
})
// 폼 데이터 타입
@@ -185,6 +186,7 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
itemIds: [],
dueDate: undefined,
description: "",
+ remark: "",
}
})
@@ -252,6 +254,7 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
itemIds: data.itemIds,
dueDate: data.dueDate,
description: data.description,
+ remark: data.remark,
createdBy: Number(session.user.id),
})
@@ -371,6 +374,25 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
</FormItem>
)}
/>
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Context</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Context를 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
<Separator className="my-4" />
{/* 마감일 설정 */}
<FormField
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 6ef0f221..d834875a 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -797,6 +797,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
open={contactSelectionDialogOpen}
onOpenChange={setContactSelectionDialogOpen}
vendorIds={selectedRows.map(row => row.vendorId).filter(Boolean) as number[]}
+ rfqId={selectedRfqId}
onSendRfq={handleSendRfqWithContacts}
/>
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
index 031f4aa2..d83394bb 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
@@ -25,6 +25,7 @@ interface VendorContact {
contactEmail: string
contactPhone: string | null
isPrimary: boolean
+ isSelectedForRfq?: boolean // RFQ의 아이템과 연결되어 있는지 여부
}
interface VendorWithContacts {
@@ -47,6 +48,7 @@ interface VendorContactSelectionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
vendorIds: number[]
+ rfqId?: number // RFQ ID 추가
onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
}
@@ -54,6 +56,7 @@ export function VendorContactSelectionDialog({
open,
onOpenChange,
vendorIds,
+ rfqId,
onSendRfq
}: VendorContactSelectionDialogProps) {
const [vendorsWithContacts, setVendorsWithContacts] = useState<Record<number, VendorWithContacts>>({})
@@ -80,38 +83,41 @@ export function VendorContactSelectionDialog({
const loadVendorsContacts = useCallback(async () => {
try {
setIsLoading(true)
- const { getTechVendorsContacts } = await import("@/lib/techsales-rfq/service")
-
- const result = await getTechVendorsContacts(vendorIds)
-
+ const { getTechVendorsContactsWithPossibleItems } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechVendorsContactsWithPossibleItems(vendorIds, rfqId)
+
if (result.error) {
toast.error(result.error)
return
}
-
+
setVendorsWithContacts(result.data)
-
- // 기본 선택: 모든 contact 선택
+
+ // 기본 선택: techSalesContactPossibleItems 테이블을 기준으로 선택
const defaultSelected: SelectedContact[] = []
Object.values(result.data).forEach(vendorData => {
vendorData.contacts.forEach(contact => {
- defaultSelected.push({
- vendorId: vendorData.vendor.id,
- contactId: contact.id,
- contactEmail: contact.contactEmail,
- contactName: contact.contactName
- })
+ // 해당 담당자가 선택된 아이템과 연결되어 있다면 우선 선택
+ if (contact.isSelectedForRfq) {
+ defaultSelected.push({
+ vendorId: vendorData.vendor.id,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ })
+ }
})
})
setSelectedContacts(defaultSelected)
-
+
} catch (error) {
console.error("벤더 contact 조회 오류:", error)
toast.error("벤더 연락처를 불러오는 중 오류가 발생했습니다")
} finally {
setIsLoading(false)
}
- }, [vendorIds])
+ }, [vendorIds, rfqId])
// contact 선택/해제 핸들러
const handleContactToggle = (vendorId: number, contact: VendorContact) => {
@@ -201,7 +207,7 @@ export function VendorContactSelectionDialog({
<DialogHeader>
<DialogTitle>RFQ 발송 대상 선택</DialogTitle>
<DialogDescription>
- 각 벤더의 연락처를 선택하여 RFQ를 발송하세요. 기본적으로 모든 연락처가 선택되어 있습니다.
+ RFQ에 포함된 자재와 연결된 담당자들이 우선 선택됩니다. 해당 벤더에 연결된 담당자가 없다면 수동으로 선택해주세요.
</DialogDescription>
</DialogHeader>
@@ -227,7 +233,8 @@ export function VendorContactSelectionDialog({
) : (
Object.entries(vendorsWithContacts).map(([vendorId, vendorData]) => {
const selectionState = getVendorSelectionState(Number(vendorId), vendorData)
-
+ const hasSelectedContacts = vendorData.contacts.some(contact => contact.isSelectedForRfq)
+
return (
<div key={vendorId} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
@@ -251,6 +258,11 @@ export function VendorContactSelectionDialog({
코드: {vendorData.vendor.vendorCode}
</p>
)}
+ {!hasSelectedContacts && (
+ <p className="text-sm text-orange-600">
+ ※ 해당 벤더의 담당자가 RFQ 자재와 연결되지 않았습니다. 수동으로 선택해주세요.
+ </p>
+ )}
</div>
</div>
<Badge variant="outline">
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 2bc5b5b4..1ac59d8b 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -96,6 +96,31 @@ export function getColumns({
size: 200,
},
{
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Context" />
+ ),
+ cell: ({ row }) => {
+ const remark = row.getValue("remark") as string | null;
+ return (
+ <div className="max-w-[200px]">
+ {remark ? (
+ <span className="text-sm truncate block" title={remark}>
+ {remark}
+ </span>
+ ) : (
+ <span className="text-muted-foreground text-sm">-</span>
+ )}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -434,6 +459,12 @@ export function getColumns({
<DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}>
<span>수정하기 </span>
</DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ className="text-destructive focus:text-destructive"
+ >
+ <span>삭제하기</span>
+ </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index e1e511c8..889b87b3 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -30,6 +30,17 @@ import { RFQFilterSheet } from "./rfq-filter-sheet"
import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
import UpdateSheet from "./update-rfq-sheet"
+import { deleteTechSalesRfq } from "@/lib/techsales-rfq/service"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
export interface TechSalesRfq {
id: number
@@ -101,6 +112,11 @@ export function RFQListTable({
// 아이템 다이얼로그 상태
const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<TechSalesRfq | null>(null)
+
+ // 삭제 다이얼로그 상태
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
+ const [rfqToDelete, setRfqToDelete] = React.useState<TechSalesRfq | null>(null)
+ const [isDeleting, setIsDeleting] = React.useState(false)
// 패널 collapse 상태
const [panelHeight, setPanelHeight] = React.useState<number>(55)
@@ -255,7 +271,9 @@ export function RFQListTable({
setUpdateSheetOpen(true);
break;
case "delete":
- console.log("Delete rfq:", rowAction.row.original)
+ // 삭제 다이얼로그 열기
+ setRfqToDelete(rowAction.row.original as TechSalesRfq)
+ setDeleteDialogOpen(true)
break;
}
setRowAction(null)
@@ -334,6 +352,38 @@ export function RFQListTable({
setItemsDialogOpen(true)
}, [])
+ // RFQ 삭제 처리 함수
+ const handleDeleteRfq = React.useCallback(async () => {
+ if (!rfqToDelete) return
+
+ try {
+ setIsDeleting(true)
+
+ const result = await deleteTechSalesRfq(rfqToDelete.id)
+
+ if (result.error) {
+ toast.error(`삭제 실패: ${result.error}`)
+ return
+ }
+
+ toast.success("RFQ가 성공적으로 삭제되었습니다.")
+
+ // 선택된 RFQ 초기화
+ setSelectedRfq(null)
+ setRfqToDelete(null)
+ setDeleteDialogOpen(false)
+
+ // 테이블 새로고침을 위해 페이지 리로드 또는 데이터 재요청 필요
+ // 현재는 캐시 무효화가 되어 있으므로 자연스럽게 업데이트됨
+
+ } catch (error) {
+ console.error("RFQ 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다.")
+ } finally {
+ setIsDeleting(false)
+ }
+ }, [rfqToDelete])
+
const columns = React.useMemo(
() => getColumns({
setRowAction,
@@ -647,6 +697,30 @@ export function RFQListTable({
}}
/>
)}
+
+ {/* RFQ 삭제 다이얼로그 */}
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>RFQ 삭제 확인</AlertDialogTitle>
+ <AlertDialogDescription>
+ 정말로 "{rfqToDelete?.rfqCode || rfqToDelete?.description}" RFQ를 삭제하시겠습니까?
+ <br />
+ <strong>주의:</strong> 이 작업은 되돌릴 수 없습니다. RFQ와 관련된 모든 데이터가 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteRfq}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제하기"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index 46b14f46..aabe7a64 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -27,23 +27,24 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
materialCode?: string;
dueDate?: Date;
rfqStatus?: string;
-
+
// 아이템 정보
itemName?: string;
itemCount?: number;
-
+
// 프로젝트 정보
projNm?: string;
pspid?: string;
sector?: string;
-
+
// RFQ 정보
description?: string;
-
+ remark?: string;
+
// 벤더 정보
vendorName?: string;
vendorCode?: string;
-
+
// 사용자 정보
createdByName?: string | null;
updatedByName?: string | null;
@@ -224,6 +225,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog, open
enableHiding: true,
},
{
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Context" />
+ ),
+ cell: ({ row }) => {
+ const remark = row.getValue("remark") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {remark || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{remark || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index 5bb219bf..214e2b89 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -40,6 +40,7 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
itemName?: string | null;
projNm?: string | null;
description?: string | null;
+ remark?: string | null;
attachmentCount?: number;
itemCount?: number;
pspid?: string | null;