diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-09 10:34:05 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-09 10:34:05 +0000 |
| commit | 86b1fd1cc801f45642f84d24c0b5c84368454ff0 (patch) | |
| tree | 63176d1feb6d3fbbb71d942343056ba6d793b586 /lib | |
| parent | c62ec046327fd388ebce04571b55910747e69a3b (diff) | |
(최겸) 구매 입찰 사전견적, 입찰, 낙찰, 유찰, 재입찰 기능 개발
Diffstat (limited to 'lib')
20 files changed, 782 insertions, 388 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index 2ef57b34..896a082d 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -1180,6 +1180,7 @@ export async function getVendorsForSelection() { // ) .orderBy(vendors.vendorName) + return vendorsData.map(vendor => ({ id: vendor.id, vendorName: vendor.vendorName || "", @@ -1188,7 +1189,7 @@ export async function getVendorsForSelection() { status: vendor.status, })) } catch (error) { - console.error("Error fetching vendors:", error) + console.log("Error fetching vendors:", error) throw new Error("Failed to fetch vendors") } } diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 956c1798..d9bcb255 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -71,7 +71,7 @@ export interface QuotationVendor { quotationAmount: number // 견적금액 currency: string submissionDate: string // 제출일 - isWinner: boolean // 낙찰여부 + isWinner: boolean | null // 낙찰여부 (null: 미정, true: 낙찰, false: 탈락) awardRatio: number | null // 발주비율 isBiddingParticipated: boolean | null // 본입찰 참여여부 status: 'pending' | 'submitted' | 'selected' | 'rejected' @@ -262,7 +262,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV quotationAmount: Number(vendor.quotationAmount) || 0, currency: vendor.currency, submissionDate: vendor.submissionDate ? (vendor.submissionDate instanceof Date ? vendor.submissionDate.toISOString().split('T')[0] : String(vendor.submissionDate).split('T')[0]) : '', - isWinner: vendor.isWinner || false, + isWinner: vendor.isWinner, awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null, isBiddingParticipated: vendor.isBiddingParticipated, status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected', @@ -1208,7 +1208,7 @@ export async function uploadAwardDocument(biddingId: number, file: File, userId: originalFileName: file.name, filePath: saveResult.filePath, fileSize: file.size, - documentType: 'award', + documentType: 'other', title: '낙찰 관련 문서', description: '낙찰 관련 첨부파일', uploadedBy: userId, @@ -1347,7 +1347,7 @@ export async function awardBidding(biddingId: number, selectionReason: string, u eq(biddingCompanies.biddingId, biddingId), eq(biddingCompanies.isWinner, true) )) - + if (awardedCompanies.length === 0) { return { success: false, error: '낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.' } } @@ -1373,27 +1373,40 @@ export async function awardBidding(biddingId: number, selectionReason: string, u // 2. 선정 사유 저장 (첫 번째 낙찰 업체 기준으로 저장) const firstAwardedCompany = awardedCompanies[0] - await tx - .insert(vendorSelectionResults) - .values({ - biddingId, - selectedCompanyId: firstAwardedCompany.companyId, - selectionReason, - selectedBy: userId, - selectedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date() - }) - .onConflictDoUpdate({ - target: [vendorSelectionResults.biddingId], - set: { + + // 기존 선정 결과 확인 + const existingResult = await tx + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + .limit(1) + + if (existingResult.length > 0) { + // 업데이트 + await tx + .update(vendorSelectionResults) + .set({ selectedCompanyId: firstAwardedCompany.companyId, selectionReason, selectedBy: userId, selectedAt: new Date(), updatedAt: new Date() - } - }) + }) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + } else { + // 삽입 + await tx + .insert(vendorSelectionResults) + .values({ + biddingId, + selectedCompanyId: firstAwardedCompany.companyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } }) @@ -1644,12 +1657,14 @@ export interface PartnersBiddingListItem { isWinner: boolean | null isAttendingMeeting: boolean | null isPreQuoteSelected: boolean | null + isPreQuoteParticipated: boolean | null + preQuoteDeadline: Date | null isBiddingInvited: boolean | null notes: string | null createdAt: Date updatedAt: Date // updatedBy: string | null - + hasSpecificationMeeting: boolean | null // biddings 정보 biddingId: number biddingNumber: string @@ -1688,6 +1703,8 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, + preQuoteDeadline: biddingCompanies.preQuoteDeadline, isBiddingInvited: biddingCompanies.isBiddingInvited, notes: biddingCompanies.notes, createdAt: biddingCompanies.createdAt, @@ -1712,6 +1729,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part managerPhone: biddings.managerPhone, currency: biddings.currency, budget: biddings.budget, + hasSpecificationMeeting: biddings.hasSpecificationMeeting, }) .from(biddingCompanies) .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) @@ -1791,6 +1809,8 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: isAttendingMeeting: biddingCompanies.isAttendingMeeting, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, isBiddingParticipated: biddingCompanies.isBiddingParticipated, + isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, + hasSpecificationMeeting: biddings.hasSpecificationMeeting, // 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리 paymentTermsResponse: companyConditionResponses.paymentTermsResponse, taxConditionsResponse: companyConditionResponses.taxConditionsResponse, diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 91bea2f4..a96509a9 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -7,6 +7,10 @@ import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table' import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' +import { BiddingPreQuoteItemDetailsDialog } from '../../../bidding/pre-quote/table/bidding-pre-quote-item-details-dialog' +import { getPrItemsForBidding } from '../../../bidding/pre-quote/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' interface BiddingDetailContentProps { bidding: Bidding @@ -21,6 +25,9 @@ export function BiddingDetailContent({ quotationVendors, prItems }: BiddingDetailContentProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [dialogStates, setDialogStates] = React.useState({ items: false, targetPrice: false, @@ -29,6 +36,11 @@ export function BiddingDetailContent({ }) const [refreshTrigger, setRefreshTrigger] = React.useState(0) + + // PR 아이템 다이얼로그 관련 state + const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false) + const [selectedVendorForDetails, setSelectedVendorForDetails] = React.useState<QuotationVendor | null>(null) + const [prItemsForDialog, setPrItemsForDialog] = React.useState<any[]>([]) const handleRefresh = React.useCallback(() => { setRefreshTrigger(prev => prev + 1) @@ -42,6 +54,25 @@ export function BiddingDetailContent({ setDialogStates(prev => ({ ...prev, [type]: false })) }, []) + const handleViewItemDetails = React.useCallback((vendor: QuotationVendor) => { + startTransition(async () => { + try { + // PR 아이템 정보 로드 + const prItemsData = await getPrItemsForBidding(bidding.id) + setPrItemsForDialog(prItemsData) + setSelectedVendorForDetails(vendor) + setIsItemDetailsDialogOpen(true) + } catch (error) { + console.error('Failed to load PR items:', error) + toast({ + title: '오류', + description: '품목 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } + }) + }, [bidding.id, toast]) + return ( <div className="space-y-6"> <BiddingDetailVendorTableContent @@ -53,6 +84,7 @@ export function BiddingDetailContent({ onOpenTargetPriceDialog={() => openDialog('targetPrice')} onOpenSelectionReasonDialog={() => openDialog('selectionReason')} onOpenAwardDialog={() => openDialog('award')} + onViewItemDetails={handleViewItemDetails} onEdit={undefined} onDelete={undefined} onSelectWinner={undefined} @@ -72,6 +104,16 @@ export function BiddingDetailContent({ bidding={bidding} onSuccess={handleRefresh} /> + + <BiddingPreQuoteItemDetailsDialog + open={isItemDetailsDialogOpen} + onOpenChange={setIsItemDetailsDialogOpen} + biddingId={bidding.id} + biddingCompanyId={selectedVendorForDetails?.id || 0} + companyName={selectedVendorForDetails?.vendorName || ''} + prItems={prItemsForDialog} + currency={bidding.currency || 'KRW'} + /> </div> ) } diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx index fcbbeb9a..2798478c 100644 --- a/lib/bidding/detail/table/bidding-detail-header.tsx +++ b/lib/bidding/detail/table/bidding-detail-header.tsx @@ -141,50 +141,6 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { }) } - const getActionButtons = () => { - const buttons = [] - - // 기본 액션 버튼들 (항상 표시) - - - // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서) - buttons.push( - <Button - key="register" - onClick={handleRegister} - disabled={isPending} - > - <Send className="w-4 h-4 mr-2" /> - 입찰등록 - </Button> - ) - - buttons.push( - <Button - key="disposal" - variant="destructive" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="w-4 h-4 mr-2" /> - 유찰 - </Button> - ) - - buttons.push( - <Button - key="rebidding" - onClick={handleCreateRebidding} - disabled={isPending} - > - <RotateCcw className="w-4 h-4 mr-2" /> - 재입찰 - </Button> - ) - - return buttons - } - return ( <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="px-6 py-4"> @@ -209,11 +165,6 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { </Badge> </div> </div> - - {/* 액션 버튼들 */} - <div className="flex items-center gap-2 flex-shrink-0"> - {getActionButtons()} - </div> </div> </div> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index bb1d2c62..cbdf79c2 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -23,6 +23,7 @@ interface GetVendorColumnsProps { onDelete: (vendor: QuotationVendor) => void onSelectWinner: (vendor: QuotationVendor) => void onViewPriceAdjustment?: (vendor: QuotationVendor) => void + onViewItemDetails?: (vendor: QuotationVendor) => void onSendBidding?: (vendor: QuotationVendor) => void onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void } @@ -32,6 +33,7 @@ export function getBiddingDetailVendorColumns({ onDelete, onSelectWinner, onViewPriceAdjustment, + onViewItemDetails, onSendBidding, onUpdateParticipation }: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { @@ -72,11 +74,24 @@ export function getBiddingDetailVendorColumns({ { accessorKey: 'quotationAmount', header: '견적금액', - cell: ({ row }) => ( - <div className="text-right font-mono"> - {row.original.quotationAmount ? Number(row.original.quotationAmount).toLocaleString() : '-'} {row.original.currency} - </div> - ), + cell: ({ row }) => { + const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0 + return ( + <div className="text-right font-mono"> + {hasAmount ? ( + <button + onClick={() => onViewItemDetails?.(row.original)} + className="text-primary hover:text-primary/80 hover:underline cursor-pointer" + title="품목별 견적 상세 보기" + > + {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency} + </button> + ) : ( + <span className="text-muted-foreground">- {row.original.currency}</span> + )} + </div> + ) + }, }, { accessorKey: 'biddingResult', @@ -84,7 +99,7 @@ export function getBiddingDetailVendorColumns({ cell: ({ row }) => { const isWinner = row.original.isWinner if (isWinner === null || isWinner === undefined) { - return <div>-</div> + return <div>미정</div> } return ( <Badge variant={isWinner ? 'default' : 'secondary'} className={isWinner ? 'bg-green-600' : ''}> @@ -158,12 +173,24 @@ export function getBiddingDetailVendorColumns({ </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>작업</DropdownMenuLabel> - <DropdownMenuItem onClick={() => onEdit(vendor)}> + <DropdownMenuItem + onClick={() => onEdit(vendor)} + disabled={vendor.isBiddingParticipated !== true} + > 발주비율 산정 + {vendor.isBiddingParticipated !== true && ( + <span className="text-xs text-muted-foreground ml-2">(입찰참여 필요)</span> + )} </DropdownMenuItem> {vendor.status !== 'selected' && ( - <DropdownMenuItem onClick={() => onSelectWinner(vendor)}> + <DropdownMenuItem + onClick={() => onSelectWinner(vendor)} + disabled={vendor.isBiddingParticipated !== true} + > 낙찰 선정 + {vendor.isBiddingParticipated !== true && ( + <span className="text-xs text-muted-foreground ml-2">(입찰참여 필요)</span> + )} </DropdownMenuItem> )} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx index b10212ab..9a5408c2 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -112,6 +112,22 @@ export function BiddingDetailVendorEditDialog({ )} {/* 수정 가능한 필드들 */} + {vendor && vendor.isBiddingParticipated !== true && ( + <div className="bg-orange-50 border border-orange-200 rounded-lg p-3 mb-4"> + <div className="flex items-center gap-2 text-orange-800"> + <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> + <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> + </svg> + <span className="font-medium">입찰 참여 안내</span> + </div> + <p className="text-sm text-orange-700 mt-1"> + {vendor.isBiddingParticipated === null + ? '이 업체는 아직 입찰참여 여부가 결정되지 않았습니다. 입찰에 참여한 업체만 발주비율을 설정할 수 있습니다.' + : '이 업체는 입찰에 참여하지 않습니다. 발주비율을 설정할 수 없습니다.' + } + </p> + </div> + )} <div className="space-y-2"> <Label htmlFor="edit-awardRatio">발주비율 (%)</Label> @@ -123,14 +139,23 @@ export function BiddingDetailVendorEditDialog({ value={formData.awardRatio} onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} placeholder="발주비율을 입력하세요" + disabled={vendor?.isBiddingParticipated !== true} /> + {vendor?.isBiddingParticipated !== true && ( + <p className="text-sm text-muted-foreground"> + 입찰에 참여한 업체만 발주비율을 설정할 수 있습니다. + </p> + )} </div> </div> <DialogFooter> <Button variant="outline" onClick={() => onOpenChange(false)}> 취소 </Button> - <Button onClick={handleEdit} disabled={isPending}> + <Button + onClick={handleEdit} + disabled={isPending || vendor?.isBiddingParticipated !== true} + > 산정 </Button> </DialogFooter> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index dd1ae94b..95f63ce9 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import { useSession } from 'next-auth/react' import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table' import { useDataTable } from '@/hooks/use-data-table' import { DataTable } from '@/components/data-table/data-table' @@ -33,6 +34,7 @@ interface BiddingDetailVendorTableContentProps { onEdit?: (vendor: QuotationVendor) => void onDelete?: (vendor: QuotationVendor) => void onSelectWinner?: (vendor: QuotationVendor) => void + onViewItemDetails?: (vendor: QuotationVendor) => void } const filterFields: DataTableFilterField<QuotationVendor>[] = [ @@ -97,10 +99,15 @@ export function BiddingDetailVendorTableContent({ onOpenAwardDialog, onEdit, onDelete, - onSelectWinner + onSelectWinner, + onViewItemDetails }: BiddingDetailVendorTableContentProps) { + const { data: session } = useSession() const { toast } = useToast() const [isPending, startTransition] = useTransition() + + // 세션에서 사용자 ID 가져오기 + const userId = session?.user?.id || '' const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) @@ -145,7 +152,7 @@ export function BiddingDetailVendorTableContent({ const result = selectWinnerSchema.safeParse({ biddingId, vendorId: vendor.id, - awardRatio: vendor.awardRatio, + awardRatio: vendor.awardRatio || 0, }) if (!result.success) { @@ -157,7 +164,7 @@ export function BiddingDetailVendorTableContent({ return } - const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio, 'current-user') + const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio || 0, userId) if (response.success) { toast({ @@ -209,9 +216,10 @@ export function BiddingDetailVendorTableContent({ onEdit: onEdit || handleEdit, onDelete: onDelete || handleDelete, onSelectWinner: onSelectWinner || handleSelectWinner, - onViewPriceAdjustment: handleViewPriceAdjustment + onViewPriceAdjustment: handleViewPriceAdjustment, + onViewItemDetails: onViewItemDetails }), - [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment] + [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment, onViewItemDetails] ) const { table } = useDataTable({ @@ -241,9 +249,9 @@ export function BiddingDetailVendorTableContent({ table={table} biddingId={biddingId} bidding={bidding} + userId={userId} onOpenItemsDialog={onOpenItemsDialog} onOpenTargetPriceDialog={onOpenTargetPriceDialog} - onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} onOpenAwardDialog={() => setIsAwardDialogOpen(true)} onSuccess={onRefresh} /> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 8cdec191..64c31633 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -15,6 +15,7 @@ interface BiddingDetailVendorToolbarActionsProps { table: Table<QuotationVendor> biddingId: number bidding: Bidding + userId: string onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void onOpenAwardDialog: () => void @@ -25,6 +26,7 @@ export function BiddingDetailVendorToolbarActions({ table, biddingId, bidding, + userId, onOpenItemsDialog, onOpenTargetPriceDialog, onOpenAwardDialog, @@ -41,17 +43,17 @@ export function BiddingDetailVendorToolbarActions({ const handleRegister = () => { startTransition(async () => { - const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + const result = await registerBidding(bidding.id, userId) if (result.success) { toast({ - title: '성공', + title: result.message, description: result.message, }) router.refresh() } else { toast({ - title: '오류', + title: result.error, description: result.error, variant: 'destructive', }) @@ -61,17 +63,17 @@ export function BiddingDetailVendorToolbarActions({ const handleMarkAsDisposal = () => { startTransition(async () => { - const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID + const result = await markAsDisposal(bidding.id, userId) if (result.success) { toast({ - title: '성공', + title: result.message, description: result.message, }) router.refresh() } else { toast({ - title: '오류', + title: result.error, description: result.error, variant: 'destructive', }) @@ -81,18 +83,18 @@ export function BiddingDetailVendorToolbarActions({ const handleCreateRebidding = () => { startTransition(async () => { - const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + const result = await createRebidding(bidding.id, userId) if (result.success) { toast({ - title: '성공', + title: result.message, description: result.message, }) router.refresh() onSuccess() } else { toast({ - title: '오류', + title: result.error, description: result.error, variant: 'destructive', }) @@ -104,7 +106,7 @@ export function BiddingDetailVendorToolbarActions({ <> <div className="flex items-center gap-2"> {/* 상태별 액션 버튼 */} - {bidding.status === 'bidding_generated' && ( + {bidding.status === 'set_target_price' && ( <Button variant="default" size="sm" @@ -115,8 +117,6 @@ export function BiddingDetailVendorToolbarActions({ 입찰 등록 </Button> )} - - {bidding.status === 'bidding_closed' && ( <> <Button variant="destructive" @@ -137,7 +137,6 @@ export function BiddingDetailVendorToolbarActions({ 낙찰 </Button> </> - )} {bidding.status === 'bidding_disposal' && ( <Button @@ -159,13 +158,13 @@ export function BiddingDetailVendorToolbarActions({ )} {/* 공통 관리 버튼들 */} - <Button + {/* <Button variant="outline" size="sm" onClick={onOpenItemsDialog} > 품목 정보 - </Button> + </Button> */} <Button variant="outline" size="sm" diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 35bc8941..3f1b916c 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -133,6 +133,7 @@ export async function updatePreQuoteSelection(companyIds: number[], isSelected: await db.update(biddingCompanies) .set({ isPreQuoteSelected: isSelected, + invitationStatus: 'pending', // 초기 상태: 입찰생성 updatedAt: new Date() }) .where(inArray(biddingCompanies.id, companyIds)) @@ -194,7 +195,9 @@ export async function getBiddingCompanies(biddingId: number) { respondedAt: biddingCompanies.respondedAt, preQuoteAmount: biddingCompanies.preQuoteAmount, preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + preQuoteDeadline: biddingCompanies.preQuoteDeadline, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, isAttendingMeeting: biddingCompanies.isAttendingMeeting, notes: biddingCompanies.notes, contactPerson: biddingCompanies.contactPerson, @@ -217,6 +220,7 @@ export async function getBiddingCompanies(biddingId: number) { proposedShippingPort: companyConditionResponses.proposedShippingPort, proposedDestinationPort: companyConditionResponses.proposedDestinationPort, sparePartResponse: companyConditionResponses.sparePartResponse, + additionalProposals: companyConditionResponses.additionalProposals, }) .from(biddingCompanies) .leftJoin( @@ -243,7 +247,7 @@ export async function getBiddingCompanies(biddingId: number) { } // 선택된 업체들에게 사전견적 초대 발송 -export async function sendPreQuoteInvitations(companyIds: number[]) { +export async function sendPreQuoteInvitations(companyIds: number[], preQuoteDeadline?: Date | string) { try { if (companyIds.length === 0) { return { @@ -292,6 +296,7 @@ export async function sendPreQuoteInvitations(companyIds: number[]) { .set({ invitationStatus: 'sent', // 사전견적 초대 발송 상태 invitedAt: new Date(), + preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null, updatedAt: new Date() }) .where(eq(biddingCompanies.id, id)) @@ -406,7 +411,9 @@ export async function getBiddingCompaniesForPartners(biddingId: number, companyI invitationStatus: biddingCompanies.invitationStatus, preQuoteAmount: biddingCompanies.preQuoteAmount, preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + preQuoteDeadline: biddingCompanies.preQuoteDeadline, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, isAttendingMeeting: biddingCompanies.isAttendingMeeting, // company_condition_responses 정보 paymentTermsResponse: companyConditionResponses.paymentTermsResponse, @@ -443,7 +450,9 @@ export async function getBiddingCompaniesForPartners(biddingId: number, companyI invitationStatus: null, preQuoteAmount: null, preQuoteSubmittedAt: null, + preQuoteDeadline: null, isPreQuoteSelected: false, + isPreQuoteParticipated: null, isAttendingMeeting: null, paymentTermsResponse: null, taxConditionsResponse: null, @@ -666,7 +675,7 @@ export async function respondToPreQuoteInvitation( } } -// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteSelected 사용) +// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteSelected, isPreQuoteParticipated 사용) export async function setPreQuoteParticipation( biddingCompanyId: number, isParticipating: boolean, @@ -675,8 +684,8 @@ export async function setPreQuoteParticipation( try { await db.update(biddingCompanies) .set({ + isPreQuoteParticipated: isParticipating, isPreQuoteSelected: isParticipating, - invitationStatus: isParticipating ? 'accepted' : 'declined', respondedAt: new Date(), updatedAt: new Date() }) diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx index 692d12ea..91b80bd3 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Bidding } from '@/db/schema' -import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' +import { QuotationDetails } from '@/lib/bidding/detail/service' import { getBiddingCompanies } from '../service' import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' @@ -10,7 +10,6 @@ import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-ta interface BiddingPreQuoteContentProps { bidding: Bidding quotationDetails: QuotationDetails | null - quotationVendors: QuotationVendor[] biddingCompanies: any[] prItems: any[] } @@ -18,7 +17,6 @@ interface BiddingPreQuoteContentProps { export function BiddingPreQuoteContent({ bidding, quotationDetails, - quotationVendors, biddingCompanies: initialBiddingCompanies, prItems }: BiddingPreQuoteContentProps) { @@ -42,15 +40,11 @@ export function BiddingPreQuoteContent({ <BiddingPreQuoteVendorTableContent biddingId={bidding.id} bidding={bidding} - vendors={quotationVendors} biddingCompanies={biddingCompanies} onRefresh={handleRefresh} onOpenItemsDialog={() => {}} onOpenTargetPriceDialog={() => {}} onOpenSelectionReasonDialog={() => {}} - onEdit={undefined} - onDelete={undefined} - onSelectWinner={undefined} /> </div> ) diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx index 84824c1e..1b0598b7 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx @@ -3,6 +3,8 @@ import * as React from 'react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { Dialog, DialogContent, @@ -16,7 +18,7 @@ import { BiddingCompany } from './bidding-pre-quote-vendor-columns' import { sendPreQuoteInvitations } from '../service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' -import { Mail, Building2 } from 'lucide-react' +import { Mail, Building2, Calendar } from 'lucide-react' interface BiddingPreQuoteInvitationDialogProps { open: boolean @@ -34,6 +36,7 @@ export function BiddingPreQuoteInvitationDialog({ const { toast } = useToast() const [isPending, startTransition] = useTransition() const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([]) + const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('') // 초대 가능한 업체들 (pending 상태인 업체들) const invitableCompanies = companies.filter(company => @@ -67,7 +70,10 @@ export function BiddingPreQuoteInvitationDialog({ } startTransition(async () => { - const response = await sendPreQuoteInvitations(selectedCompanyIds) + const response = await sendPreQuoteInvitations( + selectedCompanyIds, + preQuoteDeadline || undefined + ) if (response.success) { toast({ @@ -75,6 +81,7 @@ export function BiddingPreQuoteInvitationDialog({ description: response.message, }) setSelectedCompanyIds([]) + setPreQuoteDeadline('') onOpenChange(false) onSuccess() } else { @@ -91,6 +98,7 @@ export function BiddingPreQuoteInvitationDialog({ onOpenChange(open) if (!open) { setSelectedCompanyIds([]) + setPreQuoteDeadline('') } } @@ -114,6 +122,24 @@ export function BiddingPreQuoteInvitationDialog({ </div> ) : ( <> + {/* 견적마감일 설정 */} + <div className="mb-6 p-4 border rounded-lg bg-muted/30"> + <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2"> + <Calendar className="w-4 h-4" /> + 견적 마감일 (선택사항) + </Label> + <Input + id="preQuoteDeadline" + type="datetime-local" + value={preQuoteDeadline} + onChange={(e) => setPreQuoteDeadline(e.target.value)} + className="w-full" + /> + <p className="text-xs text-muted-foreground mt-1"> + 설정하지 않으면 마감일 없이 초대가 발송됩니다. + </p> + </div> + {/* 전체 선택 */} <div className="flex items-center space-x-2 mb-4 pb-2 border-b"> <Checkbox diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx index 7e84f178..5c6f41ce 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { - MoreHorizontal, Edit, Trash2, UserPlus, Paperclip + MoreHorizontal, Edit, Trash2, Paperclip } from "lucide-react" import { DropdownMenu, @@ -27,7 +27,9 @@ export interface BiddingCompany { respondedAt: Date | null preQuoteAmount: string | null preQuoteSubmittedAt: Date | null + preQuoteDeadline: Date | null isPreQuoteSelected: boolean + isPreQuoteParticipated: boolean | null isAttendingMeeting: boolean | null notes: string | null contactPerson: string | null @@ -56,7 +58,6 @@ export interface BiddingCompany { interface GetBiddingCompanyColumnsProps { onEdit: (company: BiddingCompany) => void onDelete: (company: BiddingCompany) => void - onInvite: (company: BiddingCompany) => void onViewPriceAdjustment?: (company: BiddingCompany) => void onViewItemDetails?: (company: BiddingCompany) => void onViewAttachments?: (company: BiddingCompany) => void @@ -65,7 +66,6 @@ interface GetBiddingCompanyColumnsProps { export function getBiddingPreQuoteVendorColumns({ onEdit, onDelete, - onInvite, onViewPriceAdjustment, onViewItemDetails, onViewAttachments @@ -109,11 +109,28 @@ export function getBiddingPreQuoteVendorColumns({ header: '초대 상태', cell: ({ row }) => { const status = row.original.invitationStatus - const variant = status === 'accepted' ? 'default' : - status === 'declined' ? 'destructive' : 'outline' + let variant: any + let label: string - const label = status === 'accepted' ? '수락' : - status === 'declined' ? '거절' : '대기중' + if (status === 'accepted') { + variant = 'default' + label = '수락' + } else if (status === 'declined') { + variant = 'destructive' + label = '거절' + } else if (status === 'pending') { + variant = 'outline' + label = '대기중' + } else if (status === 'sent') { + variant = 'outline' + label = '요청됨' + } else if (status === 'submitted') { + variant = 'outline' + label = '제출됨' + } else { + variant = 'outline' + label = status || '-' + } return <Badge variant={variant}>{label}</Badge> }, @@ -150,6 +167,31 @@ export function getBiddingPreQuoteVendorColumns({ ), }, { + accessorKey: 'preQuoteDeadline', + header: '사전견적 마감일', + cell: ({ row }) => { + const deadline = row.original.preQuoteDeadline + if (!deadline) { + return <div className="text-muted-foreground text-sm">-</div> + } + + const now = new Date() + const deadlineDate = new Date(deadline) + const isExpired = deadlineDate < now + + return ( + <div className={`text-sm ${isExpired ? 'text-red-600' : ''}`}> + <div>{deadlineDate.toLocaleDateString('ko-KR')}</div> + {isExpired && ( + <Badge variant="destructive" className="text-xs mt-1"> + 마감 + </Badge> + )} + </div> + ) + }, + }, + { accessorKey: 'attachments', header: '첨부파일', cell: ({ row }) => { @@ -174,6 +216,21 @@ export function getBiddingPreQuoteVendorColumns({ }, }, { + accessorKey: 'isPreQuoteParticipated', + header: '사전견적 참여의사', + cell: ({ row }) => { + const participated = row.original.isPreQuoteParticipated + if (participated === null) { + return <Badge variant="outline">미결정</Badge> + } + return ( + <Badge variant={participated ? 'default' : 'destructive'}> + {participated ? '참여' : '미참여'} + </Badge> + ) + }, + }, + { accessorKey: 'isPreQuoteSelected', header: '본입찰 선정', cell: ({ row }) => ( @@ -307,15 +364,6 @@ export function getBiddingPreQuoteVendorColumns({ ), }, { - accessorKey: 'notes', - header: '특이사항', - cell: ({ row }) => ( - <div className="text-sm max-w-32 truncate" title={row.original.notes || ''}> - {row.original.notes || '-'} - </div> - ), - }, - { id: 'actions', header: '액션', cell: ({ row }) => { @@ -334,12 +382,6 @@ export function getBiddingPreQuoteVendorColumns({ <Edit className="mr-2 h-4 w-4" /> 수정 </DropdownMenuItem> */} - {company.invitationStatus === 'pending' && ( - <DropdownMenuItem onClick={() => onInvite(company)}> - <UserPlus className="mr-2 h-4 w-4" /> - 초대 발송 - </DropdownMenuItem> - )} <DropdownMenuItem onClick={() => onDelete(company)} className="text-destructive" diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx index a1319821..7ea05721 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx @@ -23,7 +23,6 @@ import { getPrItemsForBidding } from '../service' interface BiddingPreQuoteVendorTableContentProps { biddingId: number bidding: Bidding - vendors: any[] // 사용하지 않음 biddingCompanies: BiddingCompany[] onRefresh: () => void onOpenItemsDialog: () => void @@ -31,7 +30,6 @@ interface BiddingPreQuoteVendorTableContentProps { onOpenSelectionReasonDialog: () => void onEdit?: (company: BiddingCompany) => void onDelete?: (company: BiddingCompany) => void - onSelectWinner?: (company: BiddingCompany) => void } const filterFields: DataTableFilterField<BiddingCompany>[] = [ @@ -80,6 +78,7 @@ const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [ options: [ { label: '수락', value: 'accepted' }, { label: '거절', value: 'declined' }, + { label: '요청됨', value: 'sent' }, { label: '대기중', value: 'pending' }, ], }, @@ -88,15 +87,13 @@ const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [ export function BiddingPreQuoteVendorTableContent({ biddingId, bidding, - vendors, biddingCompanies, onRefresh, onOpenItemsDialog, onOpenTargetPriceDialog, onOpenSelectionReasonDialog, onEdit, - onDelete, - onSelectWinner + onDelete }: BiddingPreQuoteVendorTableContentProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() @@ -137,13 +134,6 @@ export function BiddingPreQuoteVendorTableContent({ setIsEditDialogOpen(true) } - const handleInvite = (company: BiddingCompany) => { - // TODO: 초대 발송 로직 구현 - toast({ - title: '알림', - description: `${company.companyName} 업체에 초대를 발송했습니다.`, - }) - } const handleViewPriceAdjustment = async (company: BiddingCompany) => { startTransition(async () => { @@ -190,12 +180,11 @@ export function BiddingPreQuoteVendorTableContent({ () => getBiddingPreQuoteVendorColumns({ onEdit: onEdit || handleEdit, onDelete: onDelete || handleDelete, - onInvite: handleInvite, onViewPriceAdjustment: handleViewPriceAdjustment, onViewItemDetails: handleViewItemDetails, onViewAttachments: handleViewAttachments }), - [onEdit, onDelete, handleEdit, handleDelete, handleInvite, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments] + [onEdit, onDelete, handleEdit, handleDelete, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments] ) const { table } = useDataTable({ diff --git a/lib/bidding/vendor/components/simple-file-upload.tsx b/lib/bidding/vendor/components/simple-file-upload.tsx index b1eb8b8f..58b60bdf 100644 --- a/lib/bidding/vendor/components/simple-file-upload.tsx +++ b/lib/bidding/vendor/components/simple-file-upload.tsx @@ -65,15 +65,23 @@ export function SimpleFileUpload({ try { setIsLoading(true) const docs = await getPreQuoteDocuments(biddingId, companyId) + + // docs가 undefined이거나 배열이 아닌 경우 빈 배열로 처리 + if (!docs || !Array.isArray(docs)) { + setDocuments([]) + return + } + // Date를 string으로 변환 const mappedDocs = docs.map(doc => ({ ...doc, - uploadedAt: doc.uploadedAt.toString(), + uploadedAt: doc.uploadedAt?.toString() || '', uploadedBy: doc.uploadedBy || '' })) setDocuments(mappedDocs) } catch (error) { console.error('Failed to load documents:', error) + setDocuments([]) // 에러 시에도 빈 배열로 설정 toast({ title: '오류', description: '업로드된 문서 목록을 불러오는데 실패했습니다.', @@ -155,9 +163,13 @@ export function SimpleFileUpload({ if (result.success) { try { - await downloadFile(result.document?.filePath, result.document?.originalFileName, { - showToast: true - }) + if (result.document?.filePath && result.document?.originalFileName) { + await downloadFile(result.document.filePath, result.document.originalFileName, { + showToast: true + }) + } else { + throw new Error('파일 정보가 없습니다.') + } } catch (error) { toast({ title: '다운로드 실패', diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx index 9205c46a..e93702ed 100644 --- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -42,6 +42,7 @@ interface PartnersBiddingAttendanceDialogProps { preQuoteDate: string | null biddingRegistrationDate: string | null evaluationDate: string | null + hasSpecificationMeeting?: boolean // 사양설명회 여부 추가 } | null biddingCompanyId: number isAttending: boolean | null @@ -206,6 +207,36 @@ export function PartnersBiddingAttendanceDialog({ if (!biddingDetail) return null + // 사양설명회가 없는 경우 + if (biddingDetail.hasSpecificationMeeting === false) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="w-5 h-5" /> + 사양설명회 정보 + </DialogTitle> + </DialogHeader> + + <div className="py-6 text-center"> + <XCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> + <h3 className="text-lg font-medium mb-2">사양설명회 없음</h3> + <p className="text-muted-foreground"> + 해당 입찰 건은 사양설명회가 없습니다. + </p> + </div> + + <DialogFooter> + <Button onClick={() => onOpenChange(false)}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-4xl max-h-[90vh]"> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 0d1e3123..7058f026 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -94,7 +94,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL // 액션 (드롭다운 메뉴) columnHelper.display({ id: 'actions', - header: 'Actions', + header: '액션', cell: ({ row }) => { const handleView = () => { if (setRowAction) { @@ -122,13 +122,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL }) } } - - const canManageAttendance = row.original.invitationStatus === 'sent' || - row.original.invitationStatus === 'accepted' - // 사전견적이 가능한 조건: 초대 발송(sent) 상태인 경우 - const canDoPreQuote = row.original.invitationStatus === 'sent' || row.original.invitationStatus === 'pending' || row.original.invitationStatus === 'submitted'; - return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -143,20 +137,12 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL <DropdownMenuContent align="end" className="w-[160px]"> <DropdownMenuItem onClick={handleView}> <FileText className="mr-2 h-4 w-4" /> - 상세보기 + 입찰 상세보기 </DropdownMenuItem> - {canDoPreQuote && ( <DropdownMenuItem onClick={handlePreQuote}> <Calculator className="mr-2 h-4 w-4" /> 사전견적하기 </DropdownMenuItem> - )} - {canManageAttendance && ( - <DropdownMenuItem onClick={handleAttendance}> - <Users className="mr-2 h-4 w-4" /> - 참석여부 - </DropdownMenuItem> - )} </DropdownMenuContent> </DropdownMenu> ) @@ -199,6 +185,22 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL }, }), + // 사전견적 참여의사 + columnHelper.accessor('isPreQuoteParticipated', { + header: '사전견적 참여의사', + cell: ({ row }) => { + const participated = row.original.isPreQuoteParticipated + if (participated === null) { + return <Badge variant="outline">미결정</Badge> + } + return ( + <Badge variant={participated ? 'default' : 'destructive'}> + {participated ? '참여' : '미참여'} + </Badge> + ) + }, + }), + // 입찰 참여의사 columnHelper.accessor('invitationStatus', { header: '입찰 참여의사', @@ -250,6 +252,33 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL }, }), + // 사전견적 마감일 + columnHelper.accessor('preQuoteDeadline', { + header: '사전견적 마감일', + cell: ({ row }) => { + const deadline = row.original.preQuoteDeadline + if (!deadline) { + return <div className="text-muted-foreground">-</div> + } + + const now = new Date() + const deadlineDate = new Date(deadline) + const isExpired = deadlineDate < now + + return ( + <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> + <Calendar className="w-4 h-4" /> + <span>{formatDate(deadline, 'KR')}</span> + {isExpired && ( + <Badge variant="destructive" className="text-xs"> + 마감 + </Badge> + )} + </div> + ) + }, + }), + // 계약기간 columnHelper.accessor('contractPeriod', { header: '계약기간', diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index 2e8d4164..08489da5 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -93,6 +93,17 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { case 'view': // 본입찰 초대 여부 확인 const bidding = rowAction.row.original + + // 사전견적 요청 상태에서는 상세보기 제한 + if (bidding.status === 'request_for_quotation') { + toast({ + title: '접근 제한', + description: '사전견적 요청 상태에서는 상세보기를 이용할 수 없습니다.', + variant: 'destructive', + }) + return + } + if (bidding.status === 'bidding_opened' && !bidding.isBiddingInvited) { // 본입찰이 오픈되었지만 초대받지 않은 경우 toast({ @@ -227,6 +238,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { preQuoteDate: null, biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null, evaluationDate: null, + hasSpecificationMeeting: (rowAction.row.original as any).hasSpecificationMeeting || false, // 사양설명회 여부 추가 } : null} biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0} isAttending={rowAction?.row.original?.isAttendingMeeting || null} diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx index 4cd0efdb..fdd05154 100644 --- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx +++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx @@ -30,7 +30,8 @@ import { submitPreQuoteResponse, getPrItemsForBidding, getSavedPrItemQuotations, - savePreQuoteDraft + savePreQuoteDraft, + setPreQuoteParticipation } from '../pre-quote/service' import { getBiddingConditions } from '../service' import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service' @@ -80,6 +81,7 @@ interface BiddingDetail { invitationStatus: string | null preQuoteAmount: string | null preQuoteSubmittedAt: string | null + preQuoteDeadline: string | null isPreQuoteSelected: boolean | null isAttendingMeeting: boolean | null // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두) @@ -126,6 +128,9 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin isAttendingMeeting: false, }) + // 사전견적 참여의사 상태 + const [participationDecision, setParticipationDecision] = React.useState<boolean | null>(null) + // 연동제 폼 상태 const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({ itemName: '', @@ -211,6 +216,9 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin additionalProposals: result.additionalProposals || '', isAttendingMeeting: result.isAttendingMeeting || false, }) + + // 사전견적 참여의사 초기화 + setParticipationDecision(result.isPreQuoteParticipated) } if (conditions) { @@ -238,64 +246,131 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin // 임시저장 기능 const handleTempSave = () => { - if (!biddingDetail) return + if (!biddingDetail || !biddingDetail.biddingCompanyId) { + toast({ + title: '임시저장 실패', + description: '입찰 정보가 올바르지 않습니다.', + variant: 'destructive', + }) + return + } + + if (!userId) { + toast({ + title: '임시저장 실패', + description: '사용자 정보를 확인할 수 없습니다. 다시 로그인해주세요.', + variant: 'destructive', + }) + return + } setIsSaving(true) startTransition(async () => { - const result = await savePreQuoteDraft( + try { + const result = await savePreQuoteDraft( + biddingDetail.biddingCompanyId!, + { + prItemQuotations, + paymentTermsResponse: responseData.paymentTermsResponse, + taxConditionsResponse: responseData.taxConditionsResponse, + incotermsResponse: responseData.incotermsResponse, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + proposedShippingPort: responseData.proposedShippingPort, + proposedDestinationPort: responseData.proposedDestinationPort, + priceAdjustmentResponse: responseData.priceAdjustmentResponse, + isInitialResponse: responseData.isInitialResponse, + sparePartResponse: responseData.sparePartResponse, + additionalProposals: responseData.additionalProposals, + priceAdjustmentForm: responseData.priceAdjustmentResponse ? { + itemName: priceAdjustmentForm.itemName, + adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: priceAdjustmentForm.referenceDate, + comparisonDate: priceAdjustmentForm.comparisonDate, + adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined, + notes: priceAdjustmentForm.notes, + adjustmentConditions: priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod, + contractorWriter: priceAdjustmentForm.contractorWriter, + adjustmentDate: priceAdjustmentForm.adjustmentDate, + nonApplicableReason: priceAdjustmentForm.nonApplicableReason, + } : undefined + }, + userId + ) + + if (result.success) { + toast({ + title: '임시저장 완료', + description: result.message, + }) + } else { + toast({ + title: '임시저장 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Temp save error:', error) + toast({ + title: '임시저장 실패', + description: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + variant: 'destructive', + }) + } finally { + setIsSaving(false) + } + }) + } + + // 사전견적 참여의사 설정 함수 + const handleParticipationDecision = async (participate: boolean) => { + if (!biddingDetail?.biddingCompanyId) return + + startTransition(async () => { + const result = await setPreQuoteParticipation( biddingDetail.biddingCompanyId!, - { - prItemQuotations, - paymentTermsResponse: responseData.paymentTermsResponse, - taxConditionsResponse: responseData.taxConditionsResponse, - incotermsResponse: responseData.incotermsResponse, - proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, - proposedShippingPort: responseData.proposedShippingPort, - proposedDestinationPort: responseData.proposedDestinationPort, - priceAdjustmentResponse: responseData.priceAdjustmentResponse, - isInitialResponse: responseData.isInitialResponse, - sparePartResponse: responseData.sparePartResponse, - additionalProposals: responseData.additionalProposals, - priceAdjustmentForm: responseData.priceAdjustmentResponse ? { - itemName: priceAdjustmentForm.itemName, - adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: priceAdjustmentForm.referenceDate, - comparisonDate: priceAdjustmentForm.comparisonDate, - adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined, - notes: priceAdjustmentForm.notes, - adjustmentConditions: priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod, - contractorWriter: priceAdjustmentForm.contractorWriter, - adjustmentDate: priceAdjustmentForm.adjustmentDate, - nonApplicableReason: priceAdjustmentForm.nonApplicableReason, - } : undefined - }, - 'current-user' // TODO: 실제 사용자 ID + participate, + userId ) if (result.success) { + setParticipationDecision(participate) toast({ - title: '임시저장 완료', - description: result.message, + title: '설정 완료', + description: `사전견적 ${participate ? '참여' : '미참여'}로 설정되었습니다.`, }) } else { toast({ - title: '임시저장 실패', + title: '설정 실패', description: result.error, variant: 'destructive', }) } - setIsSaving(false) }) } const handleSubmitResponse = () => { if (!biddingDetail) return + // 견적마감일 체크 + if (biddingDetail.preQuoteDeadline) { + const now = new Date() + const deadline = new Date(biddingDetail.preQuoteDeadline) + if (deadline < now) { + toast({ + title: '견적 마감', + description: '견적 마감일이 지나 제출할 수 없습니다.', + variant: 'destructive', + }) + return + } + } + // 필수값 검증 if (prItemQuotations.length === 0 || totalAmount === 0) { toast({ @@ -342,7 +417,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin const result = await submitPreQuoteResponse( biddingDetail.biddingCompanyId!, submissionData, - 'current-user' // TODO: 실제 사용자 ID + userId ) console.log('제출 결과:', result) @@ -493,7 +568,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin <span>{biddingDetail.itemName}</span> </div> </div> - <div> + {/* <div> <Label className="text-sm font-medium text-muted-foreground">계약구분</Label> <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div> </div> @@ -504,7 +579,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin <div> <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div> - </div> + </div> */} <div> <Label className="text-sm font-medium text-muted-foreground">담당자</Label> <div className="flex items-center gap-2 mt-1"> @@ -514,7 +589,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> </div> - {biddingDetail.budget && ( + {/* {biddingDetail.budget && ( <div> <Label className="text-sm font-medium text-muted-foreground">예산</Label> <div className="flex items-center gap-2 mt-1"> @@ -522,10 +597,10 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> </div> </div> - )} + )} */} {/* 일정 정보 */} - <div className="pt-4 border-t"> + {/* <div className="pt-4 border-t"> <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label> <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( @@ -539,7 +614,60 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> )} </div> - </div> + </div> */} + + {/* 견적마감일 정보 */} + {biddingDetail.preQuoteDeadline && ( + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">견적 마감 정보</Label> + {(() => { + const now = new Date() + const deadline = new Date(biddingDetail.preQuoteDeadline) + const isExpired = deadline < now + const timeLeft = deadline.getTime() - now.getTime() + const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) + const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + + return ( + <div className={`p-3 rounded-lg border-2 ${ + isExpired + ? 'border-red-200 bg-red-50' + : daysLeft <= 1 + ? 'border-orange-200 bg-orange-50' + : 'border-green-200 bg-green-50' + }`}> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calendar className="w-5 h-5" /> + <span className="font-medium">견적 마감일:</span> + <span className="text-lg font-semibold"> + {formatDate(biddingDetail.preQuoteDeadline, 'KR')} + </span> + </div> + {isExpired ? ( + <Badge variant="destructive" className="ml-2"> + 마감됨 + </Badge> + ) : daysLeft <= 1 ? ( + <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> + {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} + </Badge> + ) : ( + <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> + {daysLeft}일 남음 + </Badge> + )} + </div> + {isExpired && ( + <div className="mt-2 text-sm text-red-600"> + ⚠️ 견적 마감일이 지났습니다. 견적 제출이 불가능합니다. + </div> + )} + </div> + ) + })()} + </div> + )} </CardContent> </Card> @@ -617,28 +745,124 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </Card> )} - {/* 품목별 견적 작성 섹션 */} - {prItems.length > 0 && ( - <PrItemsPricingTable - prItems={prItems} - initialQuotations={prItemQuotations} - currency={biddingDetail?.currency || 'KRW'} - onQuotationsChange={setPrItemQuotations} - onTotalAmountChange={setTotalAmount} - readOnly={false} - /> - )} + {/* 사전견적 참여의사 결정 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Users className="w-5 h-5" /> + 사전견적 참여의사 결정 + </CardTitle> + </CardHeader> + <CardContent> + {participationDecision === null ? ( + <div className="space-y-4"> + <p className="text-muted-foreground"> + 해당 입찰의 사전견적에 참여하시겠습니까? + </p> + <div className="flex gap-3"> + <Button + onClick={() => handleParticipationDecision(true)} + disabled={isPending} + className="flex items-center gap-2" + > + <CheckCircle className="w-4 h-4" /> + 참여 + </Button> + <Button + variant="outline" + onClick={() => handleParticipationDecision(false)} + disabled={isPending} + className="flex items-center gap-2" + > + <XCircle className="w-4 h-4" /> + 미참여 + </Button> + </div> + </div> + ) : ( + <div className="space-y-4"> + <div className={`flex items-center gap-2 p-3 rounded-lg ${ + participationDecision ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800' + }`}> + {participationDecision ? ( + <CheckCircle className="w-5 h-5" /> + ) : ( + <XCircle className="w-5 h-5" /> + )} + <span className="font-medium"> + 사전견적 {participationDecision ? '참여' : '미참여'}로 설정되었습니다. + </span> + </div> + {participationDecision === false && ( + <div className="p-4 bg-muted rounded-lg"> + <p className="text-muted-foreground"> + 미참여로 설정되어 견적 작성 섹션이 숨겨집니다. 참여하시려면 아래 버튼을 클릭해주세요. + </p> + </div> + )} + <Button + variant="outline" + size="sm" + onClick={() => setParticipationDecision(null)} + disabled={isPending} + > + 결정 변경하기 + </Button> + </div> + )} + </CardContent> + </Card> + + {/* 참여 결정 시에만 견적 작성 섹션들 표시 (단, 견적마감일이 지나지 않은 경우에만) */} + {participationDecision === true && (() => { + // 견적마감일 체크 + if (biddingDetail?.preQuoteDeadline) { + const now = new Date() + const deadline = new Date(biddingDetail.preQuoteDeadline) + const isExpired = deadline < now + + if (isExpired) { + return ( + <Card> + <CardContent className="pt-6"> + <div className="text-center py-8"> + <XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> + <h3 className="text-lg font-semibold text-red-700 mb-2">견적 마감</h3> + <p className="text-muted-foreground"> + 견적 마감일({formatDate(biddingDetail.preQuoteDeadline, 'KR')})이 지나 견적 제출이 불가능합니다. + </p> + </div> + </CardContent> + </Card> + ) + } + } + + return true // 견적 작성 가능 + })() && ( + <> + {/* 품목별 견적 작성 섹션 */} + {prItems.length > 0 && ( + <PrItemsPricingTable + prItems={prItems} + initialQuotations={prItemQuotations} + currency={biddingDetail?.currency || 'KRW'} + onQuotationsChange={setPrItemQuotations} + onTotalAmountChange={setTotalAmount} + readOnly={false} + /> + )} - {/* 견적 문서 업로드 섹션 */} - <SimpleFileUpload - biddingId={biddingId} - companyId={companyId} - userId={userId} - readOnly={false} - /> + {/* 견적 문서 업로드 섹션 */} + <SimpleFileUpload + biddingId={biddingId} + companyId={companyId} + userId={userId} + readOnly={false} + /> - {/* 사전견적 폼 섹션 */} - <Card> + {/* 사전견적 폼 섹션 */} + <Card> <CardHeader> <CardTitle className="flex items-center gap-2"> <Send className="w-5 h-5" /> @@ -952,30 +1176,23 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin )} <div className="flex justify-end gap-2 pt-4"> - <> - <Button - variant="outline" - onClick={handleTempSave} - disabled={isSaving || isPending} - > - <Save className="w-4 h-4 mr-2" /> - {isSaving ? '저장중...' : '임시저장'} - </Button> - <Button onClick={handleSubmitResponse} disabled={isPending || isSaving}> - <Send className="w-4 h-4 mr-2" /> - 사전견적 제출 - </Button> - </> - - {/* {biddingDetail?.invitationStatus === 'submitted' && ( - <div className="flex items-center gap-2 text-green-600"> - <CheckCircle className="w-5 h-5" /> - <span className="font-medium">사전견적이 제출되었습니다</span> - </div> - )} */} + <Button + variant="outline" + onClick={handleTempSave} + disabled={isSaving || isPending} + > + <Save className="w-4 h-4 mr-2" /> + {isSaving ? '저장중...' : '임시저장'} + </Button> + <Button onClick={handleSubmitResponse} disabled={isPending || isSaving}> + <Send className="w-4 h-4 mr-2" /> + 사전견적 제출 + </Button> </div> </CardContent> </Card> + </> + )} </div> ) } diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index c2fb6487..1500f6a7 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -24,16 +24,6 @@ export function PartnersBiddingToolbarActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null - // 사양설명회 참석 여부 버튼 활성화 조건 - const canManageAttendance = selectedBidding && ( - selectedBidding.invitationStatus === 'sent' || - selectedBidding.invitationStatus === 'accepted' || - selectedBidding.invitationStatus === 'submitted' - ) - - // 참여 의사 결정 버튼 활성화 조건 (sent 상태이고 아직 참여의사를 결정하지 않은 경우) - const canDecideParticipation = selectedBidding - const handleAttendanceClick = () => { if (selectedBidding && setRowAction) { setRowAction({ @@ -43,33 +33,14 @@ export function PartnersBiddingToolbarActions({ } } - const handleParticipationClick = () => { - if (selectedBidding && setRowAction) { - setRowAction({ - type: 'participation', - row: { original: selectedBidding } - }) - } - } + return ( <div className="flex items-center gap-2"> <Button variant="outline" size="sm" - onClick={handleParticipationClick} - disabled={!canDecideParticipation} - className="flex items-center gap-2" - > - <CheckCircle className="w-4 h-4" /> - 참여 의사 결정 - </Button> - - <Button - variant="outline" - size="sm" onClick={handleAttendanceClick} - disabled={!canManageAttendance} className="flex items-center gap-2" > <Users className="w-4 h-4" /> diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx index bfaa14b9..d99fc0e3 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(), @@ -102,10 +102,7 @@ export function RequestReviewDialog({ const [editorContent, setEditorContent] = React.useState("") const [canRequest, setCanRequest] = React.useState(true) const [requestCheckMessage, setRequestCheckMessage] = React.useState("") - - // "기타" 모드 상태를 별도로 관리 - const [isTitleOtherMode, setIsTitleOtherMode] = React.useState(false) - const [customTitle, setCustomTitle] = React.useState("") + const [isCustomTitle, setIsCustomTitle] = React.useState(false) // work의 category에 따라 기본 reviewDepartment 결정 const getDefaultReviewDepartment = () => { @@ -132,7 +129,7 @@ export function RequestReviewDialog({ setCanRequest(result.canRequest) setRequestCheckMessage(result.reason || "") }) - + const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토" form.setValue("reviewDepartment", defaultDepartment) } @@ -141,24 +138,25 @@ 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 === "기타" + // const isTitleOther = titleValue === "기타" // 검토부문 변경시 관련 필드 초기화 React.useEffect(() => { if (reviewDepartment === "준법문의") { + setIsCustomTitle(false) form.setValue("inquiryType", undefined) // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) const currentTitle = form.getValues("title") - if (currentTitle === "기타" || !currentTitle || currentTitle === "GTC검토") { + if (!currentTitle || currentTitle === "GTC검토") { form.setValue("title", "CP검토") } // 법무검토 전용 필드들 초기화 @@ -173,20 +171,14 @@ export function RequestReviewDialog({ form.setValue("shipownerOrderer", "") form.setValue("projectType", "") form.setValue("governingLaw", "") - - setIsTitleOtherMode(false) // 기타 모드 해제 - setCustomTitle("") // 커스텀 제목 초기화 - } else { + setIsCustomTitle(false) // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) const currentTitle = form.getValues("title") - if (currentTitle === "기타" || !currentTitle || currentTitle === "CP검토") { + if (!currentTitle || currentTitle === "CP검토") { form.setValue("title", "GTC검토") } form.setValue("isPublic", false) - - setIsTitleOtherMode(false) // 기타 모드 해제 - setCustomTitle("") // 커스텀 제목 초기화 } }, [reviewDepartment, form]) @@ -195,7 +187,7 @@ export function RequestReviewDialog({ if (inquiryType) { // 계약서 종류 초기화 (옵션이 달라지므로) form.setValue("contractType", "") - + // 조건에 맞지 않는 필드들 초기화 if (!isDomesticContractFieldsActive) { form.setValue("contractCounterparty", "") @@ -203,11 +195,11 @@ export function RequestReviewDialog({ form.setValue("contractPeriod", "") form.setValue("contractAmount", "") } - + if (!isFactualRelationActive) { form.setValue("factualRelation", "") } - + if (!isOverseasFieldsActive) { form.setValue("projectNumber", "") form.setValue("shipownerOrderer", "") @@ -235,15 +227,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 +255,8 @@ export function RequestReviewDialog({ // 폼 리셋 함수 const handleReset = () => { const defaultDepartment = getDefaultReviewDepartment() + setIsCustomTitle(false) // 추가 + form.reset({ dueDate: "", assignee: "", @@ -274,8 +268,6 @@ export function RequestReviewDialog({ }) setAttachments([]) setEditorContent("") - setIsTitleOtherMode(false) // 기타 모드 리셋 - setCustomTitle("") // 커스텀 제목 리셋 } // 다이얼로그 닫기 핸들러 @@ -435,7 +427,7 @@ export function RequestReviewDialog({ </div> <Form {...form}> - <form + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0" > @@ -576,76 +568,73 @@ export function RequestReviewDialog({ /> )} - {/* 제목 필드 수정 */} - <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> - )} - /> + {/* 제목 - 조건부 렌더링 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + {!isCustomTitle ? ( + // Select 모드 + <Select + onValueChange={(value) => { + if (value === "기타") { + setIsCustomTitle(true) + field.onChange("") // 빈 값으로 초기화 + } else { + field.onChange(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) + setIsCustomTitle(false) // 상태 초기화 + }} + className="h-6 text-xs" + > + 선택 모드로 돌아가기 + </Button> + </div> + <FormControl> + <Input + placeholder="제목을 직접 입력하세요" + value={field.value} + onChange={(e) => field.onChange(e.target.value)} + autoFocus + /> + </FormControl> + </div> + )} + <FormMessage /> + </FormItem> + )} +/> + {/* 준법문의 전용 필드들 */} {reviewDepartment === "준법문의" && ( <FormField @@ -931,7 +920,7 @@ export function RequestReviewDialog({ </span> </label> </div> - + {/* 선택된 파일 목록 */} {attachments.length > 0 && ( <div className="space-y-2"> @@ -967,8 +956,8 @@ export function RequestReviewDialog({ > 취소 </Button> - <Button - type="submit" + <Button + type="submit" disabled={isSubmitting} className="bg-blue-600 hover:bg-blue-700" > |
