summaryrefslogtreecommitdiff
path: root/lib/bidding/detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail')
-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
4 files changed, 80 insertions, 400 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 />