summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-09 10:32:34 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-09 10:32:34 +0000
commitc62ec046327fd388ebce04571b55910747e69a3b (patch)
tree41ccdc4a8dea99808622f6d5d52014ac59a2d7ab /lib
parentebcec3f296d1d27943caf8a3aed26efef117cdc5 (diff)
(정희성, 최겸, 대표님) formatDate 변경 등
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/service.ts2
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx16
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx52
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx24
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx33
-rw-r--r--lib/bidding/pre-quote/service.ts27
-rw-r--r--lib/bidding/service.ts22
-rw-r--r--lib/bidding/validation.ts12
-rw-r--r--lib/forms/stat.ts82
-rw-r--r--lib/legal-review/status/request-review-dialog.tsx182
-rw-r--r--lib/rfq-last/service.ts310
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx205
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx803
-rw-r--r--lib/site-visit/client-site-visit-wrapper.tsx8
-rw-r--r--lib/site-visit/site-visit-detail-dialog.tsx8
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx8
-rw-r--r--lib/tech-vendors/table/vendor-all-export.ts11
-rw-r--r--lib/vendors/service.ts42
-rw-r--r--lib/vendors/table/vendor-all-export.ts10
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();