diff options
Diffstat (limited to 'lib/rfq-last/vendor-response')
5 files changed, 227 insertions, 291 deletions
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx index c146e42b..34259d37 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState,useEffect } from "react" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" @@ -163,18 +163,74 @@ export default function VendorResponseEditor({ const methods = useForm<VendorResponseFormData>({ resolver: zodResolver(vendorResponseSchema), - defaultValues + defaultValues, + mode: 'onChange' // 추가: 실시간 validation }) + const { formState: { errors, isValid } } = methods + + useEffect(() => { + if (Object.keys(errors).length > 0) { + console.log('Validation errors:', errors) + } + }, [errors]) + + + + const handleFormSubmit = (isSubmit: boolean = false) => { + // 임시저장일 경우 validation 없이 바로 저장 + if (!isSubmit) { + const formData = methods.getValues() + onSubmit(formData, false) + return + } + + // 제출일 경우에만 validation 수행 + methods.handleSubmit( + (data) => onSubmit(data, isSubmit), + (errors) => { + console.error('Form validation errors:', errors) + + // 첫 번째 에러 필드로 포커스 이동 + const firstErrorField = Object.keys(errors)[0] + if (firstErrorField) { + // 어느 탭에 에러가 있는지 확인 + if (firstErrorField.startsWith('vendor') && + !firstErrorField.startsWith('vendorFirst') && + !firstErrorField.startsWith('vendorSparepart')) { + setActiveTab('terms') + } else if (firstErrorField === 'quotationItems') { + setActiveTab('items') + } + + // 구체적인 에러 메시지 표시 + if (errors.quotationItems) { + toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.") + } else { + toast.error("입력 정보를 확인해주세요.") + } + } + } + )() + } + const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => { + console.log('onSubmit called with:', { data, isSubmit }) // 디버깅용 + setLoading(true) setUploadProgress(0) try { const formData = new FormData() + const fileMetadata = attachments.map((file: any) => ({ + attachmentType: file.attachmentType || "기타", + description: file.description || "" + })) + + // 기본 데이터 추가 - formData.append('data', JSON.stringify({ + const submitData = { ...data, rfqsLastId: rfq.id, rfqLastDetailsId: rfqDetail.id, @@ -183,69 +239,76 @@ export default function VendorResponseEditor({ submittedAt: isSubmit ? new Date().toISOString() : null, submittedBy: isSubmit ? userId : null, totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0), - updatedBy: userId - })) + updatedBy: userId, + fileMetadata + } + + console.log('Submitting data:', submitData) // 디버깅용 + + formData.append('data', JSON.stringify(submitData)) // 첨부파일 추가 attachments.forEach((file, index) => { formData.append(`attachments`, file) }) - // const response = await fetch(`/api/partners/rfq-last/${rfq.id}/response`, { - // method: existingResponse ? 'PUT' : 'POST', - // body: formData - // }) - - // if (!response.ok) { - // throw new Error('응답 저장에 실패했습니다.') - // } - - // XMLHttpRequest 사용하여 업로드 진행률 추적 - const xhr = new XMLHttpRequest() - - // Promise로 감싸서 async/await 사용 가능하게 - const uploadPromise = new Promise((resolve, reject) => { - // 업로드 진행률 이벤트 - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - const percentComplete = Math.round((event.loaded / event.total) * 100) - setUploadProgress(percentComplete) - } - }) - - // 완료 이벤트 - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - setUploadProgress(100) - resolve(JSON.parse(xhr.responseText)) - } else { - reject(new Error('응답 저장에 실패했습니다.')) + // XMLHttpRequest 사용하여 업로드 진행률 추적 + const xhr = new XMLHttpRequest() + + const uploadPromise = new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percentComplete = Math.round((event.loaded / event.total) * 100) + setUploadProgress(percentComplete) + } + }) + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploadProgress(100) + try { + const response = JSON.parse(xhr.responseText) + resolve(response) + } catch (e) { + console.error('Response parsing error:', e) + reject(new Error('응답 파싱 실패')) } - }) - - // 에러 이벤트 - xhr.addEventListener('error', () => { - reject(new Error('네트워크 오류가 발생했습니다.')) - }) - - // 요청 전송 - xhr.open(existingResponse ? 'PUT' : 'POST', `/api/partners/rfq-last/${rfq.id}/response`) - xhr.send(formData) + } else { + console.error('Server error:', xhr.status, xhr.responseText) + reject(new Error(`서버 오류: ${xhr.status}`)) + } + }) + + xhr.addEventListener('error', () => { + console.error('Network error') + reject(new Error('네트워크 오류가 발생했습니다.')) }) + + // 요청 전송 + const method = existingResponse ? 'PUT' : 'POST' + const url = `/api/partners/rfq-last/${rfq.id}/response` + + console.log(`Sending ${method} request to ${url}`) // 디버깅용 - await uploadPromise + xhr.open(method, url) + xhr.send(formData) + }) + + await uploadPromise toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.") router.push('/partners/rfq-last') router.refresh() } catch (error) { - console.error('Error:', error) - toast.error("오류가 발생했습니다.") + console.error('Submit error:', error) // 더 상세한 에러 로깅 + toast.error(error instanceof Error ? error.message : "오류가 발생했습니다.") } finally { setLoading(false) + setUploadProgress(0) } } + const totalAmount = methods.watch('quotationItems')?.reduce( (sum, item) => sum + (item.totalPrice || 0), 0 ) || 0 @@ -256,7 +319,10 @@ export default function VendorResponseEditor({ return ( <FormProvider {...methods}> - <form onSubmit={methods.handleSubmit((data) => onSubmit(data, false))}> + <form onSubmit={(e) => { + e.preventDefault() // 기본 submit 동작 방지 + handleFormSubmit(false) + }}> <div className="space-y-6"> {/* 헤더 정보 */} <RfqInfoHeader rfq={rfq} rfqDetail={rfqDetail} vendor={vendor} /> @@ -293,92 +359,92 @@ export default function VendorResponseEditor({ </CardDescription> </CardHeader> <CardContent> - {basicContracts.length > 0 ? ( - <div className="space-y-4"> - {/* 계약 목록 - 그리드 레이아웃 */} - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> - {basicContracts.map((contract) => ( - <div - key={contract.id} - className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors" - > - <div className="flex items-start gap-2"> - <div className="p-1.5 bg-primary/10 rounded"> - <Shield className="h-3.5 w-3.5 text-primary" /> - </div> - <div className="flex-1 min-w-0"> - <h4 className="font-medium text-sm truncate" title={contract.templateName}> - {contract.templateName} - </h4> - <Badge - variant={contract.signedAt ? "success" : "warning"} - className="text-xs mt-1.5" - > - {contract.signedAt ? ( - <> - <CheckCircle className="h-3 w-3 mr-1" /> - 서명완료 - </> + {basicContracts.length > 0 ? ( + <div className="space-y-4"> + {/* 계약 목록 - 그리드 레이아웃 */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> + {basicContracts.map((contract) => ( + <div + key={contract.id} + className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors" + > + <div className="flex items-start gap-2"> + <div className="p-1.5 bg-primary/10 rounded"> + <Shield className="h-3.5 w-3.5 text-primary" /> + </div> + <div className="flex-1 min-w-0"> + <h4 className="font-medium text-sm truncate" title={contract.templateName}> + {contract.templateName} + </h4> + <Badge + variant={contract.signedAt ? "success" : "warning"} + className="text-xs mt-1.5" + > + {contract.signedAt ? ( + <> + <CheckCircle className="h-3 w-3 mr-1" /> + 서명완료 + </> + ) : ( + <> + <Clock className="h-3 w-3 mr-1" /> + 서명대기 + </> + )} + </Badge> + <p className="text-xs text-muted-foreground mt-1"> + {contract.signedAt + ? `${formatDate(new Date(contract.signedAt))}` + : contract.deadline + ? `~${formatDate(new Date(contract.deadline))}` + : '마감일 없음'} + </p> + </div> + </div> + </div> + ))} + </div> + + {/* 서명 상태 요약 및 액션 */} + {basicContracts.some(contract => !contract.signedAt) ? ( + <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-amber-600" /> + <div> + <p className="text-sm font-medium"> + 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개 + </p> + <p className="text-xs text-muted-foreground"> + 견적서 제출 전 모든 계약서 서명 필요 + </p> + </div> + </div> + <Button + type="button" + size="sm" + onClick={() => router.push(`/partners/basic-contract`)} + > + 서명하기 + </Button> + </div> + ) : ( + <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <AlertDescription className="text-sm"> + 모든 기본계약 서명 완료 + </AlertDescription> + </Alert> + )} + </div> ) : ( - <> - <Clock className="h-3 w-3 mr-1" /> - 서명대기 - </> + <div className="text-center py-8"> + <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> + <p className="text-muted-foreground"> + 이 RFQ에 요청된 기본계약이 없습니다 + </p> + </div> )} - </Badge> - <p className="text-xs text-muted-foreground mt-1"> - {contract.signedAt - ? `${formatDate(new Date(contract.signedAt))}` - : contract.deadline - ? `~${formatDate(new Date(contract.deadline))}` - : '마감일 없음'} - </p> - </div> - </div> - </div> - ))} - </div> - - {/* 서명 상태 요약 및 액션 */} - {basicContracts.some(contract => !contract.signedAt) ? ( - <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg"> - <div className="flex items-center gap-2"> - <AlertCircle className="h-4 w-4 text-amber-600" /> - <div> - <p className="text-sm font-medium"> - 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개 - </p> - <p className="text-xs text-muted-foreground"> - 견적서 제출 전 모든 계약서 서명 필요 - </p> - </div> - </div> - <Button - type="button" - size="sm" - onClick={() => router.push(`/partners/basic-contract`)} - > - 서명하기 - </Button> - </div> - ) : ( - <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20"> - <CheckCircle className="h-4 w-4 text-green-600" /> - <AlertDescription className="text-sm"> - 모든 기본계약 서명 완료 - </AlertDescription> - </Alert> - )} - </div> - ) : ( - <div className="text-center py-8"> - <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> - <p className="text-muted-foreground"> - 이 RFQ에 요청된 기본계약이 없습니다 - </p> - </div> - )} -</CardContent> + </CardContent> </Card> </TabsContent> @@ -429,8 +495,9 @@ export default function VendorResponseEditor({ 취소 </Button> <Button - type="submit" + type="button" // submit에서 button으로 변경 variant="secondary" + onClick={() => handleFormSubmit(false)} // 직접 핸들러 호출 disabled={loading} > {loading ? ( @@ -448,7 +515,7 @@ export default function VendorResponseEditor({ <Button type="button" variant="default" - onClick={methods.handleSubmit((data) => onSubmit(data, true))} + onClick={() => handleFormSubmit(true)} // 직접 핸들러 호출 disabled={loading || !allContractsSigned} > {!allContractsSigned ? ( diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts index 7de3ae58..04cc5234 100644 --- a/lib/rfq-last/vendor-response/service.ts +++ b/lib/rfq-last/vendor-response/service.ts @@ -7,7 +7,7 @@ import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm"; import { rfqsLastView, rfqLastDetails, - rfqLastVendorResponses, + rfqLastVendorResponses,vendorQuotationView, type RfqsLastView } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; @@ -26,25 +26,6 @@ export type VendorQuotationStatus = | "최종확정" // 최종 확정됨 | "취소" // 취소됨 -// 벤더 견적 뷰 타입 확장 -export interface VendorQuotationView extends RfqsLastView { - // 벤더 응답 정보 - responseStatus?: VendorQuotationStatus; - displayStatus?:string; - responseVersion?: number; - submittedAt?: Date; - totalAmount?: number; - vendorCurrency?: string; - - // 벤더별 조건 - vendorPaymentTerms?: string; - vendorIncoterms?: string; - vendorDeliveryDate?: Date; - - participationStatus: "미응답" | "참여" | "불참" | null - participationRepliedAt: Date | null - nonParticipationReason: string | null -} /** * 벤더별 RFQ 목록 조회 @@ -66,28 +47,9 @@ export async function getVendorQuotationsLast( const perPage = input.perPage || 10; const offset = (page - 1) * perPage; - // 1. 먼저 벤더가 포함된 RFQ ID들 조회 - const vendorRfqIds = await db - .select({ rfqsLastId: rfqLastDetails.rfqsLastId }) - .from(rfqLastDetails) - .where( - and( - eq(rfqLastDetails.vendorsId, numericVendorId), - eq(rfqLastDetails.isLatest, true) - ) - ); - - - const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null); - - if (rfqIds.length === 0) { - return { data: [], pageCount: 0 }; - } - - // 2. 필터링 설정 - // advancedTable 모드로 where 절 구성 + // 필터링 설정 const advancedWhere = filterColumns({ - table: rfqsLastView, + table: vendorQuotationView, filters: input.filters, joinOperator: input.joinOperator, }); @@ -97,148 +59,55 @@ export async function getVendorQuotationsLast( if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(rfqsLastView.rfqCode, s), - ilike(rfqsLastView.rfqTitle, s), - ilike(rfqsLastView.itemName, s), - ilike(rfqsLastView.projectName, s), - ilike(rfqsLastView.packageName, s), - ilike(rfqsLastView.status, s) + ilike(vendorQuotationView.rfqCode, s), + ilike(vendorQuotationView.rfqTitle, s), + ilike(vendorQuotationView.itemName, s), + ilike(vendorQuotationView.projectName, s), + ilike(vendorQuotationView.packageName, s), + ilike(vendorQuotationView.status, s), + ilike(vendorQuotationView.displayStatus, s) ); } - // RFQ ID 조건 (벤더가 포함된 RFQ만) - const rfqIdWhere = inArray(rfqsLastView.id, rfqIds); + // 벤더 ID 조건 (필수) + const vendorIdWhere = eq(vendorQuotationView.vendorId, numericVendorId); // 모든 조건 결합 - let whereConditions = [rfqIdWhere]; // 필수 조건 + let whereConditions = [vendorIdWhere]; if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); - // 최종 조건 const finalWhere = and(...whereConditions); - // 3. 정렬 설정 + // 정렬 설정 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => { - // @ts-ignore - 동적 속성 접근 - return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]); + // @ts-ignore + return item.desc ? desc(vendorQuotationView[item.id]) : asc(vendorQuotationView[item.id]); }) - : [desc(rfqsLastView.updatedAt)]; + : [desc(vendorQuotationView.updatedAt)]; - // 4. 메인 쿼리 실행 + // 메인 쿼리 실행 - 이제 한 번의 쿼리로 모든 데이터를 가져옴 const quotations = await db .select() - .from(rfqsLastView) + .from(vendorQuotationView) .where(finalWhere) .orderBy(...orderBy) .limit(perPage) .offset(offset); - // 5. 각 RFQ에 대한 벤더 응답 정보 조회 - const quotationsWithResponse = await Promise.all( - quotations.map(async (rfq) => { - // 벤더 응답 정보 조회 - const response = await db.query.rfqLastVendorResponses.findFirst({ - where: and( - eq(rfqLastVendorResponses.rfqsLastId, rfq.id), - eq(rfqLastVendorResponses.vendorId, numericVendorId), - eq(rfqLastVendorResponses.isLatest, true) - ), - columns: { - status: true, - responseVersion: true, - submittedAt: true, - totalAmount: true, - vendorCurrency: true, - vendorPaymentTermsCode: true, - vendorIncotermsCode: true, - vendorDeliveryDate: true, - participationStatus: true, - participationRepliedAt: true, - nonParticipationReason: true, - } - }); - - // 벤더 상세 정보 조회 - const detail = await db.query.rfqLastDetails.findFirst({ - where: and( - eq(rfqLastDetails.rfqsLastId, rfq.id), - eq(rfqLastDetails.vendorsId, numericVendorId), - eq(rfqLastDetails.isLatest, true) - ), - columns: { - id: true, // rfqLastDetailsId 필요 - emailSentAt: true, - emailStatus: true, - shortList: true, - } - }); - - // 표시할 상태 결정 (새로운 로직) - let displayStatus: string | null = null; - - if (response) { - // 응답 레코드가 있는 경우 - if (response.participationStatus === "불참") { - displayStatus = "불참"; - } else if (response.participationStatus === "참여") { - // 참여한 경우 실제 작업 상태 표시 - displayStatus = response.status || "작성중"; - } else { - // participationStatus가 없거나 "미응답"인 경우 - displayStatus = "미응답"; - } - } else { - // 응답 레코드가 없는 경우 - if (detail?.emailSentAt) { - displayStatus = "미응답"; // 초대는 받았지만 응답 안함 - } else { - displayStatus = null; // 아직 초대도 안됨 - } - } - - return { - ...rfq, - // 새로운 상태 체계 - displayStatus, // UI에서 표시할 통합 상태 - - // 참여 관련 정보 - participationStatus: response?.participationStatus || "미응답", - participationRepliedAt: response?.participationRepliedAt, - nonParticipationReason: response?.nonParticipationReason, - - // 견적 작업 상태 (참여한 경우에만 의미 있음) - responseStatus: response?.status, - responseVersion: response?.responseVersion, - submittedAt: response?.submittedAt, - totalAmount: response?.totalAmount, - vendorCurrency: response?.vendorCurrency, - vendorPaymentTerms: response?.vendorPaymentTermsCode, - vendorIncoterms: response?.vendorIncotermsCode, - vendorDeliveryDate: response?.vendorDeliveryDate, - - // 초대 관련 정보 - rfqLastDetailsId: detail?.id, // 참여 결정 시 필요 - emailSentAt: detail?.emailSentAt, - emailStatus: detail?.emailStatus, - shortList: detail?.shortList, - } as VendorQuotationView; - }) - ); - - // 6. 전체 개수 조회 + // 전체 개수 조회 const { totalCount } = await db .select({ totalCount: count() }) - .from(rfqsLastView) + .from(vendorQuotationView) .where(finalWhere) .then(rows => rows[0]); // 페이지 수 계산 const pageCount = Math.ceil(Number(totalCount) / perPage); - return { - data: quotationsWithResponse, + data: quotations, pageCount }; } catch (err) { diff --git a/lib/rfq-last/vendor-response/validations.ts b/lib/rfq-last/vendor-response/validations.ts index 033154c2..5834bbf6 100644 --- a/lib/rfq-last/vendor-response/validations.ts +++ b/lib/rfq-last/vendor-response/validations.ts @@ -7,7 +7,7 @@ import { createSearchParamsCache, import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { RfqsLastView } from "@/db/schema"; +import { VendorQuotationView } from "@/db/schema"; @@ -15,7 +15,7 @@ export const searchParamsVendorRfqCache = createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<RfqsLastView>().withDefault([ + sort: getSortingStateParser<VendorQuotationView>().withDefault([ { id: "updatedAt", desc: true }, ]), diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx index 144c6c43..a7135ea5 100644 --- a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx +++ b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx @@ -27,8 +27,8 @@ import { } from "@/components/ui/tooltip" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { useRouter } from "next/navigation" -import type { VendorQuotationView } from "./service" import { ParticipationDialog } from "./participation-dialog" +import { VendorQuotationView } from "@/db/schema" // 통합 상태 배지 컴포넌트 (displayStatus 사용) function DisplayStatusBadge({ status }: { status: string | null }) { diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx index 683a0318..2e4975f1 100644 --- a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx +++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx @@ -12,9 +12,9 @@ import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" -import type { VendorQuotationView } from "./service" import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; import { RfqItemsDialog } from "./rfq-items-dialog"; +import { VendorQuotationView } from "@/db/schema" interface VendorQuotationsTableLastProps { promises: Promise<[{ data: VendorQuotationView[], pageCount: number }]> |
