diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
| commit | 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch) | |
| tree | 8e66703ec821888ad51dcc242a508813a027bf71 /lib/bidding/detail/table | |
| parent | 7eac558470ef179dad626a8e82db5784fe86a556 (diff) | |
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/bidding/detail/table')
10 files changed, 2086 insertions, 0 deletions
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx new file mode 100644 index 00000000..090e7218 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -0,0 +1,93 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' +import { BiddingDetailHeader } from './bidding-detail-header' +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[] +} + +export function BiddingDetailContent({ + bidding, + quotationDetails, + quotationVendors, + biddingCompanies, + prItems +}: BiddingDetailContentProps) { + const [dialogStates, setDialogStates] = React.useState({ + items: false, + targetPrice: false, + selectionReason: false + }) + + const [refreshTrigger, setRefreshTrigger] = React.useState(0) + + const handleRefresh = React.useCallback(() => { + setRefreshTrigger(prev => prev + 1) + }, []) + + const openDialog = React.useCallback((type: keyof typeof dialogStates) => { + setDialogStates(prev => ({ ...prev, [type]: true })) + }, []) + + const closeDialog = React.useCallback((type: keyof typeof dialogStates) => { + setDialogStates(prev => ({ ...prev, [type]: false })) + }, []) + + return ( + <div className="container py-6"> + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> + <div className="p-6"> + <BiddingDetailHeader bidding={bidding} /> + + <div className="mt-6"> + <BiddingDetailVendorTableContent + biddingId={bidding.id} + vendors={quotationVendors} + biddingCompanies={biddingCompanies} + onRefresh={handleRefresh} + onOpenItemsDialog={() => openDialog('items')} + onOpenTargetPriceDialog={() => openDialog('targetPrice')} + onOpenSelectionReasonDialog={() => openDialog('selectionReason')} + onEdit={undefined} + onDelete={undefined} + onSelectWinner={undefined} + /> + </div> + </div> + </section> + + <BiddingDetailItemsDialog + open={dialogStates.items} + onOpenChange={(open) => closeDialog('items')} + prItems={prItems} + bidding={bidding} + /> + + <BiddingDetailTargetPriceDialog + open={dialogStates.targetPrice} + onOpenChange={(open) => closeDialog('targetPrice')} + quotationDetails={quotationDetails} + 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-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx new file mode 100644 index 00000000..3135f37d --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-header.tsx @@ -0,0 +1,328 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { Bidding, biddingStatusLabels, contractTypeLabels, biddingTypeLabels } from '@/db/schema' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ArrowLeft, + Send, + RotateCcw, + XCircle, + Calendar, + Building2, + User, + Package, + DollarSign, + Hash +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { + registerBidding, + markAsDisposal, + createRebidding +} from '@/lib/bidding/detail/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailHeaderProps { + bidding: Bidding +} + +export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { + const router = useRouter() + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + const handleGoBack = () => { + router.push('/evcp/bid') + } + + 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 + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + router.refresh() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + 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 + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + router.refresh() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + 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 + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + // 새로 생성된 입찰로 이동 + if (result.data) { + router.push(`/evcp/bid/${result.data.id}`) + } else { + router.refresh() + } + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const getActionButtons = () => { + const buttons = [] + + // 기본 액션 버튼들 (항상 표시) + buttons.push( + <Button + key="back" + variant="outline" + onClick={handleGoBack} + disabled={isPending} + > + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Button> + ) + + // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서) + buttons.push( + <Button + key="register" + onClick={handleRegister} + disabled={isPending} + > + <Send className="w-4 h-4 mr-2" /> + 입찰등록 + </Button> + ) + + buttons.push( + <Button + key="disposal" + variant="destructive" + onClick={handleMarkAsDisposal} + disabled={isPending} + > + <XCircle className="w-4 h-4 mr-2" /> + 유찰 + </Button> + ) + + buttons.push( + <Button + key="rebidding" + onClick={handleCreateRebidding} + disabled={isPending} + > + <RotateCcw className="w-4 h-4 mr-2" /> + 재입찰 + </Button> + ) + + return buttons + } + + return ( + <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="px-6 py-4"> + {/* 헤더 메인 영역 */} + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-4 flex-1 min-w-0"> + {/* 제목과 배지 */} + <div className="flex items-center gap-3 flex-1 min-w-0"> + <h1 className="text-xl font-semibold truncate">{bidding.title}</h1> + <div className="flex items-center gap-2 flex-shrink-0"> + <Badge variant="outline" className="font-mono text-xs"> + <Hash className="w-3 h-3 mr-1" /> + {bidding.biddingNumber} + {bidding.revision && bidding.revision > 0 && ` Rev.${bidding.revision}`} + </Badge> + <Badge variant={ + bidding.status === 'bidding_disposal' ? 'destructive' : + bidding.status === 'vendor_selected' ? 'default' : + 'secondary' + } className="text-xs"> + {biddingStatusLabels[bidding.status]} + </Badge> + </div> + </div> + + {/* 액션 버튼들 */} + <div className="flex items-center gap-2 flex-shrink-0"> + {getActionButtons()} + </div> + </div> + </div> + + {/* 세부 정보 영역 */} + <div className="flex flex-wrap items-center gap-6 text-sm"> + {/* 프로젝트 정보 */} + {bidding.projectName && ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <Building2 className="w-4 h-4" /> + <span className="font-medium">프로젝트:</span> + <span>{bidding.projectName}</span> + </div> + )} + + {/* 품목 정보 */} + {bidding.itemName && ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <Package className="w-4 h-4" /> + <span className="font-medium">품목:</span> + <span>{bidding.itemName}</span> + </div> + )} + + {/* 담당자 정보 */} + {bidding.managerName && ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <User className="w-4 h-4" /> + <span className="font-medium">담당자:</span> + <span>{bidding.managerName}</span> + </div> + )} + + {/* 계약구분 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <span className="font-medium">계약:</span> + <span>{contractTypeLabels[bidding.contractType]}</span> + </div> + + {/* 입찰유형 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <span className="font-medium">유형:</span> + <span>{biddingTypeLabels[bidding.biddingType]}</span> + </div> + + {/* 낙찰수 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <span className="font-medium">낙찰:</span> + <span>{bidding.awardCount === 'single' ? '단수' : '복수'}</span> + </div> + + {/* 통화 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <DollarSign className="w-4 h-4" /> + <span className="font-mono">{bidding.currency}</span> + </div> + + {/* 예산 정보 */} + {bidding.budget && ( + <div className="flex items-center gap-1.5"> + <span className="font-medium text-muted-foreground">예산:</span> + <span className="font-semibold"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: bidding.currency || 'KRW', + }).format(Number(bidding.budget))} + </span> + </div> + )} + </div> + + {/* 일정 정보 */} + {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && ( + <div className="flex flex-wrap items-center gap-4 mt-3 pt-3 border-t border-border/50"> + <Calendar className="w-4 h-4 text-muted-foreground flex-shrink-0" /> + <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> + {bidding.submissionStartDate && bidding.submissionEndDate && ( + <div> + <span className="font-medium">제출기간:</span> {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')} + </div> + )} + {bidding.evaluationDate && ( + <div> + <span className="font-medium">평가일:</span> {formatDate(bidding.evaluationDate, 'KR')} + </div> + )} + {bidding.preQuoteDate && ( + <div> + <span className="font-medium">사전견적일:</span> {formatDate(bidding.preQuoteDate, 'KR')} + </div> + )} + {bidding.biddingRegistrationDate && ( + <div> + <span className="font-medium">입찰등록일:</span> {formatDate(bidding.biddingRegistrationDate, 'KR')} + </div> + )} + </div> + </div> + )} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx new file mode 100644 index 00000000..2bab3ef0 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx @@ -0,0 +1,138 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { formatDate } from '@/lib/utils' + +interface PrItem { + id: number + biddingId: number + itemName: string + itemCode: string + specification: string + quantity: number + unit: string + estimatedPrice: number + budget: number + deliveryDate: Date + notes: string + createdAt: Date + updatedAt: Date +} + +interface BiddingDetailItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + prItems: PrItem[] + bidding: Bidding +} + +export function BiddingDetailItemsDialog({ + open, + onOpenChange, + prItems, + bidding +}: BiddingDetailItemsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>품목 정보</DialogTitle> + <DialogDescription> + 입찰번호: {bidding.biddingNumber} - 품목 상세 정보 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">프로젝트:</span> {bidding.projectName || '-'} + </div> + <div> + <span className="font-medium">품목:</span> {bidding.itemName || '-'} + </div> + </div> + + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>품목코드</TableHead> + <TableHead>품목명</TableHead> + <TableHead>규격</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead>단위</TableHead> + <TableHead className="text-right">예상단가</TableHead> + <TableHead className="text-right">예산</TableHead> + <TableHead>납기요청일</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.length > 0 ? ( + prItems.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-mono text-sm"> + {item.itemCode} + </TableCell> + <TableCell className="font-medium"> + {item.itemName} + </TableCell> + <TableCell className="text-sm"> + {item.specification || '-'} + </TableCell> + <TableCell className="text-right"> + {item.quantity ? Number(item.quantity).toLocaleString() : '-'} + </TableCell> + <TableCell>{item.unit}</TableCell> + <TableCell className="text-right font-mono"> + {item.estimatedPrice ? Number(item.estimatedPrice).toLocaleString() : '-'} {bidding.currency} + </TableCell> + <TableCell className="text-right font-mono"> + {item.budget ? Number(item.budget).toLocaleString() : '-'} {bidding.currency} + </TableCell> + <TableCell className="text-sm"> + {item.deliveryDate ? formatDate(item.deliveryDate, 'KR') : '-'} + </TableCell> + <TableCell className="text-sm"> + {item.notes || '-'} + </TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={9} className="text-center py-8"> + 등록된 품목이 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {prItems.length > 0 && ( + <div className="text-sm text-muted-foreground"> + 총 {prItems.length}개 품목 + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx new file mode 100644 index 00000000..0e7ca364 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx @@ -0,0 +1,167 @@ +'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 new file mode 100644 index 00000000..b9dd44dd --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx @@ -0,0 +1,238 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, updateTargetPrice } from '@/lib/bidding/detail/service' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailTargetPriceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + quotationDetails: QuotationDetails | null + bidding: Bidding + onSuccess: () => void +} + +export function BiddingDetailTargetPriceDialog({ + open, + onOpenChange, + quotationDetails, + bidding, + onSuccess +}: BiddingDetailTargetPriceDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [targetPrice, setTargetPrice] = React.useState( + bidding.targetPrice ? Number(bidding.targetPrice) : 0 + ) + const [calculationCriteria, setCalculationCriteria] = React.useState( + (bidding as any).targetPriceCalculationCriteria || '' + ) + + // Dialog가 열릴 때 상태 초기화 + React.useEffect(() => { + if (open) { + setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0) + setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '') + } + }, [open, bidding]) + + const handleSave = () => { + // 필수값 검증 + if (targetPrice <= 0) { + toast({ + title: '유효성 오류', + description: '내정가는 0보다 큰 값을 입력해주세요.', + variant: 'destructive', + }) + return + } + + if (!calculationCriteria.trim()) { + toast({ + title: '유효성 오류', + description: '내정가 산정 기준을 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await updateTargetPrice( + bidding.id, + targetPrice, + calculationCriteria.trim(), + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: bidding.currency || 'KRW', + }).format(amount) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px]"> + <DialogHeader> + <DialogTitle>내정가 산정</DialogTitle> + <DialogDescription> + 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[200px]">항목</TableHead> + <TableHead>값</TableHead> + </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-semibold"> + {quotationDetails?.averageQuote ? formatCurrency(quotationDetails.averageQuote) : '-'} + </TableCell> + </TableRow> + <TableRow> + <TableCell className="font-medium">견적 수</TableCell> + <TableCell className="font-semibold"> + {quotationDetails?.quotationCount || 0}개 + </TableCell> + </TableRow> + + {/* 예산 정보 */} + {bidding.budget && ( + <TableRow> + <TableCell className="font-medium">예산</TableCell> + <TableCell className="font-semibold"> + {formatCurrency(Number(bidding.budget))} + </TableCell> + </TableRow> + )} + + {/* 최종 업데이트 시간 */} + {quotationDetails?.lastUpdated && ( + <TableRow> + <TableCell className="font-medium">최종 업데이트</TableCell> + <TableCell className="text-sm text-muted-foreground"> + {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')} + </TableCell> + </TableRow> + )} + + {/* 내정가 입력 */} + <TableRow> + <TableCell className="font-medium"> + <Label htmlFor="targetPrice" className="text-sm font-medium"> + 내정가 * + </Label> + </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="text-sm text-muted-foreground"> + {targetPrice > 0 ? formatCurrency(targetPrice) : ''} + </div> + </div> + </TableCell> + </TableRow> + + {/* 내정가 산정 기준 입력 */} + <TableRow> + <TableCell className="font-medium align-top pt-2"> + <Label htmlFor="calculationCriteria" className="text-sm font-medium"> + 내정가 산정 기준 * + </Label> + </TableCell> + <TableCell> + <Textarea + id="calculationCriteria" + value={calculationCriteria} + onChange={(e) => setCalculationCriteria(e.target.value)} + placeholder="내정가 산정 기준을 자세히 입력해주세요. (예: 최저견적가 대비 10% 상향 조정, 시장 평균가 고려 등)" + className="w-full min-h-[100px]" + rows={4} + /> + <div className="text-xs text-muted-foreground mt-1"> + 필수 입력 사항입니다. 내정가 산정에 대한 근거를 명확히 기재해주세요. + </div> + </TableCell> + </TableRow> + </TableBody> + </Table> + </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-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx new file mode 100644 index 00000000..ef075459 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -0,0 +1,223 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + MoreHorizontal, Edit, Trash2, Trophy +} from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { QuotationVendor } from "@/lib/bidding/detail/service" + +interface GetVendorColumnsProps { + onEdit: (vendor: QuotationVendor) => void + onDelete: (vendor: QuotationVendor) => void + onSelectWinner: (vendor: QuotationVendor) => void +} + +export function getBiddingDetailVendorColumns({ + onEdit, + onDelete, + onSelectWinner +}: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { + return [ + { + id: 'select', + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'vendorName', + header: '업체명', + cell: ({ row }) => ( + <div className="font-medium">{row.original.vendorName}</div> + ), + }, + { + accessorKey: 'vendorCode', + header: '업체코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.original.vendorCode}</div> + ), + }, + { + accessorKey: 'contactPerson', + header: '담당자', + cell: ({ row }) => ( + <div className="text-sm">{row.original.contactPerson || '-'}</div> + ), + }, + { + accessorKey: 'quotationAmount', + header: '견적금액', + cell: ({ row }) => ( + <div className="text-right font-mono"> + {row.original.quotationAmount ? Number(row.original.quotationAmount).toLocaleString() : '-'} {row.original.currency} + </div> + ), + }, + { + accessorKey: 'awardRatio', + header: '발주비율', + cell: ({ row }) => ( + <div className="text-right"> + {row.original.awardRatio ? `${row.original.awardRatio}%` : '-'} + </div> + ), + }, + { + accessorKey: 'status', + header: '상태', + cell: ({ row }) => { + const status = row.original.status + const variant = status === 'selected' ? 'default' : + status === 'submitted' ? 'secondary' : + status === 'rejected' ? 'destructive' : 'outline' + + const label = status === 'selected' ? '선정' : + status === 'submitted' ? '제출' : + status === 'rejected' ? '거절' : '대기' + + return <Badge variant={variant}>{label}</Badge> + }, + }, + { + accessorKey: 'submissionDate', + header: '제출일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.submissionDate ? new Date(row.original.submissionDate).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + accessorKey: 'offeredPaymentTerms', + header: '지급조건', + cell: ({ row }) => { + const terms = row.original.offeredPaymentTerms + if (!terms) return <div className="text-muted-foreground">-</div> + + try { + const parsed = JSON.parse(terms) + return ( + <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}> + {parsed.join(', ')} + </div> + ) + } catch { + return <div className="text-sm max-w-32 truncate">{terms}</div> + } + }, + }, + { + accessorKey: 'offeredTaxConditions', + header: '세금조건', + cell: ({ row }) => { + const conditions = row.original.offeredTaxConditions + if (!conditions) return <div className="text-muted-foreground">-</div> + + try { + const parsed = JSON.parse(conditions) + return ( + <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}> + {parsed.join(', ')} + </div> + ) + } catch { + return <div className="text-sm max-w-32 truncate">{conditions}</div> + } + }, + }, + { + accessorKey: 'offeredIncoterms', + header: '운송조건', + cell: ({ row }) => { + const terms = row.original.offeredIncoterms + if (!terms) return <div className="text-muted-foreground">-</div> + + try { + const parsed = JSON.parse(terms) + return ( + <div className="text-sm max-w-24 truncate" title={parsed.join(', ')}> + {parsed.join(', ')} + </div> + ) + } catch { + return <div className="text-sm max-w-24 truncate">{terms}</div> + } + }, + }, + { + accessorKey: 'offeredContractDeliveryDate', + header: '납품요청일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.offeredContractDeliveryDate ? + new Date(row.original.offeredContractDeliveryDate).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + id: 'actions', + header: '작업', + cell: ({ row }) => { + const vendor = row.original + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <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> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => onDelete(vendor)} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + }, + ] +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx new file mode 100644 index 00000000..9229b09c --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -0,0 +1,335 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} 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 { searchVendors } from '@/lib/vendors/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailVendorCreateDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface Vendor { + id: number + vendorName: string + vendorCode: string + status: string +} + +export function BiddingDetailVendorCreateDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingDetailVendorCreateDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // Vendor 검색 상태 + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [selectedVendor, setSelectedVendor] = React.useState<Vendor | null>(null) + const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) + const [vendorSearchValue, setVendorSearchValue] = React.useState('') + + // 폼 상태 + const [formData, setFormData] = React.useState({ + quotationAmount: 0, + currency: 'KRW', + paymentTerms: '', + taxConditions: '', + deliveryDate: '', + awardRatio: 0, + status: 'pending' as const, + }) + + // Vendor 검색 + React.useEffect(() => { + const search = async () => { + if (vendorSearchValue.trim().length < 2) { + setVendors([]) + return + } + + try { + const result = await searchVendors(vendorSearchValue.trim(), 10) + setVendors(result) + } catch (error) { + console.error('Vendor search failed:', error) + setVendors([]) + } + } + + const debounceTimer = setTimeout(search, 300) + return () => clearTimeout(debounceTimer) + }, [vendorSearchValue]) + + const handleVendorSelect = (vendor: Vendor) => { + setSelectedVendor(vendor) + setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`) + setVendorSearchOpen(false) + } + + const handleCreate = () => { + if (!selectedVendor) { + toast({ + title: '오류', + description: '업체를 선택해주세요.', + variant: 'destructive', + }) + 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') + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + resetForm() + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const resetForm = () => { + setSelectedVendor(null) + setVendorSearchValue('') + setFormData({ + quotationAmount: 0, + currency: 'KRW', + paymentTerms: '', + taxConditions: '', + deliveryDate: '', + awardRatio: 0, + status: 'pending', + }) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>협력업체 추가</DialogTitle> + <DialogDescription> + 검색해서 업체를 선택하고 견적 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + {/* Vendor 검색 */} + <div className="space-y-2"> + <Label htmlFor="vendor-search">업체 검색</Label> + <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorSearchOpen} + className="w-full justify-between" + > + {selectedVendor + ? `${selectedVendor.vendorName} (${selectedVendor.vendorCode})` + : "업체를 검색해서 선택하세요..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="업체명 또는 코드를 입력하세요..." + value={vendorSearchValue} + onValueChange={setVendorSearchValue} + /> + <CommandEmpty> + {vendorSearchValue.length < 2 + ? "최소 2자 이상 입력해주세요" + : "검색 결과가 없습니다"} + </CommandEmpty> + <CommandGroup className="max-h-64 overflow-auto"> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => handleVendorSelect(vendor)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedVendor?.id === vendor.id ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex flex-col"> + <span className="font-medium">{vendor.vendorName}</span> + <span className="text-sm text-muted-foreground">{vendor.vendorCode}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </Command> + </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="space-y-2"> + <Label htmlFor="paymentTerms">지급조건</Label> + <Input + id="paymentTerms" + value={formData.paymentTerms} + onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })} + placeholder="지급조건을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="taxConditions">세금조건</Label> + <Input + id="taxConditions" + value={formData.taxConditions} + onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })} + placeholder="세금조건을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="deliveryDate">납품일</Label> + <Input + id="deliveryDate" + type="date" + value={formData.deliveryDate} + onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })} + /> + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleCreate} disabled={isPending || !selectedVendor}> + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx new file mode 100644 index 00000000..a48aadd2 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -0,0 +1,260 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { updateQuotationVendor } from '@/lib/bidding/detail/service' +import { updateQuotationVendorSchema } from '@/lib/bidding/validation' +import { QuotationVendor } from '@/lib/bidding/detail/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailVendorEditDialogProps { + vendor: QuotationVendor | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function BiddingDetailVendorEditDialog({ + vendor, + open, + onOpenChange, + onSuccess +}: BiddingDetailVendorEditDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 폼 상태 + const [formData, setFormData] = React.useState({ + vendorName: '', + vendorCode: '', + contactPerson: '', + contactEmail: '', + contactPhone: '', + quotationAmount: 0, + currency: 'KRW', + paymentTerms: '', + taxConditions: '', + deliveryDate: '', + awardRatio: 0, + status: 'pending' as const, + }) + + // 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, + paymentTerms: vendor.paymentTerms || '', + taxConditions: vendor.taxConditions || '', + deliveryDate: vendor.deliveryDate || '', + awardRatio: vendor.awardRatio || 0, + status: vendor.status, + }) + } + }, [vendor]) + + 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') + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <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="space-y-2"> + <Label htmlFor="edit-paymentTerms">지급조건</Label> + <Input + id="edit-paymentTerms" + value={formData.paymentTerms} + onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-taxConditions">세금조건</Label> + <Input + id="edit-taxConditions" + value={formData.taxConditions} + onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-deliveryDate">납품일</Label> + <Input + id="edit-deliveryDate" + type="date" + value={formData.deliveryDate} + onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })} + /> + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleEdit} disabled={isPending}> + 수정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx new file mode 100644 index 00000000..7ad7056c --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -0,0 +1,225 @@ +'use client' + +import * as React from 'react' +import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table' +import { useDataTable } from '@/hooks/use-data-table' +import { DataTable } from '@/components/data-table/data-table' +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar' +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 { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' +import { QuotationVendor } from '@/lib/bidding/detail/service' +import { + deleteQuotationVendor, + selectWinner +} from '@/lib/bidding/detail/service' +import { selectWinnerSchema } from '@/lib/bidding/validation' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailVendorTableContentProps { + biddingId: number + vendors: QuotationVendor[] + onRefresh: () => void + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + onEdit?: (vendor: QuotationVendor) => void + onDelete?: (vendor: QuotationVendor) => void + onSelectWinner?: (vendor: QuotationVendor) => void +} + +const filterFields: DataTableFilterField<QuotationVendor>[] = [ + { + id: 'vendorName', + label: '업체명', + placeholder: '업체명으로 검색...', + }, + { + id: 'vendorCode', + label: '업체코드', + placeholder: '업체코드로 검색...', + }, + { + id: 'contactPerson', + label: '담당자', + placeholder: '담당자로 검색...', + }, +] + +const advancedFilterFields: DataTableAdvancedFilterField<QuotationVendor>[] = [ + { + id: 'vendorName', + label: '업체명', + type: 'text', + }, + { + id: 'vendorCode', + label: '업체코드', + type: 'text', + }, + { + id: 'contactPerson', + label: '담당자', + type: 'text', + }, + { + id: 'quotationAmount', + label: '견적금액', + type: 'number', + }, + { + id: 'status', + label: '상태', + type: 'multi-select', + options: [ + { label: '제출완료', value: 'submitted' }, + { label: '선정완료', value: 'selected' }, + { label: '미제출', value: 'pending' }, + ], + }, +] + +export function BiddingDetailVendorTableContent({ + biddingId, + vendors, + onRefresh, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onEdit, + onDelete, + onSelectWinner +}: BiddingDetailVendorTableContentProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + + const handleDelete = (vendor: QuotationVendor) => { + if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return + + startTransition(async () => { + const response = await deleteQuotationVendor(vendor.id) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onRefresh() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleSelectWinner = (vendor: QuotationVendor) => { + if (!vendor.awardRatio || vendor.awardRatio <= 0) { + toast({ + title: '오류', + description: '발주비율을 먼저 설정해주세요.', + variant: 'destructive', + }) + return + } + + if (!confirm(`${vendor.vendorName} 업체를 낙찰자로 선정하시겠습니까?`)) return + + startTransition(async () => { + const result = selectWinnerSchema.safeParse({ + biddingId, + vendorId: vendor.id, + awardRatio: vendor.awardRatio, + }) + + if (!result.success) { + toast({ + title: '유효성 오류', + description: result.error.issues[0]?.message || '입력값을 확인해주세요.', + variant: 'destructive', + }) + return + } + + const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio, 'current-user') + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onRefresh() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleEdit = (vendor: QuotationVendor) => { + setSelectedVendor(vendor) + setIsEditDialogOpen(true) + } + + const columns = React.useMemo( + () => getBiddingDetailVendorColumns({ + onEdit: onEdit || handleEdit, + onDelete: onDelete || handleDelete, + onSelectWinner: onSelectWinner || handleSelectWinner + }), + [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner] + ) + + const { table } = useDataTable({ + data: vendors, + columns, + pageCount: 1, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'vendorName', desc: false }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (originalRow) => originalRow.id.toString(), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <BiddingDetailVendorToolbarActions + table={table} + biddingId={biddingId} + onOpenItemsDialog={onOpenItemsDialog} + onOpenTargetPriceDialog={onOpenTargetPriceDialog} + onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} + + onSuccess={onRefresh} + /> + </DataTableAdvancedToolbar> + </DataTable> + + <BiddingDetailVendorEditDialog + vendor={selectedVendor} + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + onSuccess={onRefresh} + /> + </> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx new file mode 100644 index 00000000..00daa005 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -0,0 +1,79 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { Plus } from "lucide-react" +import { QuotationVendor } from "@/lib/bidding/detail/service" +import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" + +interface BiddingDetailVendorToolbarActionsProps { + table: Table<QuotationVendor> + biddingId: number + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + + onSuccess: () => void +} + +export function BiddingDetailVendorToolbarActions({ + table, + biddingId, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onSuccess +}: BiddingDetailVendorToolbarActionsProps) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) + + const handleCreateVendor = () => { + setIsCreateDialogOpen(true) + } + + return ( + <> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={onOpenItemsDialog} + > + 품목 정보 + </Button> + <Button + variant="outline" + size="sm" + onClick={onOpenTargetPriceDialog} + > + 내정가 산정 + </Button> + <Button + variant="outline" + size="sm" + onClick={onOpenSelectionReasonDialog} + > + 선정 사유 + </Button> + <Button + variant="default" + size="sm" + onClick={handleCreateVendor} + > + <Plus className="mr-2 h-4 w-4" /> + 업체 추가 + </Button> + </div> + + <BiddingDetailVendorCreateDialog + biddingId={biddingId} + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + onSuccess={() => { + onSuccess() + setIsCreateDialogOpen(false) + }} + /> + </> + ) +} |
