From c8cccaf1198ae48754ac036b579732018f5b448a Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 23 Oct 2025 03:30:01 +0000 Subject: (최겸) 기술영업 조선 rfq 수정(벤더, 담당자 임시삭제기능 추가) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contacts-table/add-contact-dialog.tsx | 9 +- .../contacts-table/delete-contact-dialog.tsx | 81 ++++++++ .../contacts-table/update-contact-sheet.tsx | 13 +- .../possible-items/add-item-dialog.tsx | 4 +- .../possible-items-toolbar-actions.tsx | 2 +- lib/tech-vendors/service.ts | 131 +++++++++++-- lib/tech-vendors/table/add-vendor-dialog.tsx | 13 +- .../table/delete-tech-vendors-dialog.tsx | 80 ++++++++ lib/tech-vendors/table/import-button.tsx | 19 +- .../table/tech-vendors-table-columns.tsx | 12 +- lib/tech-vendors/table/tech-vendors-table.tsx | 7 +- lib/tech-vendors/table/update-vendor-sheet.tsx | 13 +- lib/tech-vendors/utils.ts | 84 +++++--- lib/techsales-rfq/service.ts | 28 ++- lib/techsales-rfq/table/create-rfq-ship-dialog.tsx | 9 +- .../table/detail-table/add-vendor-dialog.tsx | 213 ++++++++++++--------- .../quotation-contacts-view-dialog.tsx | 94 ++++----- .../detail-table/quotation-history-dialog.tsx | 26 ++- .../table/detail-table/rfq-detail-table.tsx | 1 + lib/techsales-rfq/table/project-detail-dialog.tsx | 87 +++++++++ lib/techsales-rfq/table/update-rfq-sheet.tsx | 156 ++++++++++++++- .../detail/quotation-response-tab.tsx | 1 + 22 files changed, 857 insertions(+), 226 deletions(-) create mode 100644 lib/tech-vendors/contacts-table/delete-contact-dialog.tsx create mode 100644 lib/tech-vendors/table/delete-tech-vendors-dialog.tsx (limited to 'lib') diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx index 90ba4e04..447c44d7 100644 --- a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx +++ b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx @@ -21,6 +21,7 @@ import { type CreateTechVendorContactSchema, } from "@/lib/tech-vendors/validations" import { createTechVendorContact } from "@/lib/tech-vendors/service" +import { normalizeEmail } from "@/lib/tech-vendors/utils" interface AddContactDialogProps { vendorId: number @@ -46,8 +47,14 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { }) async function onSubmit(data: CreateTechVendorContactSchema) { + // 이메일을 소문자로 변환 + const normalizedData = { + ...data, + contactEmail: normalizeEmail(data.contactEmail), + } + // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨 - const result = await createTechVendorContact(data) + const result = await createTechVendorContact(normalizedData) if (result.error) { alert(`에러: ${result.error}`) return diff --git a/lib/tech-vendors/contacts-table/delete-contact-dialog.tsx b/lib/tech-vendors/contacts-table/delete-contact-dialog.tsx new file mode 100644 index 00000000..6c8b14d7 --- /dev/null +++ b/lib/tech-vendors/contacts-table/delete-contact-dialog.tsx @@ -0,0 +1,81 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { Trash2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" + +import { deleteTechVendorContact } from "../service" +import type { TechVendorContact } from "@/db/schema/techVendors" + +interface DeleteContactDialogProps { + contact: TechVendorContact + vendorId: number + onSuccess?: () => void +} + +export function DeleteContactDialog({ contact, vendorId, onSuccess }: DeleteContactDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false) + + const handleDelete = async () => { + setIsDeleting(true) + try { + const result = await deleteTechVendorContact(contact.id, vendorId) + + if (result.success) { + toast.success("담당자가 성공적으로 삭제되었습니다.") + onSuccess?.() + } else { + toast.error(result.error || "담당자 삭제 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("담당자 삭제 오류:", error) + toast.error("담당자 삭제 중 오류가 발생했습니다.") + } finally { + setIsDeleting(false) + } + } + + return ( + + + + + + + 담당자 삭제 + + {contact.contactName} 담당자를 정말 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없으며, 관련된 모든 데이터(아이템 매핑 등)가 함께 삭제됩니다. +
+
+ + 취소 + + {isDeleting ? "삭제 중..." : "삭제"} + + +
+
+ ) +} diff --git a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx index 4713790c..877e8b4f 100644 --- a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx +++ b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx @@ -28,6 +28,7 @@ import { Loader2 } from "lucide-react" import type { TechVendorContact } from "@/db/schema/techVendors" import { updateTechVendorContactSchema, type UpdateTechVendorContactSchema } from "../validations" import { updateTechVendorContact } from "../service" +import { normalizeEmail } from "../utils" interface UpdateContactSheetProps extends React.ComponentPropsWithoutRef { @@ -67,13 +68,19 @@ export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContac async function onSubmit(data: UpdateTechVendorContactSchema) { if (!contact) return - + startTransition(async () => { try { - const { error } = await updateTechVendorContact({ + // 이메일을 소문자로 변환 + const normalizedData = { + ...data, + contactEmail: normalizeEmail(data.contactEmail) || undefined, + } + + const { error } = await updateTechVendorContact({ id: contact.id, vendorId: vendorId, - ...data + ...normalizedData, }) if (error) throw new Error(error) diff --git a/lib/tech-vendors/possible-items/add-item-dialog.tsx b/lib/tech-vendors/possible-items/add-item-dialog.tsx index 0e6edd19..ceb34276 100644 --- a/lib/tech-vendors/possible-items/add-item-dialog.tsx +++ b/lib/tech-vendors/possible-items/add-item-dialog.tsx @@ -166,9 +166,9 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro - 아이템 추가 + 아이템 연결 - 추가할 아이템을 선택하세요. 복수 선택이 가능합니다. + 연결할 아이템을 선택하세요. 복수 선택이 가능합니다. diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx index bed65727..49a673ff 100644 --- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx +++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx @@ -362,7 +362,7 @@ export function PossibleItemsTableToolbarActions({ onClick={onAdd} > - 아이템 추가 + 아이템 연결 {selectedRows.length > 0 && ( diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 72f8632d..940e59ce 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -40,6 +40,7 @@ import { sql } from "drizzle-orm"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { deleteFile, saveDRMFile } from "../file-stroage"; import { techSalesContactPossibleItems } from "@/db/schema"; +import { normalizeEmailFields } from "./utils"; /* ----------------------------------------------------- 1) 조회 관련 @@ -563,24 +564,6 @@ export async function updateTechVendorContact(input: UpdateTechVendorContactSche } } -export async function deleteTechVendorContact(contactId: number, vendorId: number) { - unstable_noStore(); - try { - const [deletedContact] = await db - .delete(techVendorContacts) - .where(eq(techVendorContacts.id, contactId)) - .returning(); - - // 캐시 무효화 - revalidateTag(`tech-vendor-contacts-${contactId}`); - revalidateTag(`tech-vendor-contacts-${vendorId}`); - - return { data: deletedContact, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - /* ----------------------------------------------------- 5) 아이템 관리 ----------------------------------------------------- */ @@ -4116,4 +4099,116 @@ export async function generateContactPossibleItemsErrorExcel(errors: Array<{ return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }) +} + +/** + * 기술영업 벤더 담당자 삭제 (cascade 방식) + * 1. tech_sales_contact_possible_items (해당 담당자의 아이템 매핑) + * 2. tech_vendor_contacts (해당 담당자) + */ +export async function deleteTechVendorContact(contactId: number, vendorId: number) { + unstable_noStore() + + try { + const result = await db.transaction(async (tx) => { + // 1. 해당 담당자의 아이템 매핑 삭제 (tech_sales_contact_possible_items) + await tx + .delete(techSalesContactPossibleItems) + .where(eq(techSalesContactPossibleItems.contactId, contactId)) + + // 2. 담당자 삭제 (tech_vendor_contacts) + const deletedContact = await tx + .delete(techVendorContacts) + .where(eq(techVendorContacts.id, contactId)) + .returning() + + return deletedContact + }) + + // 캐시 무효화 + revalidateTag(`tech-vendor-contacts-${vendorId}`) + revalidateTag("tech-vendors") + + return { + success: true, + data: result, + error: null + } + } catch (err) { + console.error("담당자 삭제 오류:", err) + return { + success: false, + data: null, + error: getErrorMessage(err) + } + } +} + +/** + * 기술영업 벤더 삭제 (cascade 방식) + * 1. tech_sales_contact_possible_items (담당자별 아이템) + * 2. tech_vendor_possible_items (벤더 아이템) + * 3. tech_vendor_contacts (벤더 담당자) + * 4. tech_vendor_attachments (벤더 첨부파일) + * 5. tech_vendors (벤더) + */ +// 임시 삭제 함수 +export async function deleteTechVendor(vendorId: number) { + unstable_noStore() + + try { + const result = await db.transaction(async (tx) => { + // 1. 해당 벤더의 담당자별 아이템 삭제 (tech_sales_contact_possible_items) + await tx + .delete(techSalesContactPossibleItems) + .where( + inArray( + techSalesContactPossibleItems.contactId, + tx + .select({ contactId: techVendorContacts.id }) + .from(techVendorContacts) + .where(eq(techVendorContacts.vendorId, vendorId)) + ) + ) + + // 2. 해당 벤더의 아이템 삭제 (tech_vendor_possible_items) + await tx + .delete(techVendorPossibleItems) + .where(eq(techVendorPossibleItems.vendorId, vendorId)) + + // 3. 해당 벤더의 담당자 삭제 (tech_vendor_contacts) + await tx + .delete(techVendorContacts) + .where(eq(techVendorContacts.vendorId, vendorId)) + + // 4. 해당 벤더의 첨부파일 삭제 (tech_vendor_attachments) + await tx + .delete(techVendorAttachments) + .where(eq(techVendorAttachments.vendorId, vendorId)) + + // 5. 벤더 삭제 (tech_vendors) + const deletedVendor = await tx + .delete(techVendors) + .where(eq(techVendors.id, vendorId)) + .returning() + + return deletedVendor + }) + + // 캐시 무효화 + revalidateTag("tech-vendors") + + return { + success: true, + data: result, + error: null + } + } catch (err) { + console.error("벤더 삭제 오류:", err) + return { + success: false, + data: null, + error: getErrorMessage(err) + } + } } \ No newline at end of file diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx index e89f5d6b..f696b30a 100644 --- a/lib/tech-vendors/table/add-vendor-dialog.tsx +++ b/lib/tech-vendors/table/add-vendor-dialog.tsx @@ -30,6 +30,7 @@ import { Textarea } from "@/components/ui/textarea" import { Plus, Loader2 } from "lucide-react" import { addTechVendor } from "../service" +import { normalizeEmailFields } from "../utils" // 폼 스키마 정의 const addVendorSchema = z.object({ @@ -92,6 +93,13 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { const onSubmit = async (data: AddVendorFormData) => { setIsLoading(true) try { + // 이메일 필드들을 소문자로 변환 + const normalizedEmails = normalizeEmailFields({ + email: data.email, + agentEmail: data.agentEmail, + representativeEmail: data.representativeEmail, + }) + const result = await addTechVendor({ ...data, vendorCode: data.vendorCode || null, @@ -100,13 +108,14 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) { countryFab: data.countryFab || null, agentName: data.agentName || null, agentPhone: data.agentPhone || null, - agentEmail: data.agentEmail || null, + agentEmail: normalizedEmails.agentEmail, address: data.address || null, phone: data.phone || null, website: data.website || null, + email: normalizedEmails.email, techVendorType: data.techVendorType.join(','), representativeName: data.representativeName || null, - representativeEmail: data.representativeEmail || null, + representativeEmail: normalizedEmails.representativeEmail, representativePhone: data.representativePhone || null, representativeBirth: data.representativeBirth || null, taxId: data.taxId || "", diff --git a/lib/tech-vendors/table/delete-tech-vendors-dialog.tsx b/lib/tech-vendors/table/delete-tech-vendors-dialog.tsx new file mode 100644 index 00000000..4fd3f32a --- /dev/null +++ b/lib/tech-vendors/table/delete-tech-vendors-dialog.tsx @@ -0,0 +1,80 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { Trash2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" + +import { deleteTechVendor } from "../service" +import type { TechVendor } from "@/db/schema/techVendors" + +interface DeleteTechVendorDialogProps { + vendor: TechVendor + onSuccess?: () => void +} +// 임시 삭제 버튼 +export function DeleteTechVendorDialog({ vendor, onSuccess }: DeleteTechVendorDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false) + + const handleDelete = async () => { + setIsDeleting(true) + try { + const result = await deleteTechVendor(vendor.id) + + if (result.success) { + toast.success("벤더가 성공적으로 삭제되었습니다.") + onSuccess?.() + } else { + toast.error(result.error || "벤더 삭제 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("벤더 삭제 오류:", error) + toast.error("벤더 삭제 중 오류가 발생했습니다.") + } finally { + setIsDeleting(false) + } + } + + return ( + + + + + + + 벤더 삭제 + + {vendor.vendorName} 벤더를 정말 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없으며, 관련된 모든 데이터(담당자, 아이템 등)가 함께 삭제됩니다. +
+
+ + 취소 + + {isDeleting ? "삭제 중..." : "삭제"} + + +
+
+ ) +} diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx index 85b16bc7..e0f95195 100644 --- a/lib/tech-vendors/table/import-button.tsx +++ b/lib/tech-vendors/table/import-button.tsx @@ -17,6 +17,7 @@ import { import { Progress } from "@/components/ui/progress" import { importTechVendorsFromExcel } from "../service" import { decryptWithServerAction } from "@/components/drm/drmUtils" +import { normalizeEmail } from "../utils" interface ImportTechVendorButtonProps { onSuccess?: () => void; @@ -192,13 +193,17 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp // 벤더 데이터 처리 const vendors = dataRows.map(row => { - const vendorEmail = row["이메일"] || row["email"] || ""; + // 이메일들을 소문자로 변환 + const vendorEmail = normalizeEmail(row["이메일"] || row["email"]) || null; + const contactEmail = normalizeEmail(row["담당자이메일"] || row["contactEmail"]); + const agentEmail = normalizeEmail(row["에이전트이메일"] || row["agentEmail"]); + const representativeEmail = normalizeEmail(row["대표자이메일"] || row["representativeEmail"]); + const contactName = row["담당자명"] || row["contactName"] || ""; - const contactEmail = row["담당자이메일"] || row["contactEmail"] || ""; - + // 담당자 정보 처리: 담당자가 없으면 벤더 이메일을 기본 담당자로 사용 const contacts = []; - + if (contactName && contactEmail) { // 명시적인 담당자가 있는 경우 contacts.push({ @@ -221,7 +226,7 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp isPrimary: true }); } - + return { vendorName: row["업체명"] || row["vendorName"] || "", vendorCode: row["업체코드"] || row["vendorCode"] || null, @@ -232,13 +237,13 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp countryFab: row["제조국"] || row["countryFab"] || null, agentName: row["에이전트명"] || row["agentName"] || null, agentPhone: row["에이전트연락처"] || row["agentPhone"] || null, - agentEmail: row["에이전트이메일"] || row["agentEmail"] || null, + agentEmail: agentEmail, address: row["주소"] || row["address"] || null, phone: row["전화번호"] || row["phone"] || null, website: row["웹사이트"] || row["website"] || null, techVendorType: row["벤더타입"] || row["techVendorType"] || "", representativeName: row["대표자명"] || row["representativeName"] || null, - representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null, + representativeEmail: representativeEmail, representativePhone: row["대표자연락처"] || row["representativePhone"] || null, representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null, items: row["아이템"] || row["items"] || "", diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index da17a975..c1bf6229 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -30,6 +30,7 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig" import { Separator } from "@/components/ui/separator" import { getVendorStatusIcon } from "../utils" +import { DeleteTechVendorDialog } from "./delete-tech-vendors-dialog" // 타입 정의 추가 type StatusType = (typeof techVendors.status.enumValues)[number]; @@ -47,6 +48,7 @@ type NextRouter = ReturnType; interface GetColumnsProps { setRowAction: React.Dispatch | null>>; router: NextRouter; + onVendorDeleted?: () => void; } @@ -55,7 +57,7 @@ interface GetColumnsProps { /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef[] { +export function getColumns({ setRowAction, router, onVendorDeleted }: GetColumnsProps): ColumnDef[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- @@ -169,6 +171,14 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef + + {/* 벤더 임시 삭제 버튼 */} +
+ +
diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index 553ff109..e432a453 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -58,8 +58,13 @@ export function TechVendorsTable({ const router = useRouter() // getColumns() 호출 시, router를 주입 + // 임시 삭제 버튼 추가 const columns = React.useMemo( - () => getColumns({ setRowAction, router }), + () => getColumns({ + setRowAction, + router, + onVendorDeleted: () => router.refresh() + }), [setRowAction, router] ) diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx index 8498df51..3cbb2ba3 100644 --- a/lib/tech-vendors/table/update-vendor-sheet.tsx +++ b/lib/tech-vendors/table/update-vendor-sheet.tsx @@ -45,6 +45,7 @@ import { useSession } from "next-auth/react" import { TechVendor, techVendors } from "@/db/schema/techVendors" import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations" import { modifyTechVendor } from "../service" +import { normalizeEmailFields } from "../utils" interface UpdateVendorSheetProps extends React.ComponentPropsWithRef { @@ -186,12 +187,22 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}` : "" // Empty string instead of undefined + // 이메일 필드들을 소문자로 변환 + const normalizedEmails = normalizeEmailFields({ + email: data.email, + agentEmail: data.agentEmail, + representativeEmail: data.representativeEmail, + }) + // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가 - const { error } = await modifyTechVendor({ + const { error } = await modifyTechVendor({ id: String(vendor.id), userId: Number(session.user.id), // Add user ID from session comment: statusComment, // Add comment for status changes ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 + email: normalizedEmails.email || undefined, + agentEmail: normalizedEmails.agentEmail || undefined, + representativeEmail: normalizedEmails.representativeEmail || undefined, techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined, }) diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts index ac91cd8d..ea8cdbd3 100644 --- a/lib/tech-vendors/utils.ts +++ b/lib/tech-vendors/utils.ts @@ -1,28 +1,56 @@ -import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react"; -import type { TechVendor } from "@/db/schema/techVendors"; - -type StatusType = TechVendor["status"]; - -/** - * 기술벤더 상태에 대한 아이콘을 반환합니다. - */ -export function getVendorStatusIcon(status: StatusType): LucideIcon { - switch (status) { - case "PENDING_INVITE": - return Clock; - case "INVITED": - return Mail; - case "QUOTE_COMPARISON": - return BarChart2; - case "ACTIVE": - return CheckCircle2; - case "INACTIVE": - return CircleAlert; - case "BLACKLISTED": - return ShieldAlert; - default: - return CircleAlert; - } -} - - +import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react"; +import type { TechVendor } from "@/db/schema/techVendors"; + +type StatusType = TechVendor["status"]; + +/** + * 기술벤더 상태에 대한 아이콘을 반환합니다. + */ +export function getVendorStatusIcon(status: StatusType): LucideIcon { + switch (status) { + case "PENDING_INVITE": + return Clock; + case "INVITED": + return Mail; + case "QUOTE_COMPARISON": + return BarChart2; + case "ACTIVE": + return CheckCircle2; + case "INACTIVE": + return CircleAlert; + case "BLACKLISTED": + return ShieldAlert; + default: + return CircleAlert; + } +} + +/** + * 이메일을 소문자로 변환합니다. + * null, undefined, 또는 빈 문자열인 경우 null을 반환합니다. + */ +export function normalizeEmail(email: string | null | undefined): string | null { + if (!email || typeof email !== 'string' || email.trim() === '') { + return null; + } + return email.toLowerCase().trim(); +} + +/** + * 여러 이메일 필드들을 소문자로 변환합니다. + */ +export function normalizeEmailFields(data: { + email?: string | null; + agentEmail?: string | null; + representativeEmail?: string | null; + contactEmail?: string | null; +}) { + return { + email: normalizeEmail(data.email), + agentEmail: normalizeEmail(data.agentEmail), + representativeEmail: normalizeEmail(data.representativeEmail), + contactEmail: normalizeEmail(data.contactEmail), + }; +} + + diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 058ef48b..9a198ee5 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -2,8 +2,8 @@ import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache"; import db from "@/db/db"; -import { - techSalesRfqs, +import { + techSalesRfqs, techSalesVendorQuotations, techSalesVendorQuotationRevisions, techSalesAttachments, @@ -13,7 +13,8 @@ import { users, techSalesRfqComments, techSalesRfqItems, - biddingProjects + biddingProjects, + projectSeries } from "@/db/schema"; import { and, desc, eq, ilike, or, sql, inArray, count, asc, lt, ne } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; @@ -4209,6 +4210,7 @@ export async function getQuotationContacts(quotationId: number) { contactId: techSalesVendorQuotationContacts.contactId, contactName: techVendorContacts.contactName, contactPosition: techVendorContacts.contactPosition, + contactTitle: techVendorContacts.contactTitle, contactEmail: techVendorContacts.contactEmail, contactPhone: techVendorContacts.contactPhone, contactCountry: techVendorContacts.contactCountry, @@ -4320,15 +4322,23 @@ export async function getTechSalesRfqById(id: number) { pjtType: biddingProjects.pjtType, ptypeNm: biddingProjects.ptypeNm, projMsrm: biddingProjects.projMsrm, + pspid: biddingProjects.pspid, }) .from(biddingProjects) .where(eq(biddingProjects.id, rfq?.biddingProjectId ?? 0)); - + + // 시리즈 정보 가져오기 + const series = await db + .select() + .from(projectSeries) + .where(eq(projectSeries.pspid, project?.pspid ?? "")) + .orderBy(projectSeries.sersNo); + if (!rfq) { return { data: null, error: "RFQ를 찾을 수 없습니다." }; } - - return { data: { ...rfq, project }, error: null }; + + return { data: { ...rfq, project, series }, error: null }; } catch (err) { console.error("Error fetching RFQ:", err); return { data: null, error: getErrorMessage(err) }; @@ -4339,6 +4349,7 @@ export async function getTechSalesRfqById(id: number) { export async function updateTechSalesRfq(data: { id: number; description: string; + remark: string; dueDate: Date; updatedBy: number; }) { @@ -4347,15 +4358,16 @@ export async function updateTechSalesRfq(data: { const rfq = await tx.query.techSalesRfqs.findFirst({ where: eq(techSalesRfqs.id, data.id), }); - + if (!rfq) { return { data: null, error: "RFQ를 찾을 수 없습니다." }; } - + const [updatedRfq] = await tx .update(techSalesRfqs) .set({ description: data.description, // description 필드로 업데이트 + remark: data.remark, // remark 필드로 업데이트 dueDate: data.dueDate, updatedAt: new Date(), }) diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx index b851f7e8..035cd97e 100644 --- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { toast } from "sonner" import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { CalendarIcon } from "lucide-react" @@ -228,9 +229,10 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) { return filtered }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) - // 사용 가능한 선종 목록 가져오기 (OPTION 제외) + // 사용 가능한 선종 목록 가져오기 (OPTION 제외, others는 맨 밑으로) const availableShipTypes = React.useMemo(() => { - return shipTypes.filter(shipType => shipType !== "OPTION") + const filtered = shipTypes.filter(shipType => shipType !== "OPTION") + return [...filtered.filter(type => type !== "OTHERS"), ...filtered.filter(type => type === "OTHERS")] }, [shipTypes]) // 프로젝트 선택 처리 @@ -408,8 +410,9 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) { RFQ Context - diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx index ea982407..438ee840 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -362,99 +362,128 @@ export function AddVendorDialog({ ) // 벤더 구분자 설정 UI - const renderVendorFlagsStep = () => ( -
-
- 선택된 벤더들의 구분자를 설정해주세요. 각 벤더별로 여러 구분자를 선택할 수 있습니다. -
- - {selectedVendorData.length > 0 ? ( -
- {selectedVendorData.map((vendor) => ( - - - {vendor.vendorName} - - {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} - - - -
-
- - handleVendorFlagChange(vendor.id, 'isCustomerPreferred', checked as boolean) - } - /> - -
- -
- - handleVendorFlagChange(vendor.id, 'isNewDiscovery', checked as boolean) - } - /> - -
- -
- - handleVendorFlagChange(vendor.id, 'isProjectApproved', checked as boolean) - } - /> - -
- -
- - handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean) - } - /> - -
-
-
-
- ))} -
- ) : ( -
- 선택된 벤더가 없습니다 + const renderVendorFlagsStep = () => { + const isShipRfq = selectedRfq?.rfqType === 'SHIP' + + return ( +
+
+ 선택된 벤더들의 구분자를 설정해주세요. 각 벤더별로 여러 구분자를 선택할 수 있습니다.
- )} -
- ) + + {selectedVendorData.length > 0 ? ( +
+ {selectedVendorData.map((vendor) => ( + + + {vendor.vendorName} + + {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} + + + +
+ {/* 조선 RFQ인 경우: 고객 선호벤더, 신규발굴벤더, SHI Proposal Vendor 표시 */} + {isShipRfq && ( + <> +
+ + handleVendorFlagChange(vendor.id, 'isCustomerPreferred', checked as boolean) + } + /> + +
+ +
+ + handleVendorFlagChange(vendor.id, 'isNewDiscovery', checked as boolean) + } + /> + +
+
+ + handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean) + } + /> + +
+ + )} + + {/* 조선 RFQ가 아닌 경우: Project Approved Vendor, SHI Proposal Vendor 표시 */} + {!isShipRfq && ( + <> +
+ + handleVendorFlagChange(vendor.id, 'isProjectApproved', checked as boolean) + } + /> + +
+ +
+ + handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean) + } + /> + +
+ + )} +
+
+
+ ))} +
+ ) : ( +
+ 선택된 벤더가 없습니다 +
+ )} +
+ ) + } return ( diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx index 61c97b1b..608b5670 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx @@ -109,60 +109,62 @@ export function QuotationContactsViewDialog({
) : (
- {contacts.map((contact) => ( -
-
- -
-
- {contact.contactName} - {contact.isPrimary && ( - - 주담당자 - + {contacts + .filter((contact) => contact.contactTitle) // contactTitle이 있는 담당자만 필터링 (체크표시된 담당자) + .map((contact) => ( +
+
+ +
+
+ {contact.contactName} + {contact.isPrimary && ( + + 주담당자 + + )} +
+ {contact.contactPosition && ( +

+ {contact.contactPosition} +

+ )} + {contact.contactTitle && ( +

+ {contact.contactTitle} +

+ )} + {contact.contactCountry && ( +

+ {contact.contactCountry} +

)}
- {contact.contactPosition && ( -

- {contact.contactPosition} -

- )} - {contact.contactTitle && ( -

- {contact.contactTitle} -

- )} - {contact.contactCountry && ( -

- {contact.contactCountry} -

- )} -
-
- -
-
- - {contact.contactEmail}
- {contact.contactPhone && ( + +
- - {contact.contactPhone} + + {contact.contactEmail} +
+ {contact.contactPhone && ( +
+ + {contact.contactPhone} +
+ )} +
+ 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
- )} -
- 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
-
- ))} - + ))} +
- 총 {contacts.length}명의 담당자에게 발송됨 + 총 {contacts.filter((contact) => contact.contactTitle).length}명의 담당자에게 발송됨
)} diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx index 7d972b91..023d3599 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx @@ -78,6 +78,7 @@ interface QuotationHistoryDialogProps { open: boolean onOpenChange: (open: boolean) => void quotationId: number | null + isInternal?: boolean // 내부 사용자인지 여부 (partners가 아니면 내부) } const statusConfig = { @@ -88,15 +89,16 @@ const statusConfig = { "Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" }, } -function QuotationCard({ - data, - version, - isCurrent = false, - revisedBy, +function QuotationCard({ + data, + version, + isCurrent = false, + revisedBy, revisedAt, attachments, revisionId, revisionNote, + isInternal = false, }: { data: QuotationSnapshot | QuotationHistoryData["current"] version: number @@ -106,6 +108,7 @@ function QuotationCard({ attachments?: QuotationAttachment[] revisionId?: number revisionNote?: string | null + isInternal?: boolean }) { const statusInfo = statusConfig[data.status as keyof typeof statusConfig] || { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" } @@ -171,7 +174,7 @@ function QuotationCard({
)} - {revisionId && ( + {revisionId && isInternal && (

SHI Comment