diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-09 10:32:34 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-09 10:32:34 +0000 |
| commit | c62ec046327fd388ebce04571b55910747e69a3b (patch) | |
| tree | 41ccdc4a8dea99808622f6d5d52014ac59a2d7ab /lib | |
| parent | ebcec3f296d1d27943caf8a3aed26efef117cdc5 (diff) | |
(정희성, 최겸, 대표님) formatDate 변경 등
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/basic-contract/service.ts | 2 | ||||
| -rw-r--r-- | lib/bidding/list/bidding-detail-dialogs.tsx | 16 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-toolbar-actions.tsx | 52 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 24 | ||||
| -rw-r--r-- | lib/bidding/list/edit-bidding-sheet.tsx | 33 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/service.ts | 27 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 22 | ||||
| -rw-r--r-- | lib/bidding/validation.ts | 12 | ||||
| -rw-r--r-- | lib/forms/stat.ts | 82 | ||||
| -rw-r--r-- | lib/legal-review/status/request-review-dialog.tsx | 182 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 310 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 205 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 803 | ||||
| -rw-r--r-- | lib/site-visit/client-site-visit-wrapper.tsx | 8 | ||||
| -rw-r--r-- | lib/site-visit/site-visit-detail-dialog.tsx | 8 | ||||
| -rw-r--r-- | lib/site-visit/vendor-info-view-dialog.tsx | 8 | ||||
| -rw-r--r-- | lib/tech-vendors/table/vendor-all-export.ts | 11 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 42 | ||||
| -rw-r--r-- | lib/vendors/table/vendor-all-export.ts | 10 |
19 files changed, 1276 insertions, 581 deletions
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 ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-4xl max-h-[90vh]"> @@ -355,7 +343,7 @@ export function SpecificationMeetingDialog({ <div className="text-sm space-y-1"> <div> <CalendarIcon className="inline h-3 w-3 text-muted-foreground mr-2" /> - <span className="font-medium">날짜:</span> {formatDate(data.meetingDate)} + <span className="font-medium">날짜:</span> {formatDate(data.meetingDate, "kr")} {data.meetingTime && <span className="ml-4"><ClockIcon className="inline h-3 w-3 text-muted-foreground mr-1" />{data.meetingTime}</span>} </div> 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 {/* 신규 생성 */} <CreateBiddingDialog/> - {/* 사전견적 요청 */} - {preQuoteEligibleBiddings.length > 0 && ( - <Button - variant="outline" - size="sm" - onClick={handlePreQuoteRequest} - > - <Send className="mr-2 h-4 w-4" /> - 사전견적 요청 ({preQuoteEligibleBiddings.length}) - </Button> - )} - {/* 개찰 (입찰 오픈) */} - {openEligibleBiddings.length > 0 && ( + {/* {openEligibleBiddings.length > 0 && ( <Button variant="outline" size="sm" @@ -114,7 +66,7 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio <Gavel className="mr-2 h-4 w-4" /> 개찰 ({openEligibleBiddings.length}) </Button> - )} + )} */} {/* Export */} <DropdownMenu> 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() { 세금조건 <span className="text-red-500">*</span> </label> <Input - + placeholder="예: 부가세 별도" value={biddingConditions.taxConditions} onChange={(e) => setBiddingConditions(prev => ({ ...prev, @@ -1341,7 +1345,7 @@ export function CreateBiddingDialog() { <div className="space-y-2"> <label className="text-sm font-medium"> - 계약 납품일 + 계약 납품일 <span className="text-red-500">*</span> </label> <Input type="date" @@ -1354,7 +1358,7 @@ export function CreateBiddingDialog() { </div> <div className="space-y-2"> - <label className="text-sm font-medium">선적지</label> + <label className="text-sm font-medium">선적지 <span className="text-red-500">*</span></label> <Input placeholder="예: 부산항, 인천항" value={biddingConditions.shippingPort} @@ -1366,7 +1370,7 @@ export function CreateBiddingDialog() { </div> <div className="space-y-2"> - <label className="text-sm font-medium">도착지</label> + <label className="text-sm font-medium">도착지 <span className="text-red-500">*</span></label> <Input placeholder="예: 현장 직납, 창고 납품" value={biddingConditions.destinationPort} diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx index f3bc1805..71eeed2b 100644 --- a/lib/bidding/list/edit-bidding-sheet.tsx +++ b/lib/bidding/list/edit-bidding-sheet.tsx @@ -49,6 +49,7 @@ import { biddingTypeLabels, awardCountLabels } from "@/db/schema" +import { formatDate } from "@/lib/utils" interface EditBiddingSheetProps { open: boolean @@ -111,28 +112,6 @@ export function EditBiddingSheet({ // 시트가 열릴 때 기존 데이터로 폼 초기화 React.useEffect(() => { 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<VendorFormStatus[]> { +export async function getVendorFormStatus(projectId?: number): Promise<VendorFormStatus[]> { 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<VendorFormStatus[]> { let vendorCompletedFields = 0 const uniqueTags = new Set<string>() - // 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({ </div> <Form {...form}> - <form + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0" > @@ -563,71 +576,76 @@ export function RequestReviewDialog({ /> )} - {/* 제목 - 조건부 렌더링 */} - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel>제목</FormLabel> - {!isTitleOther ? ( - // Select 모드 - <Select - onValueChange={(value) => { - field.onChange(value) - if (value !== "기타") { - // 기타가 아닌 값으로 변경시 해당 값으로 설정 - form.setValue("title", value) - } - }} - value={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="제목 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {getTitleOptions().map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - ) : ( - // Input 모드 (기타 선택시) - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <Badge variant="outline" className="text-xs">기타</Badge> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => { - const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토" - form.setValue("title", defaultTitle) - }} - className="h-6 text-xs" - > - 선택 모드로 돌아가기 - </Button> - </div> - <FormControl> - <Input - placeholder="제목을 직접 입력하세요" - value={field.value === "기타" ? "" : field.value} - onChange={(e) => field.onChange(e.target.value)} - autoFocus - /> - </FormControl> - </div> - )} - <FormMessage /> - </FormItem> - )} - /> - + {/* 제목 필드 수정 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + {!isTitleOtherMode ? ( + // Select 모드 + <Select + onValueChange={(value) => { + if (value === "기타") { + setIsTitleOtherMode(true) + // "기타" 상태는 유지하되 실제 값은 나중에 입력받음 + field.onChange(value) + } else { + field.onChange(value) + setIsTitleOtherMode(false) + } + }} + value={field.value || (reviewDepartment === "준법문의" ? "CP검토" : "GTC검토")} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="제목 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {getTitleOptions().map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + // Input 모드 (기타 선택시) + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs">기타</Badge> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토" + form.setValue("title", defaultTitle) + setIsTitleOtherMode(false) + }} + className="h-6 text-xs" + > + 선택 모드로 돌아가기 + </Button> + </div> + <FormControl> + <Input + placeholder="제목을 직접 입력하세요" + value={field.value === "기타" ? "" : field.value} + onChange={(e) => { + field.onChange(e.target.value) + }} + autoFocus + /> + </FormControl> + </div> + )} + <FormMessage /> + </FormItem> + )} + /> {/* 준법문의 전용 필드들 */} {reviewDepartment === "준법문의" && ( <FormField @@ -913,7 +931,7 @@ export function RequestReviewDialog({ </span> </label> </div> - + {/* 선택된 파일 목록 */} {attachments.length > 0 && ( <div className="space-y-2"> @@ -949,8 +967,8 @@ export function RequestReviewDialog({ > 취소 </Button> - <Button - type="submit" + <Button + type="submit" disabled={isSubmitting} className="bg-blue-600 hover:bg-blue-700" > diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 0c75e72f..ac7104df 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} from "@/db/schema"; +import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts,projects} from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -1570,18 +1570,27 @@ export async function getRfqVendorResponses(rfqId: number) { ) .orderBy(desc(rfqLastVendorResponses.createdAt)); + if (!vendorResponsesData || vendorResponsesData.length === 0) { + return { + success: true, + data: [], + rfq: rfqData[0], + details: details, + }; + } + // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 const vendorResponsesWithCounts = await Promise.all( vendorResponsesData.map(async (response) => { // 견적 아이템 수 조회 const itemCount = await db - .select({ count: sql`COUNT(*)::int` }) + .select({ count: count()}) .from(rfqLastVendorQuotationItems) .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); // 첨부파일 수 조회 const attachmentCount = await db - .select({ count: sql`COUNT(*)::int` }) + .select({ count: count()}) .from(rfqLastVendorAttachments) .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); @@ -1594,7 +1603,8 @@ export async function getRfqVendorResponses(rfqId: number) { ); // 5. 응답 데이터 정리 - const formattedResponses = vendorResponsesWithCounts.map(response => ({ + const formattedResponses = vendorResponsesWithCounts + .filter(response => response && response.id).map(response => ({ id: response.id, rfqsLastId: response.rfqsLastId, rfqLastDetailsId: response.rfqLastDetailsId, @@ -2311,4 +2321,296 @@ export async function getRfqVendors(rfqId: number) { export async function getRfqAttachments(rfqId: number) { const fullInfo = await getRfqFullInfo(rfqId); return fullInfo.attachments; +} + + +// RFQ 발송용 데이터 타입 +export interface RfqSendData { + rfqInfo: { + rfqCode: string; + rfqTitle: string; + rfqType: string; + projectCode?: string; + projectName?: string; + picName?: string; + picCode?: string; + picTeam?: string; + packageNo?: string; + packageName?: string; + designPicName?: string; + designTeam?: string; + materialGroup?: string; + materialGroupDesc?: string; + dueDate: Date; + quotationType?: string; + evaluationApply?: boolean; + contractType?: string; + }; + attachments: Array<{ + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description?: string | null; + fileName?: string | null; + fileSize?: number | null; + uploadedAt?: Date; + }>; +} + +// 선택된 벤더의 이메일 정보 조회 +export interface VendorEmailInfo { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + vendorEmail?: string | null; // vendors 테이블의 기본 이메일 + representativeEmail?: string | null; // 대표자 이메일 + contactEmails: string[]; // 영업/대표 담당자 이메일들 + primaryEmail?: string | null; // 최종 선택된 주 이메일 + currency?: string | null; +} + +/** + * RFQ 발송 다이얼로그용 데이터 조회 + */ +export async function getRfqSendData(rfqId: number): Promise<RfqSendData> { + try { + // 1. RFQ 기본 정보 조회 + const [rfqData] = await db + .select({ + rfq: rfqsLast, + project: projects, + picUser: users, + }) + .from(rfqsLast) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .leftJoin(users, eq(rfqsLast.pic, users.id)) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData) { + throw new Error(`RFQ ID ${rfqId}를 찾을 수 없습니다.`); + } + + const { rfq, project, picUser } = rfqData; + + // 2. PR Items에서 자재그룹 정보 조회 (Major Item) + const [majorItem] = await db + .select({ + materialCategory: rfqPrItems.materialCategory, + materialDescription: rfqPrItems.materialDescription, + }) + .from(rfqPrItems) + .where(and( + eq(rfqPrItems.rfqsLastId, rfqId), + eq(rfqPrItems.majorYn, true) + )) + .limit(1); + + // 3. 첨부파일 정보 조회 + const attachmentsData = await db + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where(eq(rfqLastAttachments.rfqId, rfqId)); + + const attachments = attachmentsData.map(a => ({ + id: a.attachment.id, + attachmentType: a.attachment.attachmentType, + serialNo: a.attachment.serialNo, + currentRevision: a.attachment.currentRevision, + description: a.attachment.description, + fileName: a.revision?.originalFileName ?? null, + fileSize: a.revision?.fileSize ?? null, + uploadedAt: a.attachment.createdAt, + })); + + // 4. RFQ 정보 조합 + const rfqInfo = { + rfqCode: rfq.rfqCode || '', + rfqTitle: rfq.rfqTitle || '', + rfqType: rfq.rfqType || '', + projectCode: project?.code || undefined, + projectName: project?.name || undefined, + picName: rfq.picName || undefined, + picCode: rfq.picCode || undefined, + picTeam: picUser?.deptName || undefined, + packageNo: rfq.packageNo || undefined, + packageName: rfq.packageName || undefined, + designPicName: rfq.EngPicName || undefined, + rfqTitle: rfq.rfqTitle || undefined, + rfqType: rfq.rfqType || undefined, + designTeam: undefined, // 필요시 추가 조회 + materialGroup: majorItem?.materialCategory || undefined, + materialGroupDesc: majorItem?.materialDescription || undefined, + dueDate: rfq.dueDate || new Date(), + quotationType: rfq.rfqType || undefined, + evaluationApply: true, // 기본값 또는 별도 필드 + contractType: undefined, // 필요시 추가 + }; + + return { + rfqInfo, + attachments, + }; + } catch (error) { + console.error("RFQ 발송 데이터 조회 실패:", error); + throw error; + } +} + +interface ContactDetail { + id: number; + name: string; + position?: string | null; + department?: string | null; + email: string; + phone?: string | null; + isPrimary: boolean; +} + +/** + * 벤더 이메일 정보 조회 + */ +export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEmailInfo[]> { + try { + // 1. 벤더 기본 정보 조회 + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + country: vendors.country, + email: vendors.email, + representativeEmail: vendors.representativeEmail, + }) + .from(vendors) + .where(sql`${vendors.id} IN ${vendorIds}`); + + // 2. 각 벤더의 모든 담당자 정보 조회 + const contactsData = await db + .select({ + id: vendorContacts.id, + vendorId: vendorContacts.vendorId, + contactName: vendorContacts.contactName, + contactPosition: vendorContacts.contactPosition, + contactDepartment: vendorContacts.contactDepartment, + contactEmail: vendorContacts.contactEmail, + contactPhone: vendorContacts.contactPhone, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(sql`${vendorContacts.vendorId} IN ${vendorIds}`); + + // 3. 데이터 조합 + const vendorEmailInfos: VendorEmailInfo[] = vendorsData.map(vendor => { + const vendorContacts = contactsData.filter(c => c.vendorId === vendor.id); + + // ContactDetail 형식으로 변환 + const contacts: ContactDetail[] = vendorContacts.map(c => ({ + id: c.id, + name: c.contactName, + position: c.contactPosition, + department: c.contactDepartment, + email: c.contactEmail, + phone: c.contactPhone, + isPrimary: c.isPrimary, + })); + + // 포지션별로 그룹화 + const contactsByPosition: Record<string, ContactDetail[]> = {}; + contacts.forEach(contact => { + const position = contact.position || '기타'; + if (!contactsByPosition[position]) { + contactsByPosition[position] = []; + } + contactsByPosition[position].push(contact); + }); + + // 주 이메일 선택 우선순위: + // 1. isPrimary가 true인 담당자 이메일 + // 2. 대표자 이메일 + // 3. vendors 테이블의 기본 이메일 + // 4. 영업 담당자 이메일 + // 5. 첫번째 담당자 이메일 + const primaryContact = contacts.find(c => c.isPrimary); + const salesContact = contacts.find(c => c.position === '영업'); + const primaryEmail = + primaryContact?.email || + vendor.representativeEmail || + vendor.email || + salesContact?.email || + contacts[0]?.email || + null; + + return { + vendorId: vendor.id, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + vendorCountry: vendor.country, + vendorEmail: vendor.email, + representativeEmail: vendor.representativeEmail, + contacts, + contactsByPosition, + primaryEmail, + currency: 'KRW', // 기본값, 필요시 별도 조회 + }; + }); + + return vendorEmailInfos; + } catch (error) { + console.error("벤더 이메일 정보 조회 실패:", error); + throw error; + } +} + +/** + * 선택된 벤더들의 상세 정보 조회 (RFQ Detail 포함) + */ +export async function getSelectedVendorsWithEmails( + rfqId: number, + vendorIds: number[] +): Promise<Array<VendorEmailInfo & { currency?: string | null }>> { + try { + // 1. 벤더 이메일 정보 조회 + const vendorEmailInfos = await getVendorEmailInfo(vendorIds); + + // 2. RFQ Detail에서 통화 정보 조회 (옵션) + const rfqDetailsData = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + currency: rfqLastDetails.currency, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + sql`${rfqLastDetails.vendorsId} IN ${vendorIds}` + ) + ); + + // 3. 통화 정보 병합 + const result = vendorEmailInfos.map(vendor => { + const detail = rfqDetailsData.find(d => d.vendorId === vendor.vendorId); + return { + ...vendor, + currency: detail?.currency || vendor.currency || 'KRW', + }; + }); + + return result; + } catch (error) { + console.error("선택된 벤더 정보 조회 실패:", error); + throw error; + } }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 7f7afe14..b2ea7588 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -24,7 +24,8 @@ import { Globe, Package, MapPin, - Info + Info, + Loader2 } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -53,6 +54,12 @@ import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; // import { VendorDetailDialog } from "./vendor-detail-dialog"; // import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action"; +import { + getRfqSendData, + getSelectedVendorsWithEmails, + type RfqSendData, + type VendorEmailInfo +} from "../service" // 타입 정의 interface RfqDetail { @@ -100,13 +107,12 @@ interface VendorResponse { attachmentCount?: number; } -// Props 타입 정의 (중복 제거하고 하나로 통합) +// Props 타입 정의 interface RfqVendorTableProps { rfqId: number; rfqCode?: string; rfqDetails: RfqDetail[]; vendorResponses: VendorResponse[]; - // 추가 props rfqInfo?: { rfqTitle: string; rfqType: string; @@ -201,6 +207,17 @@ export function RfqVendorTable({ const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); + const [isLoadingSendData, setIsLoadingSendData] = React.useState(false); + + const [sendDialogData, setSendDialogData] = React.useState<{ + rfqInfo: RfqSendData['rfqInfo'] | null; + attachments: RfqSendData['attachments']; + selectedVendors: VendorEmailInfo[]; + }>({ + rfqInfo: null, + attachments: [], + selectedVendors: [], + }); // 데이터 병합 const mergedData = React.useMemo( @@ -215,9 +232,63 @@ export function RfqVendorTable({ return; } - // 다이얼로그 열기 - setIsSendDialogOpen(true); - }, [selectedRows]); + try { + setIsLoadingSendData(true); + + // 선택된 벤더 ID들 추출 + const selectedVendorIds = selectedRows + .map(row => row.vendorId) + .filter(id => id != null); + + if (selectedVendorIds.length === 0) { + toast.error("유효한 벤더가 선택되지 않았습니다."); + return; + } + + // 병렬로 데이터 가져오기 (에러 처리 포함) + const [rfqSendData, vendorEmailInfos] = await Promise.all([ + getRfqSendData(rfqId), + getSelectedVendorsWithEmails(rfqId, selectedVendorIds) + ]); + + // 데이터 검증 + if (!rfqSendData?.rfqInfo) { + toast.error("RFQ 정보를 불러올 수 없습니다."); + return; + } + + if (!vendorEmailInfos || vendorEmailInfos.length === 0) { + toast.error("선택된 벤더의 이메일 정보를 찾을 수 없습니다."); + return; + } + + // 다이얼로그 데이터 설정 + setSendDialogData({ + rfqInfo: rfqSendData.rfqInfo, + attachments: rfqSendData.attachments || [], + selectedVendors: vendorEmailInfos.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + vendorEmail: v.vendorEmail, + representativeEmail: v.representativeEmail, + contacts: v.contacts || [], + contactsByPosition: v.contactsByPosition || {}, + primaryEmail: v.primaryEmail, + currency: v.currency, + })), + }); + + // 다이얼로그 열기 + setIsSendDialogOpen(true); + } catch (error) { + console.error("RFQ 발송 데이터 로드 실패:", error); + toast.error("데이터를 불러오는데 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoadingSendData(false); + } + }, [selectedRows, rfqId]); // RFQ 발송 핸들러 const handleSendRfq = React.useCallback(async (data: { @@ -248,6 +319,12 @@ export function RfqVendorTable({ // 성공 후 처리 setSelectedRows([]); + setSendDialogData({ + rfqInfo: null, + attachments: [], + selectedVendors: [], + }); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); } catch (error) { console.error("RFQ 발송 실패:", error); @@ -264,30 +341,63 @@ export function RfqVendorTable({ break; case "send": - // RFQ 발송 로직 - toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); + // 개별 RFQ 발송 + try { + setIsLoadingSendData(true); + + const [rfqSendData, vendorEmailInfos] = await Promise.all([ + getRfqSendData(rfqId), + getSelectedVendorsWithEmails(rfqId, [vendor.vendorId]) + ]); + + if (!rfqSendData?.rfqInfo || !vendorEmailInfos || vendorEmailInfos.length === 0) { + toast.error("벤더 정보를 불러올 수 없습니다."); + return; + } + + setSendDialogData({ + rfqInfo: rfqSendData.rfqInfo, + attachments: rfqSendData.attachments || [], + selectedVendors: vendorEmailInfos.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + vendorEmail: v.vendorEmail, + representativeEmail: v.representativeEmail, + contacts: v.contacts || [], + contactsByPosition: v.contactsByPosition || {}, + primaryEmail: v.primaryEmail, + currency: v.currency, + })), + }); + + setIsSendDialogOpen(true); + } catch (error) { + console.error("개별 발송 데이터 로드 실패:", error); + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingSendData(false); + } break; case "edit": - // 수정 로직 toast.info("수정 기능은 준비중입니다."); break; case "delete": - // 삭제 로직 if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); } break; case "response-detail": - // 회신 상세 보기 toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); break; } - }, []); + }, [rfqId]); - // 컬럼 정의 (확장된 버전) + // 컬럼 정의 const columns: ColumnDef<any>[] = React.useMemo(() => [ { id: "select", @@ -535,7 +645,6 @@ export function RfqVendorTable({ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, cell: ({ row }) => { const submittedAt = row.original.response?.submittedAt; - const status = row.original.response?.status; if (!submittedAt) { return <Badge variant="outline">미참여</Badge>; @@ -639,7 +748,10 @@ export function RfqVendorTable({ 상세보기 </DropdownMenuItem> {!hasResponse && ( - <DropdownMenuItem onClick={() => handleAction("send", vendor)}> + <DropdownMenuItem + onClick={() => handleAction("send", vendor)} + disabled={isLoadingSendData} + > <Send className="mr-2 h-4 w-4" /> RFQ 발송 </DropdownMenuItem> @@ -662,7 +774,7 @@ export function RfqVendorTable({ }, size: 60, }, - ], [handleAction, rfqCode]); + ], [handleAction, rfqCode, isLoadingSendData]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ { id: "vendorName", label: "벤더명", type: "text" }, @@ -701,41 +813,6 @@ export function RfqVendorTable({ })); }, [selectedRows]); - // 선택된 벤더 정보 (Send용) - const selectedVendorsForSend = React.useMemo(() => { - return selectedRows.map(row => ({ - vendorId: row.vendorId, - vendorName: row.vendorName, - vendorCode: row.vendorCode, - vendorCountry: row.vendorCountry, - vendorEmail: row.vendorEmail || `vendor${row.vendorId}@example.com`, - currency: row.currency, - })); - }, [selectedRows]); - - // RFQ 정보 준비 (다이얼로그용) - const rfqInfoForDialog = React.useMemo(() => { - // props로 받은 rfqInfo 사용, 없으면 기본값 - return rfqInfo || { - rfqCode: rfqCode || '', - rfqTitle: '테스트 RFQ', - rfqType: '정기견적', - projectCode: 'PN003', - projectName: 'PETRONAS ZLNG nearshore project', - picName: '김*종', - picCode: '86D', - picTeam: '해양구매팀(해양구매1)', - packageNo: 'MM03', - packageName: 'Deck Machinery', - designPicName: '이*진', - designTeam: '전장설계팀 (전장기기시스템)', - materialGroup: 'BE2101', - materialGroupDesc: 'Combined Windlass & Mooring Wi', - dueDate: new Date('2025-07-05'), - evaluationApply: true, - }; - }, [rfqInfo, rfqCode]); - // 추가 액션 버튼들 const additionalActions = React.useMemo(() => ( <div className="flex items-center gap-2"> @@ -743,6 +820,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)} + disabled={isLoadingSendData} > <Plus className="h-4 w-4 mr-2" /> 벤더 추가 @@ -753,6 +831,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={() => setIsBatchUpdateOpen(true)} + disabled={isLoadingSendData} > <Settings2 className="h-4 w-4 mr-2" /> 정보 일괄 입력 ({selectedRows.length}) @@ -761,9 +840,19 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={handleBulkSend} + disabled={isLoadingSendData || selectedRows.length === 0} > - <Send className="h-4 w-4 mr-2" /> - 선택 발송 ({selectedRows.length}) + {isLoadingSendData ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 데이터 준비중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + 선택 발송 ({selectedRows.length}) + </> + )} </Button> </> )} @@ -777,13 +866,13 @@ export function RfqVendorTable({ toast.success("데이터를 새로고침했습니다."); }, 1000); }} - disabled={isRefreshing} + disabled={isRefreshing || isLoadingSendData} > <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> 새로고침 </Button> </div> - ), [selectedRows, isRefreshing, handleBulkSend]); + ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]); return ( <> @@ -828,9 +917,9 @@ export function RfqVendorTable({ <SendRfqDialog open={isSendDialogOpen} onOpenChange={setIsSendDialogOpen} - selectedVendors={selectedVendorsForSend} - rfqInfo={rfqInfoForDialog} - attachments={attachments || []} + selectedVendors={sendDialogData.selectedVendors} + rfqInfo={sendDialogData.rfqInfo} + attachments={sendDialogData.attachments || []} onSend={handleSendRfq} /> 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<string, ContactDetail[]>; + 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<void>; } +// 이메일 유효성 검사 함수 +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 <User className="h-3 w-3" />; + + const lowerPosition = position.toLowerCase(); + if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { + return <Building2 className="h-3 w-3" />; + } + if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { + return <Briefcase className="h-3 w-3" />; + } + if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { + return <Package className="h-3 w-3" />; + } + return <User className="h-3 w-3" />; +}; + +// 포지션별 색상 +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<VendorWithRecipients[]>([]); const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); const [additionalMessage, setAdditionalMessage] = React.useState(""); + const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]); + const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({}); + const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); // 초기화 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 ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Send className="h-5 w-5" /> @@ -249,7 +434,8 @@ export function SendRfqDialog({ </DialogDescription> </DialogHeader> - <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]"> + {/* ScrollArea 대신 div 사용 */} + <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}> <div className="space-y-6 pr-4"> {/* RFQ 정보 섹션 */} <div className="space-y-4"> @@ -257,88 +443,367 @@ export function SendRfqDialog({ <Info className="h-4 w-4" /> RFQ 정보 </div> - + <div className="bg-muted/50 rounded-lg p-4 space-y-3"> - {/* 프로젝트 정보 */} <div className="grid grid-cols-2 gap-4 text-sm"> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">프로젝트:</span> - <span className="font-medium"> - {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"}) - </span> + <span className="text-muted-foreground min-w-[80px]">RFQ 코드:</span> + <span className="font-medium">{rfqInfo?.rfqCode}</span> </div> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">견적번호:</span> - <span className="font-medium font-mono">{rfqInfo.rfqCode}</span> - </div> - </div> - - {/* 담당자 정보 */} - <div className="grid grid-cols-2 gap-4 text-sm"> - <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">구매담당:</span> - <span> - {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"} + <span className="text-muted-foreground min-w-[80px]">견적마감일:</span> + <span className="font-medium text-red-600"> + {formatDate(rfqInfo?.dueDate, "KR")} </span> </div> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">설계담당:</span> - <span> - {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"} + <span className="text-muted-foreground min-w-[80px]">프로젝트:</span> + <span className="font-medium"> + {rfqInfo?.projectCode} ({rfqInfo?.projectName}) </span> </div> - </div> - - {/* PKG 및 자재 정보 */} - <div className="grid grid-cols-2 gap-4 text-sm"> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">PKG 정보:</span> - <span> - {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"}) + <span className="text-muted-foreground min-w-[80px]"> 자재그룹:</span> + <span className="font-medium"> + {rfqInfo?.packageNo} - {rfqInfo?.packageName} </span> </div> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">자재그룹:</span> - <span> - {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"}) + <span className="text-muted-foreground min-w-[80px]">구매담당자:</span> + <span className="font-medium"> + {rfqInfo?.picName} ({rfqInfo?.picCode}) {rfqInfo?.picTeam} </span> </div> - </div> - - {/* 견적 정보 */} - <div className="grid grid-cols-2 gap-4 text-sm"> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">견적마감일:</span> - <span className="font-medium text-red-600"> - {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })} + <span className="text-muted-foreground min-w-[80px]"> 설계담당자:</span> + <span className="font-medium"> + {rfqInfo?.designPicName} </span> </div> - <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">평가적용:</span> - <Badge variant={rfqInfo.evaluationApply ? "default" : "outline"}> - {rfqInfo.evaluationApply ? "Y" : "N"} - </Badge> - </div> + {rfqInfo?.rfqCode.startsWith("F") && + <> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">견적명:</span> + <span className="font-medium"> + {rfqInfo?.rfqTitle} + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]"> 견적종류:</span> + <span className="font-medium"> + {rfqInfo?.rfqType} + </span> + </div> + </> + } </div> + </div> + </div> - {/* 견적명 */} - <div className="flex items-start gap-2 text-sm"> - <span className="text-muted-foreground min-w-[80px]">견적명:</span> - <span className="font-medium">{rfqInfo.rfqTitle}</span> + <Separator /> + + {/* 수신 업체 섹션 - 테이블 버전 with 인라인 추가 폼 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Building2 className="h-4 w-4" /> + 수신 업체 ({selectedVendors.length}) </div> + <Badge variant="outline" className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + 총 {totalRecipientCount}명 + </Badge> + </div> - {/* 계약구분 (일반견적일 때만) */} - {rfqInfo.rfqType === "일반견적" && ( - <div className="flex items-start gap-2 text-sm"> - <span className="text-muted-foreground min-w-[80px]">계약구분:</span> - <span>{rfqInfo.contractType || "-"}</span> - </div> - )} + <div className="border rounded-lg overflow-hidden"> + <table className="w-full"> + <thead className="bg-muted/50 border-b"> + <tr> + <th className="text-left p-2 text-xs font-medium">No.</th> + <th className="text-left p-2 text-xs font-medium">업체명</th> + <th className="text-left p-2 text-xs font-medium">주 수신자</th> + <th className="text-left p-2 text-xs font-medium">CC</th> + <th className="text-left p-2 text-xs font-medium">작업</th> + </tr> + </thead> + <tbody> + {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 ( + <React.Fragment key={vendor.vendorId}> + <tr className="border-b hover:bg-muted/20"> + <td className="p-2"> + <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> + {index + 1} + </div> + </td> + <td className="p-2"> + <div className="space-y-1"> + <div className="font-medium text-sm">{vendor.vendorName}</div> + <div className="flex items-center gap-1"> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCountry} + </Badge> + <span className="text-xs text-muted-foreground"> + {vendor.vendorCode} + </span> + </div> + </div> + </td> + <td className="p-2"> + <Select + value={vendor.selectedMainEmail} + onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)} + > + <SelectTrigger className="h-7 text-xs w-[200px]"> + <SelectValue placeholder="선택하세요"> + {selectedMainEmailInfo && ( + <div className="flex items-center gap-1"> + {selectedMainEmailInfo.type === 'representative' && <Building2 className="h-3 w-3" />} + {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} + <span className="truncate">{selectedMainEmailInfo.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + {allEmails.map((email) => ( + <SelectItem key={email.value} value={email.value} className="text-xs"> + <div className="flex items-center gap-1"> + {email.type === 'representative' && <Building2 className="h-3 w-3" />} + {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} + <span>{email.label}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + {!vendor.selectedMainEmail && ( + <span className="text-xs text-red-500">필수</span> + )} + </td> + <td className="p-2"> + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" className="h-7 text-xs"> + {vendor.additionalEmails.length > 0 + ? `${vendor.additionalEmails.length}명` + : "선택" + } + <ChevronDown className="ml-1 h-3 w-3" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-48 p-2"> + <div className="max-h-48 overflow-y-auto space-y-1"> + {ccEmails.map((email) => ( + <div key={email.value} className="flex items-center space-x-1 p-1"> + <Checkbox + checked={vendor.additionalEmails.includes(email.value)} + onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)} + className="h-3 w-3" + /> + <label className="text-xs cursor-pointer flex-1 truncate"> + {email.label} + </label> + </div> + ))} + </div> + </PopoverContent> + </Popover> + </td> + <td className="p-2"> + <div className="flex items-center gap-1"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={isFormOpen ? "default" : "ghost"} + size="sm" + className="h-6 w-6 p-0" + onClick={() => { + setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: !prev[vendor.vendorId] + })); + }} + > + {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />} + </Button> + </TooltipTrigger> + <TooltipContent>{isFormOpen ? "닫기" : "수신자 추가"}</TooltipContent> + </Tooltip> + </TooltipProvider> + {vendor.customEmails.length > 0 && ( + <Badge variant="success" className="text-xs"> + +{vendor.customEmails.length} + </Badge> + )} + </div> + </td> + </tr> + + {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} + {isFormOpen && ( + <tr className="bg-muted/10 border-b"> + <td colSpan={5} className="p-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2 text-sm font-medium"> + <UserPlus className="h-4 w-4" /> + 수신자 추가 - {vendor.vendorName} + </div> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: false + }))} + > + <X className="h-3 w-3" /> + </Button> + </div> + + {/* 한 줄에 모든 요소 배치 - 명확한 너비 지정 */} + <div className="flex gap-2 items-end"> + <div className="w-[150px]"> + <Label className="text-xs mb-1 block">이름 (선택)</Label> + <Input + placeholder="홍길동" + className="h-8 text-sm" + value={customEmailInputs[vendor.vendorId]?.name || ''} + onChange={(e) => setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + name: e.target.value + } + }))} + /> + </div> + <div className="flex-1"> + <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label> + <Input + type="email" + placeholder="example@company.com" + className="h-8 text-sm" + value={customEmailInputs[vendor.vendorId]?.email || ''} + onChange={(e) => setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + email: e.target.value + } + }))} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomEmail(vendor.vendorId); + } + }} + /> + </div> + <Button + size="sm" + className="h-8 px-4" + onClick={() => addCustomEmail(vendor.vendorId)} + disabled={!customEmailInputs[vendor.vendorId]?.email} + > + <Plus className="h-3 w-3 mr-1" /> + 추가 + </Button> + <Button + variant="outline" + size="sm" + className="h-8 px-4" + onClick={() => { + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { email: '', name: '' } + })); + setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: false + })); + }} + > + 취소 + </Button> + </div> + + {/* 추가된 커스텀 이메일 목록 */} + {vendor.customEmails.length > 0 && ( + <div className="mt-3 pt-3 border-t"> + <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div> + <div className="grid grid-cols-2 xl:grid-cols-3 gap-2"> + {vendor.customEmails.map((custom) => ( + <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2"> + <div className="flex items-center gap-2 min-w-0"> + <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" /> + <div className="min-w-0"> + <div className="text-sm font-medium truncate">{custom.name}</div> + <div className="text-xs text-muted-foreground truncate">{custom.email}</div> + </div> + </div> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0 flex-shrink-0" + onClick={() => removeCustomEmail(vendor.vendorId, custom.id)} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + </td> + </tr> + )} + </React.Fragment> + ); + })} + </tbody> + </table> </div> </div> - <Separator /> + <Separator /> {/* 첨부파일 섹션 */} <div className="space-y-4"> <div className="flex items-center justify-between"> @@ -361,45 +826,30 @@ export function SendRfqDialog({ </Button> </div> - <div className="border rounded-lg divide-y"> + <div className="border rounded-lg divide-y max-h-40 overflow-y-auto"> {attachments.length > 0 ? ( attachments.map((attachment) => ( <div key={attachment.id} - className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" + className="flex items-center justify-between p-2 hover:bg-muted/50 transition-colors" > - <div className="flex items-center gap-3"> + <div className="flex items-center gap-2"> <Checkbox checked={selectedAttachments.includes(attachment.id)} onCheckedChange={() => toggleAttachment(attachment.id)} /> {getAttachmentIcon(attachment.attachmentType)} - <div> - <div className="flex items-center gap-2"> - <span className="text-sm font-medium"> - {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} - </span> - <Badge variant="outline" className="text-xs"> - {attachment.currentRevision} - </Badge> - </div> - {attachment.description && ( - <p className="text-xs text-muted-foreground mt-0.5"> - {attachment.description} - </p> - )} - </div> - </div> - <div className="flex items-center gap-2"> - <span className="text-xs text-muted-foreground"> - {formatFileSize(attachment.fileSize)} + <span className="text-sm"> + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} </span> + <Badge variant="outline" className="text-xs"> + {attachment.currentRevision} + </Badge> </div> </div> )) ) : ( - <div className="p-8 text-center text-muted-foreground"> - <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <div className="p-4 text-center text-muted-foreground"> <p className="text-sm">첨부파일이 없습니다.</p> </div> )} @@ -408,124 +858,7 @@ export function SendRfqDialog({ <Separator /> - {/* 수신 업체 섹션 */} - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2 text-sm font-medium"> - <Building2 className="h-4 w-4" /> - 수신 업체 ({selectedVendors.length}) - </div> - <Badge variant="outline" className="flex items-center gap-1"> - <Users className="h-3 w-3" /> - 총 {totalRecipientCount}명 - </Badge> - </div> - - <div className="space-y-3"> - {vendorsWithRecipients.map((vendor, index) => ( - <div - key={vendor.vendorId} - className="border rounded-lg p-4 space-y-3" - > - {/* 업체 정보 */} - <div className="flex items-start justify-between"> - <div className="flex items-center gap-3"> - <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-medium"> - {index + 1} - </div> - <div> - <div className="flex items-center gap-2"> - <span className="font-medium">{vendor.vendorName}</span> - <Badge variant="outline" className="text-xs"> - {vendor.vendorCountry} - </Badge> - </div> - {vendor.vendorCode && ( - <span className="text-xs text-muted-foreground"> - {vendor.vendorCode} - </span> - )} - </div> - </div> - <Badge variant="secondary"> - 주 수신: {vendor.vendorEmail || "vendor@example.com"} - </Badge> - </div> - - {/* 추가 수신처 */} - <div className="pl-11 space-y-2"> - <div className="flex items-center gap-2"> - <Label className="text-xs text-muted-foreground">추가 수신처:</Label> - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <AlertCircle className="h-3 w-3 text-muted-foreground" /> - </TooltipTrigger> - <TooltipContent> - <p>참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - - {/* 추가된 이메일 목록 */} - <div className="flex flex-wrap gap-2"> - {vendor.additionalRecipients.map((email, idx) => ( - <Badge - key={idx} - variant="outline" - className="flex items-center gap-1 pr-1" - > - <Mail className="h-3 w-3" /> - {email} - <Button - variant="ghost" - size="sm" - className="h-4 w-4 p-0 hover:bg-transparent" - onClick={() => handleRemoveRecipient(vendor.vendorId, idx)} - > - <X className="h-3 w-3" /> - </Button> - </Badge> - ))} - </div> - - {/* 이메일 입력 필드 */} - <div className="flex gap-2"> - <Input - type="email" - placeholder="추가 수신자 이메일 입력" - className="h-8 text-sm" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - handleAddRecipient(vendor.vendorId, input.value); - input.value = ""; - } - }} - /> - <Button - variant="outline" - size="sm" - onClick={(e) => { - const input = (e.currentTarget.previousElementSibling as HTMLInputElement); - handleAddRecipient(vendor.vendorId, input.value); - input.value = ""; - }} - > - <Plus className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - ))} - </div> - </div> - - <Separator /> - - {/* 추가 메시지 (선택사항) */} + {/* 추가 메시지 */} <div className="space-y-2"> <Label htmlFor="message" className="text-sm font-medium"> 추가 메시지 (선택사항) @@ -539,14 +872,16 @@ export function SendRfqDialog({ /> </div> </div> - </ScrollArea> + </div> <DialogFooter className="flex-shrink-0"> - <Alert className="mr-auto max-w-md"> - <AlertCircle className="h-4 w-4" /> - <AlertDescription className="text-xs"> - 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. - </AlertDescription> + <Alert className="max-w-md"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 flex-shrink-0" /> + <AlertDescription className="text-xs"> + 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. + </AlertDescription> + </div> </Alert> <Button variant="outline" diff --git a/lib/site-visit/client-site-visit-wrapper.tsx b/lib/site-visit/client-site-visit-wrapper.tsx index c93060b3..a23a78d7 100644 --- a/lib/site-visit/client-site-visit-wrapper.tsx +++ b/lib/site-visit/client-site-visit-wrapper.tsx @@ -29,6 +29,7 @@ import type { VendorInfoFormValues } from "./vendor-info-sheet" import { submitVendorInfoAction } from "./service"
import { SiteVisitDetailDialog } from "./site-visit-detail-dialog"
import { ShiAttendeesDialog } from "./shi-attendees-dialog"
+import { formatDate } from "../utils"
// SHI 참석자 총 인원수 계산 함수
function getTotalShiAttendees(shiAttendees: Record<string, unknown> | null): number {
if (!shiAttendees) return 0
@@ -178,11 +179,6 @@ export function ClientSiteVisitWrapper({ }
}
- const formatDate = (date: Date | null) => {
- if (!date) return "-"
- return format(date, "yyyy.MM.dd", { locale: ko })
- }
-
const formatDateRange = (startDate: Date | null, endDate: Date | null) => {
if (!startDate) return "-"
if (!endDate || startDate.getTime() === endDate.getTime()) {
@@ -362,7 +358,7 @@ export function ClientSiteVisitWrapper({ {formatDateRange(request.requestedStartDate, request.requestedEndDate)}
</TableCell>
<TableCell>
- {formatDate(request.actualAt)}
+ {formatDate(request.actualAt, "kr")}
</TableCell>
<TableCell>
{request.result ? (
diff --git a/lib/site-visit/site-visit-detail-dialog.tsx b/lib/site-visit/site-visit-detail-dialog.tsx index 51aeb40a..7788454a 100644 --- a/lib/site-visit/site-visit-detail-dialog.tsx +++ b/lib/site-visit/site-visit-detail-dialog.tsx @@ -15,6 +15,7 @@ import { DialogTitle,
} from "@/components/ui/dialog"
import { Separator } from "@/components/ui/separator"
+import { formatDate } from "../utils"
interface SiteVisitRequest {
id: number
@@ -101,11 +102,6 @@ export function SiteVisitDetailDialog({ selectedRequest,
}: SiteVisitDetailDialogProps) {
- const formatDate = (date: Date | null) => {
- if (!date) return "-"
- return format(date, "yyyy.MM.dd", { locale: ko })
- }
-
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -183,7 +179,7 @@ export function SiteVisitDetailDialog({ <div className="mt-6">
<h4 className="font-semibold mb-2">제출 정보</h4>
<div className="space-y-2 text-sm">
- <div><span className="font-medium">제출일:</span> {formatDate(selectedRequest.vendorInfo.submittedAt)}</div>
+ <div><span className="font-medium">제출일:</span> {formatDate(selectedRequest.vendorInfo.submittedAt, "kr")}</div>
<div><span className="font-medium">첨부파일:</span> {selectedRequest.vendorInfo.hasAttachments ? "있음" : "없음"}</div>
</div>
</div>
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx index b6e8111d..431069b3 100644 --- a/lib/site-visit/vendor-info-view-dialog.tsx +++ b/lib/site-visit/vendor-info-view-dialog.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { format } from "date-fns"
import { ko } from "date-fns/locale"
import { Building2, User, Phone, Mail, FileText, Calendar } from "lucide-react"
+import { formatDate } from "../utils"
import {
Dialog,
@@ -92,11 +93,6 @@ export function VendorInfoViewDialog({ }
}
- const formatDate = (date: Date | null) => {
- if (!date) return "-"
- return format(date, "yyyy.MM.dd", { locale: ko })
- }
-
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -257,7 +253,7 @@ export function VendorInfoViewDialog({ <div className="grid grid-cols-2 gap-4">
<div>
<div className="space-y-2 text-sm">
- <div><span className="font-medium">제출일:</span> {formatDate(data.submittedAt)}</div>
+ <div><span className="font-medium">제출일:</span> {formatDate(data.submittedAt, "kr")}</div>
<div><span className="font-medium">첨부파일:</span> {data.hasAttachments ? "있음" : "없음"}</div>
</div>
</div>
diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts index f1492324..8066e0be 100644 --- a/lib/tech-vendors/table/vendor-all-export.ts +++ b/lib/tech-vendors/table/vendor-all-export.ts @@ -2,6 +2,7 @@ import ExcelJS from "exceljs"
import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors"
import { exportTechVendorDetails } from "../service";
+import { formatDate } from "../utils";
/**
* 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
@@ -235,16 +236,6 @@ function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { });
}
-// 날짜 포맷 함수
-function formatDate(date: Date | string): string {
- if (!date) return "";
- if (typeof date === 'string') {
- date = new Date(date);
- }
- return date.toISOString().split('T')[0];
-}
-
-
// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
function getStatusText(status: string): string {
const statusMap: Record<string, string> = {
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index a22a1551..22d58ae1 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -6,8 +6,6 @@ import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, ven import logger from '@/lib/logger'; import * as z from "zod" import crypto from 'crypto'; -import fs from 'fs/promises'; -import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { saveDRMFile } from "@/lib/file-stroage"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; @@ -58,7 +56,7 @@ import { mfaTokens, roles, userRoles, users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { contractsDetailView, projects, vendorPQSubmissions, vendorsLogs } from "@/db/schema"; -import { deleteFile, saveFile } from "../file-stroage"; +import { deleteFile, saveFile, saveBuffer } from "../file-stroage"; import { basicContractTemplates } from "@/db/schema/basicContractDocumnet"; import { basicContract } from "@/db/schema/basicContractDocumnet"; import { headers } from 'next/headers'; @@ -2868,14 +2866,8 @@ export async function requestBasicContractInfo({ try { const fileId = uuidv4(); const fileName = `${fileId}.pdf`; - const relativePath = `/basicContract/${fileName}`; - const publicDir = path.join(process.cwd(), "public", "basicContract"); - const absolutePath = path.join(publicDir, fileName); - // 디렉토리 생성 - await fs.mkdir(publicDir, { recursive: true }); - - // PDF 파일 저장 (다양한 타입을 Buffer로 변환) + // PDF 버퍼를 Buffer로 변환 (saveBuffer 함수가 Buffer 메서드를 사용하므로) let bufferData: Buffer; if (Buffer.isBuffer(pdfBuffer)) { bufferData = pdfBuffer; @@ -2886,12 +2878,29 @@ export async function requestBasicContractInfo({ } else { bufferData = Buffer.from(pdfBuffer as any); } - await fs.writeFile(absolutePath, bufferData); + + // saveBuffer 함수를 사용해서 파일 저장 (환경별 경로 처리는 자동으로 됨) + const saveResult = await saveBuffer({ + buffer: bufferData, + fileName: fileName, + directory: "basicContract", + originalName: `${template.templateName || 'contract'}_${fileId}.pdf`, + userId: requestedBy.toString() + }); + + if (!saveResult.success) { + throw new Error(saveResult.error || '파일 저장에 실패했습니다.'); + } - finalFileName = fileName; - finalFilePath = relativePath; + finalFileName = saveResult.fileName || fileName; + // publicPath에서 /api/files/ 부분을 제거하여 데이터베이스에 저장 + // 이렇게 하면 /api/files/ API를 통해 접근할 때 올바른 경로가 됨 + finalFilePath = saveResult.publicPath + ? saveResult.publicPath.replace('/api/files/', '') + : `/basicContract/${fileName}`; - console.log(`✅ PDF 파일 저장 완료: ${absolutePath}`); + console.log(`✅ PDF 파일 저장 완료: ${saveResult.filePath}`); + console.log(`📄 접근 경로: ${finalFilePath}`); } catch (pdfSaveError) { console.error('PDF 파일 저장 오류:', pdfSaveError); return { error: `PDF 파일 저장 실패: ${pdfSaveError instanceof Error ? pdfSaveError.message : '알 수 없는 오류'}` }; @@ -2917,6 +2926,10 @@ export async function requestBasicContractInfo({ try { // 3-1. basic_contract 테이블에 레코드 추가 + // 마감일은 생성일로부터 10일 후로 설정 + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 10); + const [newContract] = await db .insert(basicContract) .values({ @@ -2926,6 +2939,7 @@ export async function requestBasicContractInfo({ status: "PENDING", fileName: finalFileName, // PDF 변환된 파일 이름 사용 filePath: finalFilePath, // PDF 변환된 파일 경로 사용 + deadline: deadline.toISOString().split('T')[0], // YYYY-MM-DD 형식으로 변환 }) .returning(); diff --git a/lib/vendors/table/vendor-all-export.ts b/lib/vendors/table/vendor-all-export.ts index 31ab2b52..1784ae9b 100644 --- a/lib/vendors/table/vendor-all-export.ts +++ b/lib/vendors/table/vendor-all-export.ts @@ -2,6 +2,7 @@ import ExcelJS from "exceljs" import { VendorWithType } from "@/db/schema/vendors" import { exportVendorDetails } from "../service"; +import { formatDate } from "@/lib/utils"; // 연락처 인터페이스 정의 interface VendorContact { @@ -451,15 +452,6 @@ function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { }); } -// 날짜 포맷 함수 -function formatDate(date: Date | string): string { - if (!date) return ""; - if (typeof date === 'string') { - date = new Date(date); - } - return date.toISOString().split('T')[0]; -} - // 금액 포맷 함수 function formatAmount(amount: number): string { return amount.toLocaleString(); |
