From c62ec046327fd388ebce04571b55910747e69a3b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 9 Sep 2025 10:32:34 +0000 Subject: (정희성, 최겸, 대표님) formatDate 변경 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 2 +- lib/bidding/list/bidding-detail-dialogs.tsx | 16 +- .../list/biddings-table-toolbar-actions.tsx | 52 +- lib/bidding/list/create-bidding-dialog.tsx | 24 +- lib/bidding/list/edit-bidding-sheet.tsx | 33 +- lib/bidding/pre-quote/service.ts | 27 +- lib/bidding/service.ts | 22 +- lib/bidding/validation.ts | 12 +- lib/forms/stat.ts | 82 ++- lib/legal-review/status/request-review-dialog.tsx | 182 ++--- lib/rfq-last/service.ts | 310 +++++++- lib/rfq-last/vendor/rfq-vendor-table.tsx | 205 ++++-- lib/rfq-last/vendor/send-rfq-dialog.tsx | 803 +++++++++++++++------ lib/site-visit/client-site-visit-wrapper.tsx | 8 +- lib/site-visit/site-visit-detail-dialog.tsx | 8 +- lib/site-visit/vendor-info-view-dialog.tsx | 8 +- lib/tech-vendors/table/vendor-all-export.ts | 11 +- lib/vendors/service.ts | 42 +- lib/vendors/table/vendor-all-export.ts | 10 +- 19 files changed, 1276 insertions(+), 581 deletions(-) (limited to 'lib') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index a045330d..2a66824a 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -2621,7 +2621,7 @@ export async function requestLegalReviewAction( const legalWorkResult = await tx.insert(legalWorks).values({ basicContractId: contract.id, // 레퍼런스 ID category: category, - status: "검토요청", + status: "신규등록", vendorId: contract.vendorId, vendorCode: contract.vendorCode, vendorName: contract.vendorName || "업체명 없음", diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx index 2e58d676..4fbca616 100644 --- a/lib/bidding/list/bidding-detail-dialogs.tsx +++ b/lib/bidding/list/bidding-detail-dialogs.tsx @@ -45,6 +45,7 @@ import { toast } from "sonner" import { BiddingListItem } from "@/db/schema" import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service" +import { formatDate } from "@/lib/utils" // 타입 정의 interface SpecificationMeetingDetails { @@ -301,19 +302,6 @@ export function SpecificationMeetingDialog({ } }; - const formatDate = (dateString: string) => { - try { - return new Date(dateString).toLocaleDateString('ko-KR', { - year: 'numeric', - month: 'long', - day: 'numeric', - weekday: 'long' - }); - } catch { - return dateString; - } - }; - return ( @@ -355,7 +343,7 @@ export function SpecificationMeetingDialog({
- 날짜: {formatDate(data.meetingDate)} + 날짜: {formatDate(data.meetingDate, "kr")} {data.meetingTime && {data.meetingTime}}
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index 81982a43..70b48a36 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -8,7 +8,6 @@ import { } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" - import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" import { @@ -37,41 +36,6 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio .map(row => row.original) }, [table.getFilteredSelectedRowModel().rows]) - // 사전견적 요청 가능한 입찰들 (입찰생성 상태) - const preQuoteEligibleBiddings = React.useMemo(() => { - return selectedBiddings.filter(bidding => - bidding.status === 'bidding_generated' - ) - }, [selectedBiddings]) - - // 개찰 가능한 입찰들 (내정가 산정 완료) - const openEligibleBiddings = React.useMemo(() => { - return selectedBiddings.filter(bidding => - bidding.status === 'set_target_price' - ) - }, [selectedBiddings]) - - - const handlePreQuoteRequest = () => { - if (preQuoteEligibleBiddings.length === 0) { - toast.warning("사전견적 요청 가능한 입찰을 선택해주세요.") - return - } - - toast.success(`${preQuoteEligibleBiddings.length}개 입찰의 사전견적을 요청했습니다.`) - // TODO: 실제 사전견적 요청 로직 구현 - } - - const handleBiddingOpen = () => { - if (openEligibleBiddings.length === 0) { - toast.warning("개찰 가능한 입찰을 선택해주세요.") - return - } - - toast.success(`${openEligibleBiddings.length}개 입찰을 개찰했습니다.`) - // TODO: 실제 개찰 로직 구현 - } - const handleExport = async () => { try { setIsExporting(true) @@ -92,20 +56,8 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio {/* 신규 생성 */} - {/* 사전견적 요청 */} - {preQuoteEligibleBiddings.length > 0 && ( - - )} - {/* 개찰 (입찰 오픈) */} - {openEligibleBiddings.length > 0 && ( + {/* {openEligibleBiddings.length > 0 && ( - )} + )} */} {/* Export */} diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 88697903..f21782ff 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -274,7 +274,10 @@ export function CreateBiddingDialog() { conditions: { isValid: biddingConditions.paymentTerms.trim() !== "" && biddingConditions.taxConditions.trim() !== "" && - biddingConditions.incoterms.trim() !== "", + biddingConditions.incoterms.trim() !== "" && + biddingConditions.contractDeliveryDate.trim() !== "" && + biddingConditions.shippingPort.trim() !== "" && + biddingConditions.destinationPort.trim() !== "", hasErrors: false }, details: { @@ -286,7 +289,7 @@ export function CreateBiddingDialog() { hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone) } } - }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson]) + }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, biddingConditions]) const tabValidation = getTabValidationState() @@ -428,7 +431,7 @@ export function CreateBiddingDialog() { toast.error("제출 시작일시와 마감일시를 입력해주세요") } } else if (activeTab === "conditions") { - toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건)") + toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 도착지)") } return } @@ -474,17 +477,18 @@ export function CreateBiddingDialog() { const result = await createBidding(extendedData, userId) if (result.success) { - toast.success(result.message) + toast.success(result.message || "입찰이 성공적으로 생성되었습니다.") setOpen(false) router.refresh() // 생성된 입찰 상세페이지로 이동할지 묻기 - if (result.data?.id) { + if (result.success && 'data' in result && result.data?.id) { setCreatedBiddingId(result.data.id) setShowSuccessDialog(true) } } else { - toast.error(result.error || "입찰 생성에 실패했습니다.") + const errorMessage = result.success === false && 'error' in result ? result.error : "입찰 생성에 실패했습니다." + toast.error(errorMessage) } } catch (error) { console.error("Error creating bidding:", error) @@ -1316,7 +1320,7 @@ export function CreateBiddingDialog() { 세금조건 * setBiddingConditions(prev => ({ ...prev, @@ -1341,7 +1345,7 @@ export function CreateBiddingDialog() {
- +
- + { if (open && bidding) { - const formatDateForInput = (date: Date | string | null): string => { - if (!date) return "" - try { - const d = new Date(date) - if (isNaN(d.getTime())) return "" - return d.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm - } catch { - return "" - } - } - - const formatDateOnlyForInput = (date: Date | string | null): string => { - if (!date) return "" - try { - const d = new Date(date) - if (isNaN(d.getTime())) return "" - return d.toISOString().slice(0, 10) // YYYY-MM-DD - } catch { - return "" - } - } - form.reset({ biddingNumber: bidding.biddingNumber || "", revision: bidding.revision || 0, @@ -147,11 +126,11 @@ export function EditBiddingSheet({ awardCount: bidding.awardCount || "single", contractPeriod: bidding.contractPeriod || "", - preQuoteDate: formatDateOnlyForInput(bidding.preQuoteDate), - biddingRegistrationDate: formatDateOnlyForInput(bidding.biddingRegistrationDate), - submissionStartDate: formatDateForInput(bidding.submissionStartDate), - submissionEndDate: formatDateForInput(bidding.submissionEndDate), - evaluationDate: formatDateForInput(bidding.evaluationDate), + preQuoteDate: formatDate(bidding.preQuoteDate, "kr"), + biddingRegistrationDate: formatDate(bidding.biddingRegistrationDate, "kr"), + submissionStartDate: formatDate(bidding.submissionStartDate, "kr"), + submissionEndDate: formatDate(bidding.submissionEndDate, "kr"), + evaluationDate: formatDate(bidding.evaluationDate, "kr"), hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, hasPrDocument: bidding.hasPrDocument || false, diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index b5b06769..35bc8941 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -330,17 +330,20 @@ export async function sendPreQuoteInvitations(companyIds: number[]) { } } // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만) - await tx - .update(biddings) - .set({ - status: 'request_for_quotation', - updatedAt: new Date() - }) - .where(and( - eq(biddings.id, biddingId), - eq(biddings.status, 'bidding_generated') - )) - + for (const company of companiesInfo) { + await db.transaction(async (tx) => { + await tx + .update(biddings) + .set({ + status: 'request_for_quotation', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, company.biddingId), + eq(biddings.status, 'bidding_generated') + )) + }) + } return { success: true, message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.` @@ -608,7 +611,7 @@ export async function submitPreQuoteResponse( .update(biddings) .set({ status: 'received_quotation', - preQuoteReceivedAt: new Date(), // 사전견적 접수일 업데이트 + preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트 updatedAt: new Date() }) .where(and( diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 8c99bfed..c4904219 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -378,13 +378,11 @@ export interface CreateBiddingInput extends CreateBiddingSchema { paymentTerms: string taxConditions: string incoterms: string - proposedDeliveryDate: string - proposedShippingPort: string - proposedDestinationPort: string - priceAdjustmentApplicable: boolean - specialConditions: string - sparePartRequirement: string - additionalNotes: string + contractDeliveryDate: string + shippingPort: string + destinationPort: string + isPriceAdjustmentApplicable: boolean + sparePartOptions: string } } @@ -612,11 +610,11 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { paymentTerms: input.biddingConditions.paymentTerms, taxConditions: input.biddingConditions.taxConditions, incoterms: input.biddingConditions.incoterms, - contractDeliveryDate: input.biddingConditions.proposedDeliveryDate ? new Date(input.biddingConditions.proposedDeliveryDate) : null, - shippingPort: input.biddingConditions.proposedShippingPort, - destinationPort: input.biddingConditions.proposedDestinationPort, - isPriceAdjustmentApplicable: input.biddingConditions.priceAdjustmentApplicable, - sparePartOptions: input.biddingConditions.sparePartRequirement, + contractDeliveryDate: input.biddingConditions.contractDeliveryDate ? new Date(input.biddingConditions.contractDeliveryDate) : null, + shippingPort: input.biddingConditions.shippingPort, + destinationPort: input.biddingConditions.destinationPort, + isPriceAdjustmentApplicable: input.biddingConditions.isPriceAdjustmentApplicable, + sparePartOptions: input.biddingConditions.sparePartOptions, }) } catch (error) { console.error('Error saving bidding conditions:', error) diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 95cbb02c..a7f78f72 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -107,13 +107,11 @@ export const createBiddingSchema = z.object({ paymentTerms: z.string().min(1, "지급조건은 필수입니다"), taxConditions: z.string().min(1, "세금조건은 필수입니다"), incoterms: z.string().min(1, "운송조건은 필수입니다"), - proposedDeliveryDate: z.string().optional(), - proposedShippingPort: z.string().optional(), - proposedDestinationPort: z.string().optional(), - priceAdjustmentApplicable: z.boolean().default(false), - specialConditions: z.string().optional(), - sparePartRequirement: z.string().optional(), - additionalNotes: z.string().optional(), + contractDeliveryDate: z.string().min(1, "계약납품일은 필수입니다"), + shippingPort: z.string().min(1, "선적지는 필수입니다"), + destinationPort: z.string().min(1, "도착지는 필수입니다"), + isPriceAdjustmentApplicable: z.boolean().default(false), + sparePartOptions: z.string().optional(), }).optional(), }).refine((data) => { // 제출 기간 검증: 시작일이 마감일보다 이전이어야 함 diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts index 45bf2710..887f1a05 100644 --- a/lib/forms/stat.ts +++ b/lib/forms/stat.ts @@ -1,7 +1,7 @@ "use server" import db from "@/db/db" -import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes } from "@/db/schema" +import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema" import { eq, and } from "drizzle-orm" import { getEditableFieldsByTag } from "./services" @@ -15,17 +15,47 @@ interface VendorFormStatus { completionRate: number // 완료율 (%) } +export async function getProjectsWithContracts() { + try { + const projectList = await db + .selectDistinct({ + id: projects.id, + projectCode: projects.projectCode, + projectName: projects.projectName, + }) + .from(projects) + .innerJoin(contracts, eq(contracts.projectId, projects.id)) + .orderBy(projects.projectCode) + + return projectList + } catch (error) { + console.error('Error getting projects with contracts:', error) + throw new Error('계약이 있는 프로젝트 조회 중 오류가 발생했습니다.') + } +} + + -export async function getVendorFormStatus(): Promise { +export async function getVendorFormStatus(projectId?: number): Promise { try { - // 1. 모든 벤더 조회 - const vendorList = await db - .selectDistinct({ - vendorId: vendors.id, - vendorName: vendors.vendorName, - }) - .from(vendors) - .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + // 1. 벤더 조회 쿼리 수정 + const vendorList = projectId + ? await db + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + .where(eq(contracts.projectId, projectId)) + : await db + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + const vendorStatusList: VendorFormStatus[] = [] @@ -36,15 +66,29 @@ export async function getVendorFormStatus(): Promise { let vendorCompletedFields = 0 const uniqueTags = new Set() - // 2. 벤더별 계약 조회 - const vendorContracts = await db - .select({ - id: contracts.id, - projectId: contracts.projectId - }) - .from(contracts) - .where(eq(contracts.vendorId, vendor.vendorId)) - + // 2. 계약 조회 시 projectId 필터 추가 + const vendorContracts = projectId + ? await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where( + and( + eq(contracts.vendorId, vendor.vendorId), + eq(contracts.projectId, projectId) + ) + ) + : await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where(eq(contracts.vendorId, vendor.vendorId)) + + for (const contract of vendorContracts) { // 3. 계약별 contractItems 조회 const contractItemsList = await db diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx index 838752c4..bfaa14b9 100644 --- a/lib/legal-review/status/request-review-dialog.tsx +++ b/lib/legal-review/status/request-review-dialog.tsx @@ -54,18 +54,18 @@ const requestReviewSchema = z.object({ dueDate: z.string().min(1, "검토 완료 희망일을 선택해주세요"), assignee: z.string().optional(), notificationMethod: z.enum(["email", "internal", "both"]).default("both"), - + // 법무업무 상세 정보 reviewDepartment: z.enum(["준법문의", "법무검토"]), inquiryType: z.enum(["국내계약", "국내자문", "해외계약", "해외자문"]).optional(), - + // 공통 필드 title: z.string().min(1, "제목을 선택해주세요"), requestContent: z.string().min(1, "요청내용을 입력해주세요"), - + // 준법문의 전용 필드 isPublic: z.boolean().default(false), - + // 법무검토 전용 필드들 contractProjectName: z.string().optional(), contractType: z.string().optional(), @@ -103,6 +103,10 @@ export function RequestReviewDialog({ const [canRequest, setCanRequest] = React.useState(true) const [requestCheckMessage, setRequestCheckMessage] = React.useState("") + // "기타" 모드 상태를 별도로 관리 + const [isTitleOtherMode, setIsTitleOtherMode] = React.useState(false) + const [customTitle, setCustomTitle] = React.useState("") + // work의 category에 따라 기본 reviewDepartment 결정 const getDefaultReviewDepartment = () => { return work?.category === "CP" ? "준법문의" : "법무검토" @@ -128,7 +132,7 @@ export function RequestReviewDialog({ setCanRequest(result.canRequest) setRequestCheckMessage(result.reason || "") }) - + const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토" form.setValue("reviewDepartment", defaultDepartment) } @@ -137,14 +141,14 @@ export function RequestReviewDialog({ // 검토부문 감시 const reviewDepartment = form.watch("reviewDepartment") const inquiryType = form.watch("inquiryType") - const titleValue = form.watch("title") + // const titleValue = form.watch("title") // 조건부 필드 활성화 로직 const isContractTypeActive = inquiryType && ["국내계약", "해외계약", "해외자문"].includes(inquiryType) const isDomesticContractFieldsActive = inquiryType === "국내계약" const isFactualRelationActive = inquiryType && ["국내자문", "해외자문"].includes(inquiryType) const isOverseasFieldsActive = inquiryType && ["해외계약", "해외자문"].includes(inquiryType) - + // 제목 "기타" 선택 여부 확인 const isTitleOther = titleValue === "기타" @@ -169,6 +173,10 @@ export function RequestReviewDialog({ form.setValue("shipownerOrderer", "") form.setValue("projectType", "") form.setValue("governingLaw", "") + + setIsTitleOtherMode(false) // 기타 모드 해제 + setCustomTitle("") // 커스텀 제목 초기화 + } else { // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) const currentTitle = form.getValues("title") @@ -176,6 +184,9 @@ export function RequestReviewDialog({ form.setValue("title", "GTC검토") } form.setValue("isPublic", false) + + setIsTitleOtherMode(false) // 기타 모드 해제 + setCustomTitle("") // 커스텀 제목 초기화 } }, [reviewDepartment, form]) @@ -184,7 +195,7 @@ export function RequestReviewDialog({ if (inquiryType) { // 계약서 종류 초기화 (옵션이 달라지므로) form.setValue("contractType", "") - + // 조건에 맞지 않는 필드들 초기화 if (!isDomesticContractFieldsActive) { form.setValue("contractCounterparty", "") @@ -192,11 +203,11 @@ export function RequestReviewDialog({ form.setValue("contractPeriod", "") form.setValue("contractAmount", "") } - + if (!isFactualRelationActive) { form.setValue("factualRelation", "") } - + if (!isOverseasFieldsActive) { form.setValue("projectNumber", "") form.setValue("shipownerOrderer", "") @@ -224,15 +235,15 @@ export function RequestReviewDialog({ // 폼 제출 async function onSubmit(data: RequestReviewFormValues) { if (!work) return - + console.log("Request review data:", data) console.log("Work to review:", work) console.log("Attachments:", attachments) setIsSubmitting(true) - + try { const result = await requestReview(work.id, data, attachments) - + if (result.success) { toast.success(result.data?.message || `법무업무 #${work.id}에 대한 검토요청이 완료되었습니다.`) onOpenChange(false) @@ -263,6 +274,8 @@ export function RequestReviewDialog({ }) setAttachments([]) setEditorContent("") + setIsTitleOtherMode(false) // 기타 모드 리셋 + setCustomTitle("") // 커스텀 제목 리셋 } // 다이얼로그 닫기 핸들러 @@ -422,7 +435,7 @@ export function RequestReviewDialog({
- @@ -563,71 +576,76 @@ export function RequestReviewDialog({ /> )} - {/* 제목 - 조건부 렌더링 */} - ( - - 제목 - {!isTitleOther ? ( - // Select 모드 - - ) : ( - // Input 모드 (기타 선택시) -
-
- 기타 - -
- - field.onChange(e.target.value)} - autoFocus - /> - -
- )} - -
- )} - /> - + {/* 제목 필드 수정 */} + ( + + 제목 + {!isTitleOtherMode ? ( + // Select 모드 + + ) : ( + // Input 모드 (기타 선택시) +
+
+ 기타 + +
+ + { + field.onChange(e.target.value) + }} + autoFocus + /> + +
+ )} + +
+ )} + /> {/* 준법문의 전용 필드들 */} {reviewDepartment === "준법문의" && (
- + {/* 선택된 파일 목록 */} {attachments.length > 0 && (
@@ -949,8 +967,8 @@ export function RequestReviewDialog({ > 취소 - )} @@ -777,13 +866,13 @@ export function RfqVendorTable({ toast.success("데이터를 새로고침했습니다."); }, 1000); }} - disabled={isRefreshing} + disabled={isRefreshing || isLoadingSendData} > 새로고침
- ), [selectedRows, isRefreshing, handleBulkSend]); + ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]); return ( <> @@ -828,9 +917,9 @@ export function RfqVendorTable({ diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index dc420cad..9d88bdc9 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -14,8 +14,8 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Send, Building2, @@ -33,12 +33,18 @@ import { Info, File, CheckCircle, - RefreshCw + RefreshCw, + Phone, + Briefcase, + Building, + ChevronDown, + ChevronRight, + UserPlus } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; +import { cn, formatDate } from "@/lib/utils"; import { Tooltip, TooltipContent, @@ -47,16 +53,54 @@ import { } from "@/components/ui/tooltip"; import { Alert, - AlertDescription, + AlertDescription, AlertTitle } from "@/components/ui/alert"; - +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" // 타입 정의 +interface ContactDetail { + id: number; + name: string; + position?: string | null; + department?: string | null; + email: string; + phone?: string | null; + isPrimary: boolean; +} + +interface CustomEmail { + id: string; + email: string; + name?: string; +} + interface Vendor { vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; vendorEmail?: string | null; + representativeEmail?: string | null; + contacts?: ContactDetail[]; + contactsByPosition?: Record; + primaryEmail?: string | null; currency?: string | null; } @@ -93,7 +137,9 @@ interface RfqInfo { } interface VendorWithRecipients extends Vendor { - additionalRecipients: string[]; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails: CustomEmail[]; } interface SendRfqDialogProps { @@ -109,6 +155,12 @@ interface SendRfqDialogProps { }) => Promise; } +// 이메일 유효성 검사 함수 +const validateEmail = (email: string): boolean => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +}; + // 첨부파일 타입별 아이콘 const getAttachmentIcon = (type: string) => { switch (type.toLowerCase()) { @@ -132,6 +184,40 @@ const formatFileSize = (bytes?: number) => { return `${kb.toFixed(2)} KB`; }; +// 포지션별 아이콘 +const getPositionIcon = (position?: string | null) => { + if (!position) return ; + + const lowerPosition = position.toLowerCase(); + if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { + return ; + } + if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { + return ; + } + if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { + return ; + } + return ; +}; + +// 포지션별 색상 +const getPositionColor = (position?: string | null) => { + if (!position) return 'default'; + + const lowerPosition = position.toLowerCase(); + if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { + return 'destructive'; + } + if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { + return 'success'; + } + if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { + return 'secondary'; + } + return 'default'; +}; + export function SendRfqDialog({ open, onOpenChange, @@ -144,6 +230,9 @@ export function SendRfqDialog({ const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState([]); const [selectedAttachments, setSelectedAttachments] = React.useState([]); const [additionalMessage, setAdditionalMessage] = React.useState(""); + const [expandedVendors, setExpandedVendors] = React.useState([]); + const [customEmailInputs, setCustomEmailInputs] = React.useState>({}); + const [showCustomEmailForm, setShowCustomEmailForm] = React.useState>({}); // 초기화 React.useEffect(() => { @@ -151,48 +240,135 @@ export function SendRfqDialog({ setVendorsWithRecipients( selectedVendors.map(v => ({ ...v, - additionalRecipients: [] + selectedMainEmail: v.primaryEmail || v.vendorEmail || '', + additionalEmails: [], + customEmails: [] })) ); // 모든 첨부파일 선택 setSelectedAttachments(attachments.map(a => a.id)); + // 첫 번째 벤더를 자동으로 확장 + if (selectedVendors.length > 0) { + setExpandedVendors([selectedVendors[0].vendorId]); + } + // 초기화 + setCustomEmailInputs({}); + setShowCustomEmailForm({}); } }, [open, selectedVendors, attachments]); - // 추가 수신처 이메일 추가 - const handleAddRecipient = (vendorId: number, email: string) => { - if (!email) return; - - // 이메일 유효성 검사 - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { + // 커스텀 이메일 추가 + const addCustomEmail = (vendorId: number) => { + const input = customEmailInputs[vendorId]; + if (!input || !input.email) { + toast.error("이메일 주소를 입력해주세요."); + return; + } + + if (!validateEmail(input.email)) { toast.error("올바른 이메일 형식이 아닙니다."); return; } setVendorsWithRecipients(prev => - prev.map(v => - v.vendorId === vendorId - ? { ...v, additionalRecipients: [...v.additionalRecipients, email] } - : v - ) + prev.map(v => { + if (v.vendorId !== vendorId) return v; + + // 중복 체크 + const allEmails = [ + v.vendorEmail, + v.representativeEmail, + ...(v.contacts?.map(c => c.email) || []), + ...v.customEmails.map(c => c.email) + ].filter(Boolean); + + if (allEmails.includes(input.email)) { + toast.error("이미 등록된 이메일 주소입니다."); + return v; + } + + const newCustomEmail: CustomEmail = { + id: `custom-${Date.now()}`, + email: input.email, + name: input.name || input.email.split('@')[0] + }; + + return { + ...v, + customEmails: [...v.customEmails, newCustomEmail] + }; + }) ); + + // 입력 필드 초기화 + setCustomEmailInputs(prev => ({ + ...prev, + [vendorId]: { email: '', name: '' } + })); + setShowCustomEmailForm(prev => ({ + ...prev, + [vendorId]: false + })); + + toast.success("수신자가 추가되었습니다."); }; - // 추가 수신처 이메일 제거 - const handleRemoveRecipient = (vendorId: number, index: number) => { + // 커스텀 이메일 삭제 + const removeCustomEmail = (vendorId: number, emailId: string) => { + setVendorsWithRecipients(prev => + prev.map(v => { + if (v.vendorId !== vendorId) return v; + + const emailToRemove = v.customEmails.find(e => e.id === emailId); + if (!emailToRemove) return v; + + return { + ...v, + customEmails: v.customEmails.filter(e => e.id !== emailId), + // 만약 삭제하는 이메일이 선택된 주 수신자라면 초기화 + selectedMainEmail: v.selectedMainEmail === emailToRemove.email ? '' : v.selectedMainEmail, + // 추가 수신자에서도 제거 + additionalEmails: v.additionalEmails.filter(e => e !== emailToRemove.email) + }; + }) + ); + }; + + // 주 수신자 이메일 변경 + const handleMainEmailChange = (vendorId: number, email: string) => { setVendorsWithRecipients(prev => prev.map(v => v.vendorId === vendorId - ? { - ...v, - additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index) - } + ? { ...v, selectedMainEmail: email } : v ) ); }; + // 추가 수신자 토글 + const toggleAdditionalEmail = (vendorId: number, email: string) => { + setVendorsWithRecipients(prev => + prev.map(v => { + if (v.vendorId !== vendorId) return v; + + const additionalEmails = v.additionalEmails.includes(email) + ? v.additionalEmails.filter(e => e !== email) + : [...v.additionalEmails, email]; + + return { ...v, additionalEmails }; + }) + ); + }; + + // 벤더 확장/축소 토글 + const toggleVendorExpand = (vendorId: number) => { + setExpandedVendors(prev => + prev.includes(vendorId) + ? prev.filter(id => id !== vendorId) + : [...prev, vendorId] + ); + }; + // 첨부파일 선택 토글 const toggleAttachment = (attachmentId: number) => { setSelectedAttachments(prev => @@ -206,15 +382,24 @@ export function SendRfqDialog({ const handleSend = async () => { try { setIsSending(true); - + // 유효성 검사 + const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail); + if (vendorsWithoutEmail.length > 0) { + toast.error(`${vendorsWithoutEmail.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요.`); + return; + } + if (selectedAttachments.length === 0) { toast.warning("최소 하나 이상의 첨부파일을 선택해주세요."); return; } await onSend({ - vendors: vendorsWithRecipients, + vendors: vendorsWithRecipients.map(v => ({ + ...v, + additionalRecipients: v.additionalEmails, + })), attachments: selectedAttachments, message: additionalMessage, }); @@ -231,14 +416,14 @@ export function SendRfqDialog({ // 총 수신자 수 계산 const totalRecipientCount = React.useMemo(() => { - return vendorsWithRecipients.reduce((acc, v) => - acc + 1 + v.additionalRecipients.length, 0 + return vendorsWithRecipients.reduce((acc, v) => + acc + 1 + v.additionalEmails.length, 0 ); }, [vendorsWithRecipients]); return ( - + @@ -249,7 +434,8 @@ export function SendRfqDialog({ - + {/* ScrollArea 대신 div 사용 */} +
{/* RFQ 정보 섹션 */}
@@ -257,88 +443,367 @@ export function SendRfqDialog({ RFQ 정보
- +
- {/* 프로젝트 정보 */}
- 프로젝트: - - {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"}) - + RFQ 코드: + {rfqInfo?.rfqCode}
- 견적번호: - {rfqInfo.rfqCode} -
-
- - {/* 담당자 정보 */} -
-
- 구매담당: - - {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"} + 견적마감일: + + {formatDate(rfqInfo?.dueDate, "KR")}
- 설계담당: - - {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"} + 프로젝트: + + {rfqInfo?.projectCode} ({rfqInfo?.projectName})
-
- - {/* PKG 및 자재 정보 */} -
- PKG 정보: - - {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"}) + 자재그룹: + + {rfqInfo?.packageNo} - {rfqInfo?.packageName}
- 자재그룹: - - {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"}) + 구매담당자: + + {rfqInfo?.picName} ({rfqInfo?.picCode}) {rfqInfo?.picTeam}
-
- - {/* 견적 정보 */} -
- 견적마감일: - - {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })} + 설계담당자: + + {rfqInfo?.designPicName}
-
- 평가적용: - - {rfqInfo.evaluationApply ? "Y" : "N"} - -
+ {rfqInfo?.rfqCode.startsWith("F") && + <> +
+ 견적명: + + {rfqInfo?.rfqTitle} + +
+
+ 견적종류: + + {rfqInfo?.rfqType} + +
+ + }
+
+
- {/* 견적명 */} -
- 견적명: - {rfqInfo.rfqTitle} + + + {/* 수신 업체 섹션 - 테이블 버전 with 인라인 추가 폼 */} +
+
+
+ + 수신 업체 ({selectedVendors.length})
+ + + 총 {totalRecipientCount}명 + +
- {/* 계약구분 (일반견적일 때만) */} - {rfqInfo.rfqType === "일반견적" && ( -
- 계약구분: - {rfqInfo.contractType || "-"} -
- )} +
+ + + + + + + + + + + + {vendorsWithRecipients.map((vendor, index) => { + const allContacts = vendor.contacts || []; + const allEmails = [ + ...(vendor.representativeEmail ? [{ + value: vendor.representativeEmail, + label: '대표자', + email: vendor.representativeEmail, + type: 'representative' + }] : []), + ...allContacts.map(c => ({ + value: c.email, + label: `${c.name} ${c.position ? `(${c.position})` : ''}`, + email: c.email, + type: 'contact' + })), + ...vendor.customEmails.map(c => ({ + value: c.email, + label: c.name || c.email, + email: c.email, + type: 'custom' + })), + ...(vendor.vendorEmail && vendor.vendorEmail !== vendor.representativeEmail ? [{ + value: vendor.vendorEmail, + label: '업체 기본', + email: vendor.vendorEmail, + type: 'default' + }] : []) + ]; + + const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); + const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); + const isFormOpen = showCustomEmailForm[vendor.vendorId]; + + return ( + + + + + + + + + + {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} + {isFormOpen && ( + + + + )} + + ); + })} + +
No.업체명주 수신자CC작업
+
+ {index + 1} +
+
+
+
{vendor.vendorName}
+
+ + {vendor.vendorCountry} + + + {vendor.vendorCode} + +
+
+
+ + {!vendor.selectedMainEmail && ( + 필수 + )} + + + + + + +
+ {ccEmails.map((email) => ( +
+ toggleAdditionalEmail(vendor.vendorId, email.value)} + className="h-3 w-3" + /> + +
+ ))} +
+
+
+
+
+ + + + + + {isFormOpen ? "닫기" : "수신자 추가"} + + + {vendor.customEmails.length > 0 && ( + + +{vendor.customEmails.length} + + )} +
+
+
+
+
+ + 수신자 추가 - {vendor.vendorName} +
+ +
+ + {/* 한 줄에 모든 요소 배치 - 명확한 너비 지정 */} +
+
+ + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + name: e.target.value + } + }))} + /> +
+
+ + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + email: e.target.value + } + }))} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomEmail(vendor.vendorId); + } + }} + /> +
+ + +
+ + {/* 추가된 커스텀 이메일 목록 */} + {vendor.customEmails.length > 0 && ( +
+
추가된 수신자 목록
+
+ {vendor.customEmails.map((custom) => ( +
+
+ +
+
{custom.name}
+
{custom.email}
+
+
+ +
+ ))} +
+
+ )} +
+
- + {/* 첨부파일 섹션 */}
@@ -361,45 +826,30 @@ export function SendRfqDialog({
-
+
{attachments.length > 0 ? ( attachments.map((attachment) => (
-
+
toggleAttachment(attachment.id)} /> {getAttachmentIcon(attachment.attachmentType)} -
-
- - {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} - - - {attachment.currentRevision} - -
- {attachment.description && ( -

- {attachment.description} -

- )} -
-
-
- - {formatFileSize(attachment.fileSize)} + + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} + + {attachment.currentRevision} +
)) ) : ( -
- +

첨부파일이 없습니다.

)} @@ -408,124 +858,7 @@ export function SendRfqDialog({ - {/* 수신 업체 섹션 */} -
-
-
- - 수신 업체 ({selectedVendors.length}) -
- - - 총 {totalRecipientCount}명 - -
- -
- {vendorsWithRecipients.map((vendor, index) => ( -
- {/* 업체 정보 */} -
-
-
- {index + 1} -
-
-
- {vendor.vendorName} - - {vendor.vendorCountry} - -
- {vendor.vendorCode && ( - - {vendor.vendorCode} - - )} -
-
- - 주 수신: {vendor.vendorEmail || "vendor@example.com"} - -
- - {/* 추가 수신처 */} -
-
- - - - - - - -

참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.

-
-
-
-
- - {/* 추가된 이메일 목록 */} -
- {vendor.additionalRecipients.map((email, idx) => ( - - - {email} - - - ))} -
- - {/* 이메일 입력 필드 */} -
- { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - handleAddRecipient(vendor.vendorId, input.value); - input.value = ""; - } - }} - /> - -
-
-
- ))} -
-
- - - - {/* 추가 메시지 (선택사항) */} + {/* 추가 메시지 */}
- +
- - - - 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. - + +
+ + + 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. + +