summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-20 10:25:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-20 10:25:41 +0000
commitb75b1cd920efd61923f7b2dbc4c49987b7b0c4e1 (patch)
tree9e4195e697df6df21b5896b0d33acc97d698b4a7 /lib
parent4df8d72b79140919c14df103b45bbc8b1afa37c2 (diff)
(최겸) 구매 입찰 수정
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/service.ts135
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx4
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx4
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx337
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx4
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx57
-rw-r--r--lib/bidding/list/biddings-table.tsx1
-rw-r--r--lib/bidding/list/biddings-transmission-dialog.tsx34
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx4
-rw-r--r--lib/bidding/pre-quote/service.ts78
-rw-r--r--lib/bidding/selection/actions.ts156
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx4
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx46
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx22
-rw-r--r--lib/bidding/service.ts84
-rw-r--r--lib/bidding/validation.ts2
-rw-r--r--lib/bidding/vendor/partners-bidding-attachments-dialog.tsx9
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx81
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx2
19 files changed, 425 insertions, 639 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 39bf0c46..d0f8070f 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1505,6 +1505,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
respondedAt: biddingCompanies.respondedAt,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
isWinner: biddingCompanies.isWinner,
isAttendingMeeting: biddingCompanies.isAttendingMeeting,
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
@@ -1624,6 +1625,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
hasSpecificationMeeting: biddings.hasSpecificationMeeting,
// 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리
paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
@@ -1811,37 +1813,37 @@ export async function submitPartnerResponse(
// 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지)
}
- // 스냅샷은 임시저장/최종제출 관계없이 항상 생성
- if (response.prItemQuotations && response.prItemQuotations.length > 0) {
- // 기존 스냅샷 조회
- const existingCompany = await tx
- .select({ quotationSnapshots: biddingCompanies.quotationSnapshots })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, biddingCompanyId))
- .limit(1)
-
- const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || []
-
- // 새로운 스냅샷 생성
- const newSnapshot = {
- id: Date.now().toString(), // 고유 ID
- round: existingSnapshots.length + 1, // 차수
- submittedAt: new Date().toISOString(),
- totalAmount: response.finalQuoteAmount,
- currency: 'KRW',
- isFinalSubmission: !!response.isFinalSubmission,
- items: response.prItemQuotations.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice,
- bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate,
- technicalSpecification: item.technicalSpecification
- }))
- }
-
- // 스냅샷 배열에 추가
- companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot]
- }
+ // // 스냅샷은 임시저장/최종제출 관계없이 항상 생성
+ // if (response.prItemQuotations && response.prItemQuotations.length > 0) {
+ // // 기존 스냅샷 조회
+ // const existingCompany = await tx
+ // .select({ quotationSnapshots: biddingCompanies.quotationSnapshots })
+ // .from(biddingCompanies)
+ // .where(eq(biddingCompanies.id, biddingCompanyId))
+ // .limit(1)
+
+ // const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || []
+
+ // // 새로운 스냅샷 생성
+ // const newSnapshot = {
+ // id: Date.now().toString(), // 고유 ID
+ // round: existingSnapshots.length + 1, // 차수
+ // submittedAt: new Date().toISOString(),
+ // totalAmount: response.finalQuoteAmount,
+ // currency: 'KRW',
+ // isFinalSubmission: !!response.isFinalSubmission,
+ // items: response.prItemQuotations.map(item => ({
+ // prItemId: item.prItemId,
+ // bidUnitPrice: item.bidUnitPrice,
+ // bidAmount: item.bidAmount,
+ // proposedDeliveryDate: item.proposedDeliveryDate,
+ // technicalSpecification: item.technicalSpecification
+ // }))
+ // }
+
+ // // 스냅샷 배열에 추가
+ // companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot]
+ // }
}
await tx
@@ -2342,47 +2344,40 @@ export async function deleteBiddingDocument(documentId: number, biddingId: numbe
}
}
-// 협력업체용 발주처 문서 조회 (캐시 적용)
+// 협력업체용 발주처 문서 조회 (협력업체용 첨부파일만)
export async function getBiddingDocumentsForPartners(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- const documents = await db
- .select({
- id: biddingDocuments.id,
- biddingId: biddingDocuments.biddingId,
- companyId: biddingDocuments.companyId,
- documentType: biddingDocuments.documentType,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.biddingId, biddingId),
- sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만
- eq(biddingDocuments.isPublic, true) // 공개 문서만
- )
- )
- .orderBy(desc(biddingDocuments.uploadedAt))
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ biddingId: biddingDocuments.biddingId,
+ companyId: biddingDocuments.companyId,
+ documentType: biddingDocuments.documentType,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'company_proposal'), // 협력업체용 첨부파일만
+ sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만
+ eq(biddingDocuments.isPublic, true) // 공개 문서만
+ )
+ )
+ .orderBy(desc(biddingDocuments.uploadedAt))
- return documents
- } catch (error) {
- console.error('Failed to get bidding documents for partners:', error)
- return []
- }
- },
- [`bidding-documents-partners-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'bidding-documents']
- }
- )()
+ return documents
+ } catch (error) {
+ console.error('Failed to get bidding documents for partners:', error)
+ return []
+ }
}
// =================================================
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 6e5481f4..5bc85fdb 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
@@ -22,7 +22,7 @@ interface BiddingDetailVendorEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
- biddingAwardCount?: string // 낙찰수 정보 추가
+ biddingAwardCount?: string // 낙찰업체 수 정보 추가
biddingStatus?: string // 입찰 상태 정보 추가
allVendors?: QuotationVendor[] // 전체 벤더 목록 추가
}
@@ -55,7 +55,7 @@ export function BiddingDetailVendorEditDialog({
// vendor가 변경되면 폼 데이터 업데이트
React.useEffect(() => {
if (vendor) {
- // 낙찰수가 단수인 경우 발주비율을 100%로 자동 설정
+ // 낙찰업체 수가 단수인 경우 발주비율을 100%로 자동 설정
const defaultAwardRatio = biddingAwardCount === 'single' ? 100 : (vendor.awardRatio || 0)
setFormData({
awardRatio: defaultAwardRatio,
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 34ee690f..53fe05f9 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -286,14 +286,14 @@ export function BiddingDetailVendorToolbarActions({
bidding.status === 'bidding_disposal') && (
<div className="h-4 w-px bg-border mx-1" />
)}
- <Button
+ {/* <Button
variant="outline"
size="sm"
onClick={handleDocumentUpload}
>
<FileText className="mr-2 h-4 w-4" />
입찰문서 등록
- </Button>
+ </Button> */}
</div>
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
index ffb1fcb3..582622d9 100644
--- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
@@ -33,7 +33,6 @@ import {
} from 'lucide-react'
import { getExistingBasicContractsForBidding } from '../../pre-quote/service'
import { getActiveContractTemplates } from '../../service'
-import { getVendorContacts } from '@/lib/vendors/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
import { SelectTrigger } from '@/components/ui/select'
@@ -269,47 +268,23 @@ export function BiddingInvitationDialog({
}));
setSelectedContracts(initialSelected);
- // 벤더 담당자 정보 병렬로 가져오기
- const vendorContactsPromises = selectedVendors.map(vendor =>
- getVendorContacts({
- page: 1,
- perPage: 100,
- flags: [],
- sort: [],
- filters: [],
- joinOperator: 'and',
- search: '',
- contactName: '',
- contactPosition: '',
- contactEmail: '',
- contactPhone: ''
- }, vendor.vendorId)
- .then(result => ({
- vendorId: vendor.vendorId,
- contacts: (result.data || []).map(contact => ({
- id: contact.id,
- contactName: contact.contactName,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone,
- contactPosition: contact.contactPosition,
- contactDepartment: contact.contactDepartment
- }))
- }))
- .catch(() => ({
- vendorId: vendor.vendorId,
- contacts: []
- }))
- );
-
- const vendorContactsResults = await Promise.all(vendorContactsPromises);
- const vendorContactsMap = new Map(vendorContactsResults.map(result => [result.vendorId, result.contacts]));
+ // 담당자 정보는 selectedVendors에 이미 포함되어 있음
// vendorData 초기화 (담당자 정보 포함)
const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => {
const hasExistingContract = typedContracts.some((ec) =>
ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
);
- const vendorContacts = vendorContactsMap.get(vendor.vendorId) || [];
+
+ // contacts 정보가 이미 selectedVendors에 포함되어 있음
+ const vendorContacts = (vendor.contacts || []).map(contact => ({
+ id: contact.id,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactNumber,
+ contactPosition: null,
+ contactDepartment: null
+ }));
// 주 수신자 기본값: 벤더의 기본 이메일 (vendorEmail)
const defaultEmail = vendor.vendorEmail || (vendorContacts.length > 0 ? vendorContacts[0].contactEmail : '');
@@ -569,296 +544,6 @@ export function BiddingInvitationDialog({
)}
{/* 대상 업체 정보 - 테이블 형식 */}
- <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" />
- 초대 대상 업체 ({vendorData.length})
- </div>
- <Badge variant="outline" className="flex items-center gap-1">
- <Users className="h-3 w-3" />
- 총 {totalRecipientCount}명
- </Badge>
- </div>
-
- {vendorData.length === 0 ? (
- <div className="text-center py-6 text-muted-foreground border rounded-lg">
- 초대 가능한 업체가 없습니다.
- </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>
- {vendorData.map((vendor, index) => {
- const allContacts = vendor.contacts || [];
- const allEmails = [
- // 벤더의 기본 이메일을 첫 번째로 표시
- ...(vendor.vendorEmail ? [{
- value: vendor.vendorEmail,
- label: `${vendor.vendorEmail}`,
- email: vendor.vendorEmail,
- type: 'vendor' as const
- }] : []),
- // 담당자 이메일들
- ...allContacts.map(c => ({
- value: c.contactEmail,
- label: `${c.contactName} ${c.contactPosition ? `(${c.contactPosition})` : ''}`,
- email: c.contactEmail,
- type: 'contact' as const
- })),
- // 커스텀 이메일들
- ...vendor.customEmails.map(c => ({
- value: c.email,
- label: c.name || c.email,
- email: c.email,
- type: 'custom' as const
- }))
- ];
-
- 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 gap-1">
- <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>
- </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 || vendor.vendorCode}
- </Badge>
- </div>
- </div>
- </td>
- <td className="p-2">
- <Select
- value={vendor.selectedMainEmail}
- onValueChange={(value) => updateVendor(vendor.vendorId, { selectedMainEmail: value })}
- >
- <SelectTrigger className="h-7 text-xs w-[200px]">
- <SelectValue placeholder="선택하세요">
- {selectedMainEmailInfo && (
- <div className="flex items-center gap-1">
- {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 === '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">
- <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>
- {vendor.customEmails.length > 0 && (
- <Badge variant="secondary" 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 />
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 40c7f271..36abd03c 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -122,14 +122,14 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
// ░░░ 프로젝트명 ░░░
{
accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 No." />,
cell: ({ row }) => (
<div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
{row.original.projectName || '-'}
</div>
),
size: 150,
- meta: { excelHeader: "프로젝트명" },
+ meta: { excelHeader: "프로젝트 No." },
},
// ░░░ 입찰명 ░░░
{
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 0cb87b11..3f65f559 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -80,26 +80,12 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
.getFilteredSelectedRowModel()
.rows
.map(row => row.original)
- }, [table])
+ }, [table.getFilteredSelectedRowModel().rows])
// 업체선정이 완료된 입찰만 전송 가능
- const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected'
-
- const handleExport = async () => {
- try {
- setIsExporting(true)
- await exportTableToExcel(table, {
- filename: "biddings",
- excludeColumns: ["select", "actions"],
- })
- toast.success("입찰 목록이 성공적으로 내보내졌습니다.")
- } catch {
- toast.error("내보내기 중 오류가 발생했습니다.")
- } finally {
- setIsExporting(false)
- }
- }
-
+ const canTransmit = true
+ console.log(canTransmit, 'canTransmit')
+ console.log(selectedBiddings, 'selectedBiddings')
return (
<>
@@ -121,41 +107,6 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
<Send className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">전송하기</span>
</Button>
-
- {/* 개찰 (입찰 오픈) */}
- {/* {openEligibleBiddings.length > 0 && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBiddingOpen}
- >
- <Gavel className="mr-2 h-4 w-4" />
- 개찰 ({openEligibleBiddings.length})
- </Button>
- )} */}
-
- {/* Export */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={isExporting}
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isExporting ? "내보내는 중..." : "Export"}
- </span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExport} disabled={isExporting}>
- <FileSpreadsheet className="mr-2 size-4" />
- <span>입찰 목록 내보내기</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
</div>
{/* 전송 다이얼로그 */}
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 89b6260c..35d57726 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -128,6 +128,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableMultiRowSelection: false,
initialState: {
sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
diff --git a/lib/bidding/list/biddings-transmission-dialog.tsx b/lib/bidding/list/biddings-transmission-dialog.tsx
index de28bf54..7eb7ffd1 100644
--- a/lib/bidding/list/biddings-transmission-dialog.tsx
+++ b/lib/bidding/list/biddings-transmission-dialog.tsx
@@ -92,6 +92,40 @@ export function TransmissionDialog({ open, onOpenChange, bidding, userId }: Tran
if (!bidding) return null
+ // 업체선정이 완료되지 않은 경우 에러 표시
+ if (bidding.status !== 'vendor_selected') {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[400px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2 text-red-600">
+ <Send className="w-5 h-5" />
+ 전송 불가
+ </DialogTitle>
+ <DialogDescription>
+ 업체선정이 완료된 입찰만 전송할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="py-4">
+ <div className="text-center">
+ <p className="text-sm text-muted-foreground">
+ 현재 상태: <span className="font-medium">{bidding.status}</span>
+ </p>
+ <p className="text-xs text-muted-foreground mt-2">
+ 업체선정이 완료된 후 다시 시도해주세요.
+ </p>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button onClick={() => onOpenChange(false)}>
+ 확인
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
const handleToContract = async () => {
try {
setIsLoading(true)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index ff68e739..2f458873 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -1438,11 +1438,11 @@ export function CreateBiddingDialog() {
name="awardCount"
render={({ field }) => (
<FormItem>
- <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel>
+ <FormLabel>낙찰업체 수 <span className="text-red-500">*</span></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="낙찰업체 수 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 19b418ae..81daf506 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -1,7 +1,7 @@
'use server'
import db from '@/db/db'
-import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { biddingCompanies, biddingCompaniesContacts, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
import { basicContractTemplates } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
import { users } from '@/db/schema'
@@ -1565,10 +1565,11 @@ export async function getExistingBasicContractsForBidding(biddingId: number) {
}
}
-// 선정된 업체들 조회 (서버 액션)
+// 입찰 참여 업체들 조회 (벤더와 담당자 정보 포함)
export async function getSelectedVendorsForBidding(biddingId: number) {
try {
- const selectedCompanies = await db
+ // 1. 입찰에 참여하는 모든 업체 조회
+ const companies = await db
.select({
id: biddingCompanies.id,
companyId: biddingCompanies.companyId,
@@ -1579,37 +1580,66 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
contactPerson: biddingCompanies.contactPerson,
contactEmail: biddingCompanies.contactEmail,
biddingId: biddingCompanies.biddingId,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 2. 각 업체의 담당자 정보 조회
+ const vendorsWithContacts = await Promise.all(
+ companies.map(async (company) => {
+ let contacts: any[] = []
+
+ if (company.companyId) {
+ // biddingCompaniesContacts에서 담당자 조회
+ const contactsResult = await db
+ .select({
+ id: biddingCompaniesContacts.id,
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, company.companyId)
+ )
+ )
+
+ contacts = contactsResult
+ }
+
+ return {
+ vendorId: company.companyId,
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id,
+ biddingId: company.biddingId,
+ isPreQuoteSelected: company.isPreQuoteSelected,
+ ndaYn: true,
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true,
+ contacts: contacts // 담당자 목록 추가
+ }
+ })
+ )
return {
success: true,
- vendors: selectedCompanies.map(company => ({
- vendorId: company.companyId, // 실제 vendor ID
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorEmail: company.companyEmail,
- vendorCountry: company.companyCountry || '대한민국',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id, // biddingCompany ID
- biddingId: company.biddingId,
- ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
- generalGtcYn: true,
- projectGtcYn: true,
- agreementYn: true
- }))
+ vendors: vendorsWithContacts
}
} catch (error) {
- console.error('선정된 업체 조회 실패:', error)
+ console.error('입찰 참여 업체 조회 실패:', error)
return {
success: false,
- error: '선정된 업체 조회에 실패했습니다.',
+ error: '입찰 참여 업체 조회에 실패했습니다.',
vendors: []
}
}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts
index 16b2c083..0d2a8a75 100644
--- a/lib/bidding/selection/actions.ts
+++ b/lib/bidding/selection/actions.ts
@@ -115,20 +115,17 @@ export async function saveSelectionResult(data: SaveSelectionResultData) {
// 견적 히스토리 조회
export async function getQuotationHistory(biddingId: number, vendorId: number) {
try {
- // biddingCompanies에서 해당 벤더의 스냅샷 데이터 조회
- const companyData = await db
+ // 현재 bidding의 biddingNumber와 originalBiddingNumber 조회
+ const currentBiddingInfo = await db
.select({
- quotationSnapshots: biddingCompanies.quotationSnapshots
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber
})
- .from(biddingCompanies)
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, vendorId)
- ))
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
.limit(1)
- // 데이터 존재 여부 및 유효성 체크
- if (!companyData.length || !companyData[0]?.quotationSnapshots) {
+ if (!currentBiddingInfo.length) {
return {
success: true,
data: {
@@ -137,40 +134,62 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
}
}
- let snapshots = companyData[0].quotationSnapshots
+ const baseNumber = currentBiddingInfo[0].originalBiddingNumber || currentBiddingInfo[0].biddingNumber.split('-')[0]
- // quotationSnapshots가 JSONB 타입이므로 파싱이 필요할 수 있음
- if (typeof snapshots === 'string') {
- try {
- snapshots = JSON.parse(snapshots)
- } catch (parseError) {
- console.error('Failed to parse quotationSnapshots:', parseError)
- return {
- success: true,
- data: {
- history: []
- }
- }
+ // 동일한 originalBiddingNumber를 가진 모든 bidding 조회
+ const relatedBiddings = await db
+ .select({
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ targetPrice: biddings.targetPrice,
+ currency: biddings.currency,
+ createdAt: biddings.createdAt
+ })
+ .from(biddings)
+ .where(eq(biddings.originalBiddingNumber, baseNumber))
+ .orderBy(biddings.createdAt)
+
+ // 각 bidding에 대한 벤더의 견적 정보 조회
+ const historyPromises = relatedBiddings.map(async (bidding) => {
+ const biddingCompanyData = await db
+ .select({
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ responseSubmittedAt: biddingCompanies.responseSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission
+ })
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, bidding.id),
+ eq(biddingCompanies.companyId, vendorId)
+ ))
+ .limit(1)
+
+ if (!biddingCompanyData.length || !biddingCompanyData[0].finalQuoteAmount || !biddingCompanyData[0].responseSubmittedAt) {
+ return null
}
- }
- // snapshots가 배열인지 확인
- if (!Array.isArray(snapshots)) {
- console.error('quotationSnapshots is not an array:', typeof snapshots)
return {
- success: true,
- data: {
- history: []
- }
+ biddingId: bidding.id,
+ biddingNumber: bidding.biddingNumber,
+ finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount,
+ responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt,
+ isFinalSubmission: biddingCompanyData[0].isFinalSubmission,
+ targetPrice: bidding.targetPrice,
+ currency: bidding.currency
}
- }
+ })
- // PR 항목 정보 조회 (스냅샷의 prItemId로 매핑하기 위해)
- const prItemIds = snapshots.flatMap(snapshot =>
- snapshot.items?.map((item: any) => item.prItemId) || []
- ).filter((id: number, index: number, arr: number[]) => arr.indexOf(id) === index)
+ const historyData = (await Promise.all(historyPromises)).filter(Boolean)
+
+ // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등)
+ const sortedHistory = historyData.sort((a, b) => {
+ const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0
+ const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0
+ return aSuffix - bSuffix
+ })
- const prItems = prItemIds.length > 0 ? await db
+ // PR 항목 정보 조회 (현재 bidding 기준)
+ const prItems = await db
.select({
id: prItemsForBidding.id,
itemNumber: prItemsForBidding.itemNumber,
@@ -180,53 +199,54 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate
})
.from(prItemsForBidding)
- .where(sql`${prItemsForBidding.id} IN ${prItemIds}`) : []
+ .where(eq(prItemsForBidding.biddingId, biddingId))
- // PR 항목을 Map으로 변환하여 빠른 조회를 위해
- const prItemMap = new Map(prItems.map(item => [item.id, item]))
-
- // bidding 정보 조회 (targetPrice, currency)
- const biddingInfo = await db
- .select({
- targetPrice: biddings.targetPrice,
- currency: biddings.currency
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
+ // 각 히스토리 항목에 대한 PR 아이템 견적 조회
+ const history = await Promise.all(sortedHistory.map(async (item, index) => {
+ // 각 bidding에 대한 PR 아이템 견적 조회
+ const prItemBids = await db
+ .select({
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ eq(companyPrItemBids.biddingId, item!.biddingId),
+ eq(companyPrItemBids.companyId, vendorId)
+ ))
- const targetPrice = biddingInfo[0]?.targetPrice ? parseFloat(biddingInfo[0].targetPrice.toString()) : null
- const currency = biddingInfo[0]?.currency || 'KRW'
+ const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null
+ const totalAmount = parseFloat(item!.finalQuoteAmount.toString())
- // 스냅샷 데이터를 변환
- const history = snapshots.map((snapshot: any) => {
const vsTargetPrice = targetPrice && targetPrice > 0
- ? ((snapshot.totalAmount - targetPrice) / targetPrice) * 100
+ ? ((totalAmount - targetPrice) / targetPrice) * 100
: 0
- const items = snapshot.items?.map((item: any) => {
- const prItem = prItemMap.get(item.prItemId)
+ const items = prItemBids.map(bid => {
+ const prItem = prItems.find(p => p.id === bid.prItemId)
return {
- itemCode: prItem?.itemNumber || `ITEM${item.prItemId}`,
+ itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`,
itemName: prItem?.itemInfo || '품목 정보 없음',
quantity: prItem?.quantity || 0,
unit: prItem?.quantityUnit || 'EA',
- unitPrice: item.bidUnitPrice,
- totalPrice: item.bidAmount,
- deliveryDate: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date()
+ unitPrice: parseFloat(bid.bidUnitPrice.toString()),
+ totalPrice: parseFloat(bid.bidAmount.toString()),
+ deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date()
}
- }) || []
+ })
return {
- id: snapshot.id,
- round: snapshot.round,
- submittedAt: new Date(snapshot.submittedAt),
- totalAmount: snapshot.totalAmount,
- currency: snapshot.currency || currency,
+ id: item!.biddingId,
+ round: index + 1, // 1차, 2차, 3차...
+ submittedAt: new Date(item!.responseSubmittedAt),
+ totalAmount,
+ currency: item!.currency || 'KRW',
vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)),
items
}
- })
+ }))
return {
success: true,
diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx
index f6f0bc69..8864e7db 100644
--- a/lib/bidding/selection/bidding-info-card.tsx
+++ b/lib/bidding/selection/bidding-info-card.tsx
@@ -65,9 +65,9 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) {
<label className="text-sm font-medium text-muted-foreground">
진행상태
</label>
- <Badge variant="secondary">
+ <div className="text-sm font-medium">
{biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status}
- </Badge>
+ </div>
</div>
{/* 입찰담당자 */}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 8351a0dd..9efa849b 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -221,20 +221,20 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
},
// ░░░ 참여업체수 ░░░
- {
- id: "participantCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
- cell: ({ row }) => {
- const count = row.original.participantCount || 0
- return (
- <div className="flex items-center gap-1">
- <span className="text-sm font-medium">{count}</span>
- </div>
- )
- },
- size: 100,
- meta: { excelHeader: "참여업체수" },
- },
+ // {
+ // id: "participantCount",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
+ // cell: ({ row }) => {
+ // const count = row.original.participantCount || 0
+ // return (
+ // <div className="flex items-center gap-1">
+ // <span className="text-sm font-medium">{count}</span>
+ // </div>
+ // )
+ // },
+ // size: 100,
+ // meta: { excelHeader: "참여업체수" },
+ // },
// ═══════════════════════════════════════════════════════════════
@@ -256,24 +256,6 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- {/* {row.original.status === 'bidding_opened' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "close_bidding" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 입찰마감
- </DropdownMenuItem>
- </>
- )} */}
- {row.original.status === 'bidding_closed' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "evaluate_bidding" })}>
- <DollarSign className="mr-2 h-4 w-4" />
- 평가하기
- </DropdownMenuItem>
- </>
- )}
</DropdownMenuContent>
</DropdownMenu>
),
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index 9545fe09..c3990e7b 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -19,6 +19,7 @@ import {
contractTypeLabels,
} from "@/db/schema"
import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { toast } from "@/hooks/use-toast"
type BiddingSelectionItem = {
id: number
@@ -83,17 +84,16 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
- break
- case "close_bidding":
- // 입찰마감 (추후 구현)
- console.log('입찰마감:', rowAction.row.original)
- break
- case "evaluate_bidding":
- // 평가하기 (추후 구현)
- console.log('평가하기:', rowAction.row.original)
- break
- default:
+ // 입찰평가중일때만 상세보기 가능
+ if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
+ } else {
+ toast({
+ title: '접근 제한',
+ description: '입찰평가중이 아닙니다.',
+ variant: 'destructive',
+ })
+ }
break
}
}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 14bed105..2474d464 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -865,8 +865,12 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
meetingFiles: File[]
} | null
+ // 첨부파일들 (선택사항)
+ attachments?: File[]
+ vendorAttachments?: File[]
+
// noticeType 필드 명시적 추가 (CreateBiddingSchema에 포함되어 있지만 타입 추론 문제 해결)
- noticeType?: 'standard' | 'facility' | 'unit_price'
+ noticeType: 'standard' | 'facility' | 'unit_price'
// PR 아이템들 (선택사항)
prItems?: Array<{
@@ -1420,10 +1424,80 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
}
}
}
-
+
+ // 4. 첨부파일들 저장 (있는 경우)
+ if (input.attachments && input.attachments.length > 0) {
+ for (const file of input.attachments) {
+ try {
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${biddingId}/attachments/shi`,
+ originalName: file.name,
+ userId
+ })
+
+ if (saveResult.success) {
+ await tx.insert(biddingDocuments).values({
+ biddingId,
+ documentType: 'evaluation_doc', // SHI용 문서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName!,
+ fileSize: saveResult.fileSize!,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!,
+ title: `SHI용 첨부파일 - ${file.name}`,
+ description: 'SHI용 첨부파일',
+ isPublic: true, // 발주처 문서이므로 공개
+ isRequired: false,
+ uploadedBy: userName,
+ })
+ } else {
+ console.error(`Failed to save SHI attachment file: ${file.name}`, saveResult.error)
+ }
+ } catch (error) {
+ console.error(`Error saving SHI attachment file: ${file.name}`, error)
+ }
+ }
+ }
+
+ // Vendor 첨부파일들 저장 (있는 경우)
+ if (input.vendorAttachments && input.vendorAttachments.length > 0) {
+ for (const file of input.vendorAttachments) {
+ try {
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${biddingId}/attachments/vendor`,
+ originalName: file.name,
+ userId
+ })
+
+ if (saveResult.success) {
+ await tx.insert(biddingDocuments).values({
+ biddingId,
+ documentType: 'company_proposal', // 협력업체용 문서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName!,
+ fileSize: saveResult.fileSize!,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!,
+ title: `협력업체용 첨부파일 - ${file.name}`,
+ description: '협력업체용 첨부파일',
+ isPublic: true, // 발주처 문서이므로 공개
+ isRequired: false,
+ uploadedBy: userName,
+ })
+ } else {
+ console.error(`Failed to save vendor attachment file: ${file.name}`, saveResult.error)
+ }
+ } catch (error) {
+ console.error(`Error saving vendor attachment file: ${file.name}`, error)
+ }
+ }
+ }
+
// 캐시 무효화
revalidatePath('/evcp/bid')
-
+
return {
success: true,
message: '입찰이 성공적으로 생성되었습니다.',
@@ -3200,13 +3274,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
}
}
+ //원입찰번호는 -0n 제외하고 저장
+ const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0]
// 3. 새로운 입찰 생성 (기존 정보 복제)
const [newBidding] = await tx
.insert(biddings)
.values({
biddingNumber: newBiddingNumber,
- originalBiddingNumber: existingBidding.biddingNumber, // 원입찰번호 설정
+ originalBiddingNumber: originalBiddingNumber, // 원입찰번호 설정
revision: 0,
biddingSourceType: existingBidding.biddingSourceType,
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 5cf296e1..f70e498e 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -79,7 +79,7 @@ export const createBiddingSchema = z.object({
}),
biddingTypeCustom: z.string().optional(),
awardCount: z.enum(biddings.awardCount.enumValues, {
- required_error: "낙찰수를 선택해주세요"
+ required_error: "낙찰업체 수를 선택해주세요"
}),
// ✅ 가격 정보 (조회용으로 readonly 처리)
diff --git a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
index f5206c71..14d42a46 100644
--- a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
@@ -57,12 +57,13 @@ const documentTypes = [
{ value: 'specification', label: '사양서' },
{ value: 'specification_meeting', label: '사양설명회' },
{ value: 'contract_draft', label: '계약서 초안' },
+ { value: 'company_proposal', label: '협력업체용 첨부파일' },
{ value: 'financial_doc', label: '재무 관련 문서' },
{ value: 'technical_doc', label: '기술 관련 문서' },
{ value: 'certificate', label: '인증서류' },
{ value: 'pr_document', label: 'PR 문서' },
{ value: 'spec_document', label: 'SPEC 문서' },
- { value: 'evaluation_doc', label: '평가 관련 문서' },
+ { value: 'evaluation_doc', label: 'SHI용 첨부파일' },
{ value: 'bid_attachment', label: '입찰 첨부파일' },
{ value: 'other', label: '기타' }
]
@@ -183,7 +184,7 @@ export function PartnersBiddingAttachmentsDialog({
<TableHead>파일명</TableHead>
<TableHead>크기</TableHead>
<TableHead>업로드일</TableHead>
- <TableHead>작성자</TableHead>
+ {/* <TableHead>작성자</TableHead> */}
<TableHead className="w-24">작업</TableHead>
</TableRow>
</TableHeader>
@@ -212,12 +213,12 @@ export function PartnersBiddingAttachmentsDialog({
{new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
</div>
</TableCell>
- <TableCell className="text-sm text-gray-500">
+ {/* <TableCell className="text-sm text-gray-500">
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
{doc.uploadedBy}
</div>
- </TableCell>
+ </TableCell> */}
<TableCell>
<Button
variant="outline"
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 66c90eaf..0215bcb6 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -94,6 +94,7 @@ interface BiddingDetail {
responseSubmittedAt: Date | null
priceAdjustmentResponse: boolean | null // 연동제 적용 여부
isPreQuoteParticipated: boolean | null // 사전견적 참여 여부
+ isPriceAdjustmentApplicableQuestion: boolean // 연동제 적용요건 문의 여부
}
interface PrItem {
@@ -485,7 +486,21 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
// 임시 저장 핸들러
const handleSaveDraft = async () => {
if (!biddingDetail || !userId) return
-
+
+ // 제출 마감일 체크
+ if (biddingDetail.submissionEndDate) {
+ const now = new Date()
+ const deadline = new Date(biddingDetail.submissionEndDate)
+ if (deadline < now) {
+ toast({
+ title: "접근 제한",
+ description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ }
+
// 입찰 마감 상태 체크
const biddingStatus = biddingDetail.status
const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
@@ -606,6 +621,21 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const handleSubmitResponse = () => {
if (!biddingDetail) return
+
+ // 제출 마감일 체크
+ if (biddingDetail.submissionEndDate) {
+ const now = new Date()
+ const deadline = new Date(biddingDetail.submissionEndDate)
+ if (deadline < now) {
+ toast({
+ title: "접근 제한",
+ description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ }
+
// 입찰 마감 상태 체크
const biddingStatus = biddingDetail.status
const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
@@ -661,6 +691,9 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
additionalProposals: responseData.additionalProposals,
isFinalSubmission, // 최종제출 여부 추가
+ // 연동제 데이터 추가 (연동제 적용요건 문의가 있는 경우만)
+ priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? responseData.priceAdjustmentResponse : undefined,
+ priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && responseData.priceAdjustmentResponse !== null ? priceAdjustmentForm : undefined,
prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({
prItemId: q.prItemId,
bidUnitPrice: q.bidUnitPrice,
@@ -781,7 +814,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div>
</div>
<div>
- <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label>
+ <Label className="text-sm font-medium text-muted-foreground">낙찰업체 수</Label>
<div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div>
</div>
<div>
@@ -816,7 +849,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
{/* 제출 마감일 D-day */}
- {/* {biddingDetail.submissionEndDate && (
+ {biddingDetail.submissionEndDate && (
<div className="pt-4 border-t">
<Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label>
{(() => {
@@ -826,13 +859,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
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'
+ 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">
@@ -866,7 +899,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
)
})()}
</div>
- )} */}
+ )}
{/* 일정 정보 */}
<div className="pt-4 border-t">
@@ -991,12 +1024,12 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
- <div>
+ {/* <div>
<Label className="text-muted-foreground">연동제 적용</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
<p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
</div>
- </div>
+ </div> */}
<div >
@@ -1110,8 +1143,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
/>
)}
- {/* 연동제 적용 여부 - SHI가 연동제를 요구하고, 사전견적에서 답변하지 않은 경우만 표시 */}
- {biddingConditions?.isPriceAdjustmentApplicable && biddingDetail.priceAdjustmentResponse === null && (
+ {/* 연동제 적용 여부 - 협력업체 별 연동제 적용요건 문의 여부에 따라 표시 */}
+ {biddingDetail.isPriceAdjustmentApplicableQuestion && (
<>
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<Label className="font-semibold text-base">연동제 적용 여부 *</Label>
@@ -1346,28 +1379,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</>
)}
- {/* 사전견적에서 이미 답변한 경우 - 읽기 전용으로 표시 */}
- {biddingDetail.priceAdjustmentResponse !== null && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">연동제 적용 정보 (사전견적 제출 완료)</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="p-4 bg-muted/30 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle className="w-5 h-5 text-green-600" />
- <span className="font-semibold">
- {biddingDetail.priceAdjustmentResponse ? '연동제 적용' : '연동제 미적용'}
- </span>
- </div>
- <p className="text-sm text-muted-foreground">
- 사전견적에서 이미 연동제 관련 정보를 제출하였습니다. 본입찰에서는 별도의 연동제 정보 입력이 필요하지 않습니다.
- </p>
- </div>
- </CardContent>
- </Card>
- )}
-
{/* 최종제출 체크박스 */}
{!biddingDetail.isFinalSubmission && (
<div className="flex items-center space-x-2 p-4 border rounded-lg bg-muted/30">
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index ba8efae6..ba47ce50 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -119,7 +119,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
// 첨부파일
columnHelper.display({
id: 'attachments',
- header: 'SHI 첨부파일',
+ header: '첨부파일',
cell: ({ row }) => {
const handleViewDocumentsClick = (e: React.MouseEvent) => {
e.stopPropagation()