diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
| commit | f93493f68c9f368e10f1c3379f1c1384068e3b14 (patch) | |
| tree | a9dada58741750fa7ca6e04b210443ad99a6bccc /lib/bidding/detail/table | |
| parent | e832a508e1b3c531fb3e1b9761e18e1b55e3d76a (diff) | |
(대표님, 최겸) rfqLast, bidding, prequote
Diffstat (limited to 'lib/bidding/detail/table')
10 files changed, 875 insertions, 846 deletions
diff --git a/lib/bidding/detail/table/bidding-award-dialog.tsx b/lib/bidding/detail/table/bidding-award-dialog.tsx new file mode 100644 index 00000000..3ab883f2 --- /dev/null +++ b/lib/bidding/detail/table/bidding-award-dialog.tsx @@ -0,0 +1,259 @@ +'use client' + +import * as React from 'react' +import { useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import { Trophy, Building2, Calculator } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { getAwardedCompanies, awardBidding } from '@/lib/bidding/detail/service' +import { AwardSimpleFileUpload } from './components/award-simple-file-upload' + +interface BiddingAwardDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface AwardedCompany { + companyId: number + companyName: string | null + finalQuoteAmount: number + awardRatio: number +} + +export function BiddingAwardDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingAwardDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectionReason, setSelectionReason] = React.useState('') + const [awardedCompanies, setAwardedCompanies] = React.useState<AwardedCompany[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + // 낙찰된 업체 정보 로드 + React.useEffect(() => { + if (open) { + setIsLoading(true) + getAwardedCompanies(biddingId) + .then(companies => { + setAwardedCompanies(companies) + }) + .catch(error => { + console.error('Failed to load awarded companies:', error) + toast({ + title: '오류', + description: '낙찰 업체 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [open, biddingId, toast]) + + // 최종입찰가 계산 + const finalBidPrice = React.useMemo(() => { + return awardedCompanies.reduce((sum, company) => { + return sum + (company.finalQuoteAmount * company.awardRatio / 100) + }, 0) + }, [awardedCompanies]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!selectionReason.trim()) { + toast({ + title: '유효성 오류', + description: '낙찰 사유를 입력해주세요.', + variant: 'destructive', + }) + return + } + + if (awardedCompanies.length === 0) { + toast({ + title: '유효성 오류', + description: '낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await awardBidding(biddingId, selectionReason, 'current-user') + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + // 폼 초기화 + setSelectionReason('') + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Trophy className="w-5 h-5 text-yellow-600" /> + 낙찰 처리 + </DialogTitle> + <DialogDescription> + 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit}> + <div className="space-y-6"> + {/* 낙찰 업체 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="w-4 h-4" /> + 낙찰 업체 정보 + </CardTitle> + </CardHeader> + <CardContent> + {isLoading ? ( + <div className="text-center py-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">낙찰 업체 정보를 불러오는 중...</p> + </div> + ) : awardedCompanies.length > 0 ? ( + <div className="space-y-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead className="text-right">견적금액</TableHead> + <TableHead className="text-right">발주비율</TableHead> + <TableHead className="text-right">발주금액</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {awardedCompanies.map((company) => ( + <TableRow key={company.companyId}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <Badge variant="default" className="bg-green-600">낙찰</Badge> + {company.companyName} + </div> + </TableCell> + <TableCell className="text-right"> + {company.finalQuoteAmount.toLocaleString()}원 + </TableCell> + <TableCell className="text-right"> + {company.awardRatio}% + </TableCell> + <TableCell className="text-right font-semibold"> + {(company.finalQuoteAmount * company.awardRatio / 100).toLocaleString()}원 + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + {/* 최종입찰가 요약 */} + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="flex items-center gap-2"> + <Calculator className="w-5 h-5 text-blue-600" /> + <span className="font-semibold text-blue-800">최종입찰가</span> + </div> + <span className="text-xl font-bold text-blue-800"> + {finalBidPrice.toLocaleString()}원 + </span> + </div> + </div> + ) : ( + <div className="text-center py-8"> + <Trophy className="w-12 h-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">낙찰된 업체가 없습니다</p> + <p className="text-sm text-gray-400"> + 먼저 업체 수정 다이얼로그에서 발주비율을 산정해주세요. + </p> + </div> + )} + </CardContent> + </Card> + + {/* 낙찰 사유 */} + <div className="space-y-2"> + <Label htmlFor="selectionReason"> + 낙찰 사유 <span className="text-red-500">*</span> + </Label> + <Textarea + id="selectionReason" + placeholder="낙찰 사유를 상세히 입력해주세요..." + value={selectionReason} + onChange={(e) => setSelectionReason(e.target.value)} + rows={4} + className="resize-none" + /> + </div> + + {/* 첨부파일 */} + <AwardSimpleFileUpload + biddingId={biddingId} + userId="current-user" + readOnly={false} + /> + </div> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button + type="submit" + disabled={isPending || awardedCompanies.length === 0} + > + {isPending ? '처리 중...' : '낙찰 완료'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 50f0941e..91bea2f4 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -7,13 +7,11 @@ 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 { BiddingDetailSelectionReasonDialog } from './bidding-detail-selection-reason-dialog' interface BiddingDetailContentProps { bidding: Bidding quotationDetails: QuotationDetails | null quotationVendors: QuotationVendor[] - biddingCompanies: any[] prItems: any[] } @@ -21,13 +19,13 @@ export function BiddingDetailContent({ bidding, quotationDetails, quotationVendors, - biddingCompanies, prItems }: BiddingDetailContentProps) { const [dialogStates, setDialogStates] = React.useState({ items: false, targetPrice: false, - selectionReason: false + selectionReason: false, + award: false }) const [refreshTrigger, setRefreshTrigger] = React.useState(0) @@ -50,11 +48,11 @@ export function BiddingDetailContent({ biddingId={bidding.id} bidding={bidding} vendors={quotationVendors} - biddingCompanies={biddingCompanies} onRefresh={handleRefresh} onOpenItemsDialog={() => openDialog('items')} onOpenTargetPriceDialog={() => openDialog('targetPrice')} onOpenSelectionReasonDialog={() => openDialog('selectionReason')} + onOpenAwardDialog={() => openDialog('award')} onEdit={undefined} onDelete={undefined} onSelectWinner={undefined} @@ -74,13 +72,6 @@ export function BiddingDetailContent({ bidding={bidding} onSuccess={handleRefresh} /> - - <BiddingDetailSelectionReasonDialog - open={dialogStates.selectionReason} - onOpenChange={(open) => closeDialog('selectionReason')} - bidding={bidding} - onSuccess={handleRefresh} - /> </div> ) } diff --git a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx deleted file mode 100644 index 0e7ca364..00000000 --- a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx +++ /dev/null @@ -1,167 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { updateVendorSelectionReason } from '@/lib/bidding/detail/service' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingDetailSelectionReasonDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - bidding: Bidding - onSuccess: () => void -} - -export function BiddingDetailSelectionReasonDialog({ - open, - onOpenChange, - bidding, - onSuccess -}: BiddingDetailSelectionReasonDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompanyId, setSelectedCompanyId] = React.useState<number | null>(null) - const [selectionReason, setSelectionReason] = React.useState('') - - // 낙찰된 업체 정보 조회 (실제로는 bidding_companies에서 isWinner가 true인 업체를 조회해야 함) - React.useEffect(() => { - if (open) { - // TODO: 실제로는 낙찰된 업체 정보를 조회하여 selectedCompanyId를 설정 - setSelectedCompanyId(null) - setSelectionReason('') - } - }, [open]) - - const handleSave = () => { - if (!selectedCompanyId) { - toast({ - title: '유효성 오류', - description: '선정된 업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - - if (!selectionReason.trim()) { - toast({ - title: '유효성 오류', - description: '선정 사유를 입력해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - const result = await updateVendorSelectionReason( - bidding.id, - selectedCompanyId, - selectionReason, - 'current-user' // TODO: 실제 사용자 ID - ) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>업체 선정 사유</DialogTitle> - <DialogDescription> - 입찰번호: {bidding.biddingNumber} - 낙찰 업체 선정 사유 입력 - </DialogDescription> - </DialogHeader> - - <div className="space-y-6"> - {/* 낙찰 정보 */} - <div className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <div> - <Label htmlFor="biddingNumber">입찰번호</Label> - <div className="text-sm font-mono mt-1 p-2 bg-muted rounded"> - {bidding.biddingNumber} - </div> - </div> - <div> - <Label htmlFor="projectName">프로젝트명</Label> - <div className="text-sm mt-1 p-2 bg-muted rounded"> - {bidding.projectName || '-'} - </div> - </div> - </div> - </div> - - {/* 선정 업체 선택 */} - <div className="space-y-2"> - <Label htmlFor="selectedCompany">선정된 업체</Label> - <Select - value={selectedCompanyId?.toString() || ''} - onValueChange={(value) => setSelectedCompanyId(Number(value))} - > - <SelectTrigger> - <SelectValue placeholder="선정된 업체를 선택하세요" /> - </SelectTrigger> - <SelectContent> - {/* TODO: 실제로는 낙찰된 업체 목록을 조회하여 표시 */} - <SelectItem value="1">업체 A</SelectItem> - <SelectItem value="2">업체 B</SelectItem> - <SelectItem value="3">업체 C</SelectItem> - </SelectContent> - </Select> - </div> - - {/* 선정 사유 입력 */} - <div className="space-y-2"> - <Label htmlFor="selectionReason">선정 사유</Label> - <Textarea - id="selectionReason" - value={selectionReason} - onChange={(e) => setSelectionReason(e.target.value)} - placeholder="업체 선정 사유를 상세히 입력해주세요." - rows={6} - /> - <div className="text-sm text-muted-foreground"> - 선정 사유는 추후 검토 및 감사에 활용됩니다. 구체적인 선정 기준과 이유를 명확히 기재해주세요. - </div> - </div> - </div> - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleSave} disabled={isPending}> - 저장 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx index b9dd44dd..e2cf964b 100644 --- a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Bidding } from '@/db/schema' -import { QuotationDetails, updateTargetPrice } from '@/lib/bidding/detail/service' +import { QuotationDetails, updateTargetPrice, calculateAndUpdateTargetPrice, getPreQuoteData } from '@/lib/bidding/detail/service' import { Dialog, DialogContent, @@ -49,15 +49,69 @@ export function BiddingDetailTargetPriceDialog({ const [calculationCriteria, setCalculationCriteria] = React.useState( (bidding as any).targetPriceCalculationCriteria || '' ) + const [preQuoteData, setPreQuoteData] = React.useState<any>(null) + const [isAutoCalculating, setIsAutoCalculating] = React.useState(false) - // Dialog가 열릴 때 상태 초기화 + // Dialog가 열릴 때 상태 초기화 및 사전견적 데이터 로드 React.useEffect(() => { if (open) { setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0) setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '') + + // 사전견적 데이터 로드 + const loadPreQuoteData = async () => { + try { + const data = await getPreQuoteData(bidding.id) + setPreQuoteData(data) + } catch (error) { + console.error('Failed to load pre-quote data:', error) + } + } + loadPreQuoteData() } }, [open, bidding]) + // 자동 산정 함수 + const handleAutoCalculate = () => { + setIsAutoCalculating(true) + + startTransition(async () => { + try { + const result = await calculateAndUpdateTargetPrice( + bidding.id, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success && result.data) { + setTargetPrice(result.data.targetPrice) + setCalculationCriteria(result.data.criteria) + setPreQuoteData(result.data.preQuoteData) + + toast({ + title: '성공', + description: result.message, + }) + + onSuccess() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '오류', + description: '내정가 자동 산정에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsAutoCalculating(false) + } + }) + } + const handleSave = () => { // 필수값 검증 if (targetPrice <= 0) { @@ -121,6 +175,42 @@ export function BiddingDetailTargetPriceDialog({ </DialogHeader> <div className="space-y-4"> + {/* 사전견적 리스트 */} + {preQuoteData?.quotes && preQuoteData.quotes.length > 0 && ( + <div className="mb-4"> + <h4 className="text-sm font-medium mb-2">사전견적 현황</h4> + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead className="text-right">사전견적가</TableHead> + <TableHead className="text-right">제출일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {preQuoteData.quotes.map((quote: any) => ( + <TableRow key={quote.id}> + <TableCell className="font-medium"> + {quote.vendorName || `업체 ${quote.companyId}`} + </TableCell> + <TableCell className="text-right font-mono"> + {formatCurrency(Number(quote.preQuoteAmount))} + </TableCell> + <TableCell className="text-right text-sm text-muted-foreground"> + {quote.submittedAt + ? new Date(quote.submittedAt).toLocaleDateString('ko-KR') + : '-' + } + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + )} + <Table> <TableHeader> <TableRow> @@ -129,29 +219,43 @@ export function BiddingDetailTargetPriceDialog({ </TableRow> </TableHeader> <TableBody> - {/* 견적 통계 정보 */} - <TableRow> - <TableCell className="font-medium">예상액</TableCell> - <TableCell className="font-semibold"> - {quotationDetails?.estimatedPrice ? formatCurrency(quotationDetails.estimatedPrice) : '-'} - </TableCell> - </TableRow> - <TableRow> - <TableCell className="font-medium">최저견적가</TableCell> - <TableCell className="font-semibold text-green-600"> - {quotationDetails?.lowestQuote ? formatCurrency(quotationDetails.lowestQuote) : '-'} - </TableCell> - </TableRow> + {/* 사전견적 통계 정보 */} <TableRow> - <TableCell className="font-medium">평균견적가</TableCell> + <TableCell className="font-medium">사전견적 수</TableCell> <TableCell className="font-semibold"> - {quotationDetails?.averageQuote ? formatCurrency(quotationDetails.averageQuote) : '-'} + {preQuoteData?.quotationCount || 0}개 </TableCell> </TableRow> + {preQuoteData?.lowestQuote && ( + <TableRow> + <TableCell className="font-medium">최저 사전견적가</TableCell> + <TableCell className="font-semibold text-green-600"> + {formatCurrency(preQuoteData.lowestQuote)} + </TableCell> + </TableRow> + )} + {preQuoteData?.highestQuote && ( + <TableRow> + <TableCell className="font-medium">최고 사전견적가</TableCell> + <TableCell className="font-semibold text-blue-600"> + {formatCurrency(preQuoteData.highestQuote)} + </TableCell> + </TableRow> + )} + {preQuoteData?.averageQuote && ( + <TableRow> + <TableCell className="font-medium">평균 사전견적가</TableCell> + <TableCell className="font-semibold"> + {formatCurrency(preQuoteData.averageQuote)} + </TableCell> + </TableRow> + )} + + {/* 입찰 유형 */} <TableRow> - <TableCell className="font-medium">견적 수</TableCell> + <TableCell className="font-medium">입찰 유형</TableCell> <TableCell className="font-semibold"> - {quotationDetails?.quotationCount || 0}개 + {bidding.biddingType || '-'} </TableCell> </TableRow> @@ -184,17 +288,33 @@ export function BiddingDetailTargetPriceDialog({ </TableCell> <TableCell> <div className="space-y-2"> - <Input - id="targetPrice" - type="number" - value={targetPrice} - onChange={(e) => setTargetPrice(Number(e.target.value))} - placeholder="내정가를 입력하세요" - className="w-full" - /> + <div className="flex gap-2"> + <Input + id="targetPrice" + type="number" + value={targetPrice} + onChange={(e) => setTargetPrice(Number(e.target.value))} + placeholder="내정가를 입력하세요" + className="flex-1" + /> + <Button + type="button" + variant="outline" + onClick={handleAutoCalculate} + disabled={isAutoCalculating || isPending || !preQuoteData?.quotationCount} + className="whitespace-nowrap" + > + {isAutoCalculating ? '산정 중...' : '자동 산정'} + </Button> + </div> <div className="text-sm text-muted-foreground"> {targetPrice > 0 ? formatCurrency(targetPrice) : ''} </div> + {preQuoteData?.quotationCount === 0 && ( + <div className="text-xs text-orange-600"> + 사전견적 데이터가 없어 자동 산정이 불가능합니다. + </div> + )} </div> </TableCell> </TableRow> @@ -211,7 +331,7 @@ export function BiddingDetailTargetPriceDialog({ id="calculationCriteria" value={calculationCriteria} onChange={(e) => setCalculationCriteria(e.target.value)} - placeholder="내정가 산정 기준을 자세히 입력해주세요. (예: 최저견적가 대비 10% 상향 조정, 시장 평균가 고려 등)" + placeholder="내정가 산정 기준을 자세히 입력해주세요. 자동 산정 시 입찰유형에 따른 기준이 자동 설정됩니다." className="w-full min-h-[100px]" rows={4} /> @@ -228,7 +348,7 @@ export function BiddingDetailTargetPriceDialog({ <Button variant="outline" onClick={() => onOpenChange(false)}> 취소 </Button> - <Button onClick={handleSave} disabled={isPending}> + <Button onClick={handleSave} disabled={isPending || isAutoCalculating}> 저장 </Button> </DialogFooter> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 6f02497f..bb1d2c62 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -23,13 +23,17 @@ interface GetVendorColumnsProps { onDelete: (vendor: QuotationVendor) => void onSelectWinner: (vendor: QuotationVendor) => void onViewPriceAdjustment?: (vendor: QuotationVendor) => void + onSendBidding?: (vendor: QuotationVendor) => void + onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void } export function getBiddingDetailVendorColumns({ onEdit, onDelete, onSelectWinner, - onViewPriceAdjustment + onViewPriceAdjustment, + onSendBidding, + onUpdateParticipation }: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { return [ { @@ -66,13 +70,6 @@ export function getBiddingDetailVendorColumns({ ), }, { - accessorKey: 'contactPerson', - header: '담당자', - cell: ({ row }) => ( - <div className="text-sm">{row.original.contactPerson || '-'}</div> - ), - }, - { accessorKey: 'quotationAmount', header: '견적금액', cell: ({ row }) => ( @@ -82,15 +79,45 @@ export function getBiddingDetailVendorColumns({ ), }, { + accessorKey: 'biddingResult', + header: '입찰결과', + cell: ({ row }) => { + const isWinner = row.original.isWinner + if (isWinner === null || isWinner === undefined) { + return <div>-</div> + } + return ( + <Badge variant={isWinner ? 'default' : 'secondary'} className={isWinner ? 'bg-green-600' : ''}> + {isWinner ? '낙찰' : '탈락'} + </Badge> + ) + }, + }, + { accessorKey: 'awardRatio', header: '발주비율', cell: ({ row }) => ( <div className="text-right"> - {row.original.awardRatio ? `${row.original.awardRatio}%` : '-'} + {row.original.awardRatio !== null ? `${row.original.awardRatio}%` : '-'} </div> ), }, { + accessorKey: 'isBiddingParticipated', + header: '입찰참여', + cell: ({ row }) => { + const participated = row.original.isBiddingParticipated + if (participated === null) { + return <Badge variant="outline">대기</Badge> + } + return ( + <Badge variant={participated ? 'default' : 'destructive'}> + {participated ? '응찰' : '미응찰'} + </Badge> + ) + }, + }, + { accessorKey: 'status', header: '상태', cell: ({ row }) => { @@ -116,103 +143,6 @@ export function getBiddingDetailVendorColumns({ ), }, { - accessorKey: 'paymentTermsResponse', - header: '지급조건', - cell: ({ row }) => ( - <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}> - {row.original.paymentTermsResponse || '-'} - </div> - ), - }, - { - accessorKey: 'taxConditionsResponse', - header: '세금조건', - cell: ({ row }) => ( - <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}> - {row.original.taxConditionsResponse || '-'} - </div> - ), - }, - { - accessorKey: 'incotermsResponse', - header: '운송조건', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}> - {row.original.incotermsResponse || '-'} - </div> - ), - }, - { - accessorKey: 'isInitialResponse', - header: '초도여부', - cell: ({ row }) => ( - <Badge variant={row.original.isInitialResponse ? 'default' : 'secondary'}> - {row.original.isInitialResponse ? 'Y' : 'N'} - </Badge> - ), - }, - { - accessorKey: 'priceAdjustmentResponse', - header: '연동제', - cell: ({ row }) => { - const hasPriceAdjustment = row.original.priceAdjustmentResponse - return ( - <div className="flex items-center gap-2"> - <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}> - {hasPriceAdjustment ? '적용' : '미적용'} - </Badge> - {hasPriceAdjustment && onViewPriceAdjustment && ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewPriceAdjustment(row.original)} - className="h-6 px-2 text-xs" - > - 상세 - </Button> - )} - </div> - ) - }, - }, - { - accessorKey: 'proposedContractDeliveryDate', - header: '제안납기일', - cell: ({ row }) => ( - <div className="text-sm"> - {row.original.proposedContractDeliveryDate ? - new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'} - </div> - ), - }, - { - accessorKey: 'proposedShippingPort', - header: '제안선적지', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}> - {row.original.proposedShippingPort || '-'} - </div> - ), - }, - { - accessorKey: 'proposedDestinationPort', - header: '제안도착지', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}> - {row.original.proposedDestinationPort || '-'} - </div> - ), - }, - { - accessorKey: 'sparePartResponse', - header: '스페어파트', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}> - {row.original.sparePartResponse || '-'} - </div> - ), - }, - { id: 'actions', header: '작업', cell: ({ row }) => { @@ -229,21 +159,42 @@ export function getBiddingDetailVendorColumns({ <DropdownMenuContent align="end"> <DropdownMenuLabel>작업</DropdownMenuLabel> <DropdownMenuItem onClick={() => onEdit(vendor)}> - <Edit className="mr-2 h-4 w-4" /> - 수정 + 발주비율 산정 </DropdownMenuItem> {vendor.status !== 'selected' && ( <DropdownMenuItem onClick={() => onSelectWinner(vendor)}> - <Trophy className="mr-2 h-4 w-4" /> 낙찰 선정 </DropdownMenuItem> )} + + {/* 입찰 참여여부 관리 */} + {vendor.isBiddingParticipated === null && onUpdateParticipation && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, true)}> + 응찰 설정 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, false)}> + 미응찰 설정 + </DropdownMenuItem> + </> + )} + + {/* 입찰 보내기 (응찰한 업체만) */} + {vendor.isBiddingParticipated === true && onSendBidding && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onSendBidding(vendor)}> + 입찰 보내기 + </DropdownMenuItem> + </> + )} + <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onDelete(vendor)} className="text-destructive" > - <Trash2 className="mr-2 h-4 w-4" /> 삭제 </DropdownMenuItem> </DropdownMenuContent> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx index bd0f3684..75b1f67b 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -35,8 +35,7 @@ import { } from '@/components/ui/popover' import { Check, ChevronsUpDown, Search } from 'lucide-react' import { cn } from '@/lib/utils' -import { createQuotationVendor } from '@/lib/bidding/detail/service' -import { createQuotationVendorSchema } from '@/lib/bidding/validation' +import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' import { searchVendors } from '@/lib/vendors/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' @@ -70,22 +69,9 @@ export function BiddingDetailVendorCreateDialog({ const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) const [vendorSearchValue, setVendorSearchValue] = React.useState('') - // 폼 상태 + // 폼 상태 (간소화 - 필수 항목만) const [formData, setFormData] = React.useState({ - quotationAmount: 0, - currency: 'KRW', - awardRatio: 0, - status: 'pending' as const, - // 입찰 조건 (companyConditionResponses 기반) - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', + awardRatio: 100, // 기본 100% }) // Vendor 검색 @@ -125,28 +111,13 @@ export function BiddingDetailVendorCreateDialog({ return } - const result = createQuotationVendorSchema.safeParse({ - biddingId, - vendorId: selectedVendor.id, - vendorName: selectedVendor.vendorName, - vendorCode: selectedVendor.vendorCode, - contactPerson: '', - contactEmail: '', - contactPhone: '', - ...formData, - }) - - if (!result.success) { - toast({ - title: '유효성 오류', - description: result.error.issues[0]?.message || '입력값을 확인해주세요.', - variant: 'destructive', - }) - return - } startTransition(async () => { - const response = await createQuotationVendor(result.data, 'current-user') + const response = await createBiddingDetailVendor( + biddingId, + selectedVendor.id, + 'current-user' + ) if (response.success) { toast({ @@ -170,20 +141,7 @@ export function BiddingDetailVendorCreateDialog({ setSelectedVendor(null) setVendorSearchValue('') setFormData({ - quotationAmount: 0, - currency: 'KRW', - awardRatio: 0, - status: 'pending', - // 입찰 조건 초기화 - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', + awardRatio: 100, // 기본 100% }) } @@ -250,167 +208,6 @@ export function BiddingDetailVendorCreateDialog({ </PopoverContent> </Popover> </div> - - {/* 견적 정보 입력 */} - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="quotationAmount">견적금액</Label> - <Input - id="quotationAmount" - type="number" - value={formData.quotationAmount} - onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })} - placeholder="견적금액을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="currency">통화</Label> - <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - </SelectContent> - </Select> - </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="awardRatio">발주비율 (%)</Label> - <Input - id="awardRatio" - type="number" - min="0" - max="100" - value={formData.awardRatio} - onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} - placeholder="발주비율을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="status">상태</Label> - <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="pending">대기</SelectItem> - <SelectItem value="submitted">제출</SelectItem> - <SelectItem value="selected">선정</SelectItem> - <SelectItem value="rejected">거절</SelectItem> - </SelectContent> - </Select> - </div> - </div> - - - - {/* 입찰 조건 섹션 */} - <div className="col-span-2 pt-4 border-t"> - <h3 className="text-lg font-medium mb-4">입찰 조건 설정</h3> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="paymentTermsResponse">지급조건</Label> - <Input - id="paymentTermsResponse" - value={formData.paymentTermsResponse} - onChange={(e) => setFormData({ ...formData, paymentTermsResponse: e.target.value })} - placeholder="지급조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="taxConditionsResponse">세금조건</Label> - <Input - id="taxConditionsResponse" - value={formData.taxConditionsResponse} - onChange={(e) => setFormData({ ...formData, taxConditionsResponse: e.target.value })} - placeholder="세금조건을 입력하세요" - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="incotermsResponse">운송조건 (Incoterms)</Label> - <Input - id="incotermsResponse" - value={formData.incotermsResponse} - onChange={(e) => setFormData({ ...formData, incotermsResponse: e.target.value })} - placeholder="운송조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="proposedContractDeliveryDate">제안 계약납기일</Label> - <Input - id="proposedContractDeliveryDate" - type="date" - value={formData.proposedContractDeliveryDate} - onChange={(e) => setFormData({ ...formData, proposedContractDeliveryDate: e.target.value })} - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="proposedShippingPort">제안 선적지</Label> - <Input - id="proposedShippingPort" - value={formData.proposedShippingPort} - onChange={(e) => setFormData({ ...formData, proposedShippingPort: e.target.value })} - placeholder="선적지를 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="proposedDestinationPort">제안 도착지</Label> - <Input - id="proposedDestinationPort" - value={formData.proposedDestinationPort} - onChange={(e) => setFormData({ ...formData, proposedDestinationPort: e.target.value })} - placeholder="도착지를 입력하세요" - /> - </div> - </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="sparePartResponse">스페어파트 응답</Label> - <Input - id="sparePartResponse" - value={formData.sparePartResponse} - onChange={(e) => setFormData({ ...formData, sparePartResponse: e.target.value })} - placeholder="스페어파트 관련 응답을 입력하세요" - /> - </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="additionalProposals">추가 제안사항</Label> - <Textarea - id="additionalProposals" - value={formData.additionalProposals} - onChange={(e) => setFormData({ ...formData, additionalProposals: e.target.value })} - placeholder="추가 제안사항을 입력하세요" - rows={3} - /> - </div> - - <div className="flex items-center space-x-2 mt-4"> - <Checkbox - id="priceAdjustmentResponse" - checked={formData.priceAdjustmentResponse} - onCheckedChange={(checked) => - setFormData({ ...formData, priceAdjustmentResponse: !!checked }) - } - /> - <Label htmlFor="priceAdjustmentResponse">연동제 적용</Label> - </div> - </div> </div> <DialogFooter> <Button variant="outline" onClick={() => onOpenChange(false)}> 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 75f53503..b10212ab 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -21,8 +21,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { updateQuotationVendor } from '@/lib/bidding/detail/service' -import { updateQuotationVendorSchema } from '@/lib/bidding/validation' +import { updateBiddingDetailVendor } from '@/lib/bidding/detail/service' import { QuotationVendor } from '@/lib/bidding/detail/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' @@ -43,52 +42,16 @@ export function BiddingDetailVendorEditDialog({ const { toast } = useToast() const [isPending, startTransition] = useTransition() - // 폼 상태 + // 폼 상태 (간소화 - 수정 가능한 필드만) const [formData, setFormData] = React.useState({ - vendorName: '', - vendorCode: '', - contactPerson: '', - contactEmail: '', - contactPhone: '', - quotationAmount: 0, - currency: 'KRW', awardRatio: 0, - status: 'pending' as const, - // 입찰 조건 (companyConditionResponses 기반) - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', }) // vendor가 변경되면 폼 데이터 업데이트 React.useEffect(() => { if (vendor) { setFormData({ - vendorName: vendor.vendorName, - vendorCode: vendor.vendorCode, - contactPerson: vendor.contactPerson || '', - contactEmail: vendor.contactEmail || '', - contactPhone: vendor.contactPhone || '', - quotationAmount: vendor.quotationAmount, - currency: vendor.currency, awardRatio: vendor.awardRatio || 0, - status: vendor.status, - // 입찰 조건 데이터 (vendor에서 가져오거나 기본값) - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', }) } }, [vendor]) @@ -96,22 +59,15 @@ export function BiddingDetailVendorEditDialog({ const handleEdit = () => { if (!vendor) return - const result = updateQuotationVendorSchema.safeParse({ - id: vendor.id, - ...formData, - }) - - if (!result.success) { - toast({ - title: '유효성 오류', - description: result.error.issues[0]?.message || '입력값을 확인해주세요.', - variant: 'destructive', - }) - return - } startTransition(async () => { - const response = await updateQuotationVendor(vendor.id, result.data, 'current-user') + const response = await updateBiddingDetailVendor( + vendor.id, + vendor.quotationAmount, // 기존 견적금액 유지 + vendor.currency, // 기존 통화 유지 + formData.awardRatio, + 'current-user' // TODO: 실제 사용자 ID + ) if (response.success) { toast({ @@ -134,209 +90,40 @@ export function BiddingDetailVendorEditDialog({ <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-[600px]"> <DialogHeader> - <DialogTitle>협력업체 수정</DialogTitle> + <DialogTitle>협력업체 발주비율 산정</DialogTitle> <DialogDescription> - 협력업체 정보를 수정해주세요. + 협력업체 발주비율을 산정해주세요. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-vendorName">업체명</Label> - <Input - id="edit-vendorName" - value={formData.vendorName} - onChange={(e) => setFormData({ ...formData, vendorName: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-vendorCode">업체코드</Label> - <Input - id="edit-vendorCode" - value={formData.vendorCode} - onChange={(e) => setFormData({ ...formData, vendorCode: e.target.value })} - /> - </div> - </div> - <div className="grid grid-cols-3 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-contactPerson">담당자</Label> - <Input - id="edit-contactPerson" - value={formData.contactPerson} - onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-contactEmail">이메일</Label> - <Input - id="edit-contactEmail" - type="email" - value={formData.contactEmail} - onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-contactPhone">연락처</Label> - <Input - id="edit-contactPhone" - value={formData.contactPhone} - onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} - /> - </div> - </div> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-quotationAmount">견적금액</Label> - <Input - id="edit-quotationAmount" - type="number" - value={formData.quotationAmount} - onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-currency">통화</Label> - <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-awardRatio">발주비율 (%)</Label> - <Input - id="edit-awardRatio" - type="number" - min="0" - max="100" - value={formData.awardRatio} - onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-status">상태</Label> - <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="pending">대기</SelectItem> - <SelectItem value="submitted">제출</SelectItem> - <SelectItem value="selected">선정</SelectItem> - <SelectItem value="rejected">거절</SelectItem> - </SelectContent> - </Select> - </div> - </div> - {/* 입찰 조건 섹션 */} - <div className="col-span-2 pt-4 border-t"> - <h3 className="text-lg font-medium mb-4">입찰 조건 설정</h3> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-paymentTermsResponse">지급조건</Label> - <Input - id="edit-paymentTermsResponse" - value={formData.paymentTermsResponse} - onChange={(e) => setFormData({ ...formData, paymentTermsResponse: e.target.value })} - placeholder="지급조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="edit-taxConditionsResponse">세금조건</Label> - <Input - id="edit-taxConditionsResponse" - value={formData.taxConditionsResponse} - onChange={(e) => setFormData({ ...formData, taxConditionsResponse: e.target.value })} - placeholder="세금조건을 입력하세요" - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="edit-incotermsResponse">운송조건 (Incoterms)</Label> - <Input - id="edit-incotermsResponse" - value={formData.incotermsResponse} - onChange={(e) => setFormData({ ...formData, incotermsResponse: e.target.value })} - placeholder="운송조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="edit-proposedContractDeliveryDate">제안 계약납기일</Label> - <Input - id="edit-proposedContractDeliveryDate" - type="date" - value={formData.proposedContractDeliveryDate} - onChange={(e) => setFormData({ ...formData, proposedContractDeliveryDate: e.target.value })} - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="edit-proposedShippingPort">제안 선적지</Label> - <Input - id="edit-proposedShippingPort" - value={formData.proposedShippingPort} - onChange={(e) => setFormData({ ...formData, proposedShippingPort: e.target.value })} - placeholder="선적지를 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="edit-proposedDestinationPort">제안 도착지</Label> - <Input - id="edit-proposedDestinationPort" - value={formData.proposedDestinationPort} - onChange={(e) => setFormData({ ...formData, proposedDestinationPort: e.target.value })} - placeholder="도착지를 입력하세요" - /> + {/* 읽기 전용 업체 정보 */} + {vendor && ( + <div className="bg-muted/50 rounded-lg p-3 border"> + <h4 className="font-medium mb-2">협력업체 정보</h4> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="text-muted-foreground">협력업체명:</span> {vendor.vendorName} + </div> + <div> + <span className="text-muted-foreground">협력업체코드:</span> {vendor.vendorCode} + </div> </div> </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="edit-sparePartResponse">스페어파트 응답</Label> - <Input - id="edit-sparePartResponse" - value={formData.sparePartResponse} - onChange={(e) => setFormData({ ...formData, sparePartResponse: e.target.value })} - placeholder="스페어파트 관련 응답을 입력하세요" - /> - </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="edit-additionalProposals">추가 제안사항</Label> - <Textarea - id="edit-additionalProposals" - value={formData.additionalProposals} - onChange={(e) => setFormData({ ...formData, additionalProposals: e.target.value })} - placeholder="추가 제안사항을 입력하세요" - rows={3} - /> - </div> - - <div className="flex items-center space-x-2 mt-4"> - <Checkbox - id="edit-priceAdjustmentResponse" - checked={formData.priceAdjustmentResponse} - onCheckedChange={(checked) => - setFormData({ ...formData, priceAdjustmentResponse: !!checked }) - } - /> - <Label htmlFor="edit-priceAdjustmentResponse">연동제 적용</Label> - </div> + )} + + {/* 수정 가능한 필드들 */} + + <div className="space-y-2"> + <Label htmlFor="edit-awardRatio">발주비율 (%)</Label> + <Input + id="edit-awardRatio" + type="number" + min="0" + max="100" + value={formData.awardRatio} + onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} + placeholder="발주비율을 입력하세요" + /> </div> </div> <DialogFooter> @@ -344,7 +131,7 @@ export function BiddingDetailVendorEditDialog({ 취소 </Button> <Button onClick={handleEdit} disabled={isPending}> - 수정 + 산정 </Button> </DialogFooter> </DialogContent> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index b1f0b08e..dd1ae94b 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -8,6 +8,7 @@ import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-adv import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolbar-actions' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' +import { BiddingAwardDialog } from './bidding-award-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' @@ -28,6 +29,7 @@ interface BiddingDetailVendorTableContentProps { onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void onOpenSelectionReasonDialog: () => void + onOpenAwardDialog: () => void onEdit?: (vendor: QuotationVendor) => void onDelete?: (vendor: QuotationVendor) => void onSelectWinner?: (vendor: QuotationVendor) => void @@ -92,6 +94,7 @@ export function BiddingDetailVendorTableContent({ onOpenItemsDialog, onOpenTargetPriceDialog, onOpenSelectionReasonDialog, + onOpenAwardDialog, onEdit, onDelete, onSelectWinner @@ -100,6 +103,7 @@ export function BiddingDetailVendorTableContent({ const [isPending, startTransition] = useTransition() const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) @@ -240,6 +244,7 @@ export function BiddingDetailVendorTableContent({ onOpenItemsDialog={onOpenItemsDialog} onOpenTargetPriceDialog={onOpenTargetPriceDialog} onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} + onOpenAwardDialog={() => setIsAwardDialogOpen(true)} onSuccess={onRefresh} /> </DataTableAdvancedToolbar> @@ -252,6 +257,13 @@ export function BiddingDetailVendorTableContent({ onSuccess={onRefresh} /> + <BiddingAwardDialog + biddingId={biddingId} + open={isAwardDialogOpen} + onOpenChange={setIsAwardDialogOpen} + onSuccess={onRefresh} + /> + <PriceAdjustmentDialog open={isPriceAdjustmentDialogOpen} onOpenChange={setIsPriceAdjustmentDialogOpen} 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 ca9ffc60..8cdec191 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -5,8 +5,8 @@ import { type Table } from "@tanstack/react-table" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, RotateCcw, XCircle } from "lucide-react" -import { QuotationVendor, registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" +import { Plus, Send, RotateCcw, XCircle, Trophy } from "lucide-react" +import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service" import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" @@ -17,7 +17,7 @@ interface BiddingDetailVendorToolbarActionsProps { bidding: Bidding onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void - onOpenSelectionReasonDialog: () => void + onOpenAwardDialog: () => void onSuccess: () => void } @@ -27,7 +27,7 @@ export function BiddingDetailVendorToolbarActions({ bidding, onOpenItemsDialog, onOpenTargetPriceDialog, - onOpenSelectionReasonDialog, + onOpenAwardDialog, onSuccess }: BiddingDetailVendorToolbarActionsProps) { const router = useRouter() @@ -40,18 +40,6 @@ export function BiddingDetailVendorToolbarActions({ } const handleRegister = () => { - // 상태 검증 - if (bidding.status !== 'bidding_generated') { - toast({ - title: '실행 불가', - description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.', - variant: 'destructive', - }) - return - } - - if (!confirm('입찰을 등록하시겠습니까?')) return - startTransition(async () => { const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID @@ -72,18 +60,6 @@ export function BiddingDetailVendorToolbarActions({ } const handleMarkAsDisposal = () => { - // 상태 검증 - if (bidding.status !== 'bidding_closed') { - toast({ - title: '실행 불가', - description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.', - variant: 'destructive', - }) - return - } - - if (!confirm('입찰을 유찰 처리하시겠습니까?')) return - startTransition(async () => { const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID @@ -104,18 +80,6 @@ export function BiddingDetailVendorToolbarActions({ } const handleCreateRebidding = () => { - // 상태 검증 - if (bidding.status !== 'bidding_disposal') { - toast({ - title: '실행 불가', - description: '재입찰은 유찰 상태에서만 가능합니다.', - variant: 'destructive', - }) - return - } - - if (!confirm('재입찰을 생성하시겠습니까?')) return - startTransition(async () => { const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID @@ -124,11 +88,8 @@ export function BiddingDetailVendorToolbarActions({ title: '성공', description: result.message, }) - if (result.data?.redirectTo) { - router.push(result.data.redirectTo) - } else { - router.refresh() - } + router.refresh() + onSuccess() } else { toast({ title: '오류', @@ -143,7 +104,7 @@ export function BiddingDetailVendorToolbarActions({ <> <div className="flex items-center gap-2"> {/* 상태별 액션 버튼 */} - {/* {bidding.status === 'bidding_generated' && ( + {bidding.status === 'bidding_generated' && ( <Button variant="default" size="sm" @@ -156,15 +117,26 @@ export function BiddingDetailVendorToolbarActions({ )} {bidding.status === 'bidding_closed' && ( - <Button - variant="destructive" - size="sm" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="mr-2 h-4 w-4" /> - 유찰 처리 - </Button> + <> + <Button + variant="destructive" + size="sm" + onClick={handleMarkAsDisposal} + disabled={isPending} + > + <XCircle className="mr-2 h-4 w-4" /> + 유찰 + </Button> + <Button + variant="default" + size="sm" + onClick={onOpenAwardDialog} + disabled={isPending} + > + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 + </Button> + </> )} {bidding.status === 'bidding_disposal' && ( @@ -175,11 +147,18 @@ export function BiddingDetailVendorToolbarActions({ disabled={isPending} > <RotateCcw className="mr-2 h-4 w-4" /> - 재입찰 생성 + 재입찰 </Button> - )} */} + )} + + {/* 구분선 */} + {(bidding.status === 'bidding_generated' || + bidding.status === 'bidding_closed' || + bidding.status === 'bidding_disposal') && ( + <div className="h-4 w-px bg-border mx-1" /> + )} - {/* 기존 버튼들 */} + {/* 공통 관리 버튼들 */} <Button variant="outline" size="sm" @@ -197,13 +176,6 @@ export function BiddingDetailVendorToolbarActions({ <Button variant="outline" size="sm" - onClick={onOpenSelectionReasonDialog} - > - 선정 사유 - </Button> - <Button - variant="default" - size="sm" onClick={handleCreateVendor} > <Plus className="mr-2 h-4 w-4" /> diff --git a/lib/bidding/detail/table/components/award-simple-file-upload.tsx b/lib/bidding/detail/table/components/award-simple-file-upload.tsx new file mode 100644 index 00000000..c19918f6 --- /dev/null +++ b/lib/bidding/detail/table/components/award-simple-file-upload.tsx @@ -0,0 +1,307 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Upload, + FileText, + Download, + Trash2 +} from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { + uploadAwardDocument, + getAwardDocuments, + getAwardDocumentForDownload, + deleteAwardDocument +} from '../../service' +import { downloadFile } from '@/lib/file-download' + +interface UploadedDocument { + id: number + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: Date + uploadedBy: string +} + +interface AwardSimpleFileUploadProps { + biddingId: number + userId: string + readOnly?: boolean +} + +export function AwardSimpleFileUpload({ + biddingId, + userId, + readOnly = false +}: AwardSimpleFileUploadProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) + const [isLoading, setIsLoading] = React.useState(true) + + // 업로드된 문서 목록 로드 + const loadDocuments = React.useCallback(async () => { + try { + setIsLoading(true) + const docs = await getAwardDocuments(biddingId) + setDocuments(docs as UploadedDocument[]) + } catch (error) { + console.error('Failed to load documents:', error) + toast({ + title: '오류', + description: '업로드된 문서 목록을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + }, [biddingId, toast]) + + React.useEffect(() => { + loadDocuments() + }, [loadDocuments]) + + // 파일 업로드 처리 + const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = event.target.files + if (!files || files.length === 0) return + + const file = files[0] + + // 파일 크기 체크 (50MB 제한) + if (file.size > 50 * 1024 * 1024) { + toast({ + title: '파일 크기 초과', + description: '파일 크기가 50MB를 초과합니다.', + variant: 'destructive', + }) + return + } + + // 파일 타입 체크 + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'image/jpeg', + 'image/png', + 'application/zip' + ] + + if (!allowedTypes.includes(file.type)) { + toast({ + title: '지원하지 않는 파일 형식', + description: 'PDF, Word, Excel, 이미지, ZIP 파일만 업로드 가능합니다.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await uploadAwardDocument(biddingId, file, userId) + + if (result.success) { + toast({ + title: '업로드 완료', + description: result.message, + }) + await loadDocuments() // 문서 목록 새로고침 + } else { + toast({ + title: '업로드 실패', + description: result.error, + variant: 'destructive', + }) + } + }) + + // input 초기화 + event.target.value = '' + } + + // 파일 다운로드 + const handleDownload = (document: UploadedDocument) => { + startTransition(async () => { + const result = await getAwardDocumentForDownload(document.id, biddingId) + + if (result.success) { + try { + await downloadFile(result.document?.filePath, result.document?.originalFileName, { + showToast: true + }) + } catch (error) { + toast({ + title: '다운로드 실패', + description: '파일 다운로드에 실패했습니다.', + variant: 'destructive', + }) + } + } else { + toast({ + title: '다운로드 실패', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + // 파일 삭제 + const handleDelete = (document: UploadedDocument) => { + if (!confirm(`"${document.originalFileName}" 파일을 삭제하시겠습니까?`)) { + return + } + + startTransition(async () => { + const result = await deleteAwardDocument(document.id, biddingId, userId) + + if (result.success) { + toast({ + title: '삭제 완료', + description: result.message, + }) + await loadDocuments() // 문서 목록 새로고침 + } else { + toast({ + title: '삭제 실패', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + // 파일 크기 포맷팅 + const formatFileSize = (bytes: number | null) => { + if (!bytes) return '-' + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 낙찰 관련 문서 업로드 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {!readOnly && ( + <div className="space-y-2"> + <Label htmlFor="award-file-upload">낙찰 관련 파일</Label> + <Input + id="award-file-upload" + type="file" + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip" + onChange={handleFileUpload} + disabled={isPending} + /> + <p className="text-xs text-muted-foreground"> + 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB) + </p> + </div> + )} + + {/* 업로드된 문서 목록 */} + {isLoading ? ( + <div className="text-center py-4"> + <p className="text-muted-foreground">문서 목록을 불러오는 중...</p> + </div> + ) : documents.length > 0 ? ( + <div className="space-y-2"> + <Label className="text-sm font-medium">업로드된 문서</Label> + <Table> + <TableHeader> + <TableRow> + <TableHead>파일명</TableHead> + <TableHead>크기</TableHead> + <TableHead>업로드일</TableHead> + <TableHead>작성자</TableHead> + <TableHead className="w-24">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {documents.map((doc) => ( + <TableRow key={doc.id}> + <TableCell> + <div className="flex items-center gap-2"> + <FileText className="w-4 h-4 text-gray-500" /> + <span className="truncate max-w-48" title={doc.originalFileName}> + {doc.originalFileName} + </span> + </div> + </TableCell> + <TableCell className="text-sm text-gray-500"> + {formatFileSize(doc.fileSize)} + </TableCell> + <TableCell className="text-sm text-gray-500"> + {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} + </TableCell> + <TableCell className="text-sm text-gray-500"> + {doc.uploadedBy} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + variant="outline" + size="sm" + onClick={() => handleDownload(doc)} + disabled={isPending} + title="다운로드" + > + <Download className="w-3 h-3" /> + </Button> + {!readOnly && doc.uploadedBy === userId && ( + <Button + variant="outline" + size="sm" + onClick={() => handleDelete(doc)} + disabled={isPending} + title="삭제" + className="text-red-600 hover:text-red-700" + > + <Trash2 className="w-3 h-3" /> + </Button> + )} + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + ) : ( + <div className="text-center py-4 text-gray-500"> + <FileText className="w-8 h-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">업로드된 문서가 없습니다</p> + </div> + )} + </CardContent> + </Card> + ) +} |
