diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-04 08:31:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-04 08:31:31 +0000 |
| commit | b67e36df49f067cbd5ba899f9fbcc755f38d4b4f (patch) | |
| tree | 5a71c5960f90d988cd509e3ef26bff497a277661 /lib/bidding/pre-quote/table | |
| parent | b7f54b06c1ef9e619f5358fb0a5caad9703c8905 (diff) | |
(대표님, 최겸, 임수민) 작업사항 커밋
Diffstat (limited to 'lib/bidding/pre-quote/table')
7 files changed, 1231 insertions, 0 deletions
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx new file mode 100644 index 00000000..692d12ea --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx @@ -0,0 +1,57 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' +import { getBiddingCompanies } from '../service' + +import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' + +interface BiddingPreQuoteContentProps { + bidding: Bidding + quotationDetails: QuotationDetails | null + quotationVendors: QuotationVendor[] + biddingCompanies: any[] + prItems: any[] +} + +export function BiddingPreQuoteContent({ + bidding, + quotationDetails, + quotationVendors, + biddingCompanies: initialBiddingCompanies, + prItems +}: BiddingPreQuoteContentProps) { + const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies) + const [refreshTrigger, setRefreshTrigger] = React.useState(0) + + const handleRefresh = React.useCallback(async () => { + try { + const result = await getBiddingCompanies(bidding.id) + if (result.success && result.data) { + setBiddingCompanies(result.data) + } + setRefreshTrigger(prev => prev + 1) + } catch (error) { + console.error('Failed to refresh bidding companies:', error) + } + }, [bidding.id]) + + return ( + <div className="space-y-6"> + <BiddingPreQuoteVendorTableContent + biddingId={bidding.id} + bidding={bidding} + vendors={quotationVendors} + biddingCompanies={biddingCompanies} + onRefresh={handleRefresh} + onOpenItemsDialog={() => {}} + onOpenTargetPriceDialog={() => {}} + onOpenSelectionReasonDialog={() => {}} + onEdit={undefined} + onDelete={undefined} + onSelectWinner={undefined} + /> + </div> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx new file mode 100644 index 00000000..84824c1e --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx @@ -0,0 +1,185 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { sendPreQuoteInvitations } from '../service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { Mail, Building2 } from 'lucide-react' + +interface BiddingPreQuoteInvitationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + companies: BiddingCompany[] + onSuccess: () => void +} + +export function BiddingPreQuoteInvitationDialog({ + open, + onOpenChange, + companies, + onSuccess +}: BiddingPreQuoteInvitationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([]) + + // 초대 가능한 업체들 (pending 상태인 업체들) + const invitableCompanies = companies.filter(company => + company.invitationStatus === 'pending' && company.companyName + ) + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedCompanyIds(invitableCompanies.map(company => company.id)) + } else { + setSelectedCompanyIds([]) + } + } + + const handleSelectCompany = (companyId: number, checked: boolean) => { + if (checked) { + setSelectedCompanyIds(prev => [...prev, companyId]) + } else { + setSelectedCompanyIds(prev => prev.filter(id => id !== companyId)) + } + } + + const handleSendInvitations = () => { + if (selectedCompanyIds.length === 0) { + toast({ + title: '알림', + description: '초대를 발송할 업체를 선택해주세요.', + variant: 'default', + }) + return + } + + startTransition(async () => { + const response = await sendPreQuoteInvitations(selectedCompanyIds) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + setSelectedCompanyIds([]) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + setSelectedCompanyIds([]) + } + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Mail className="w-5 h-5" /> + 사전견적 초대 발송 + </DialogTitle> + <DialogDescription> + 선택한 업체들에게 사전견적 요청을 발송합니다. + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + {invitableCompanies.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 초대 가능한 업체가 없습니다. + </div> + ) : ( + <> + {/* 전체 선택 */} + <div className="flex items-center space-x-2 mb-4 pb-2 border-b"> + <Checkbox + id="select-all" + checked={selectedCompanyIds.length === invitableCompanies.length} + onCheckedChange={handleSelectAll} + /> + <label htmlFor="select-all" className="font-medium"> + 전체 선택 ({invitableCompanies.length}개 업체) + </label> + </div> + + {/* 업체 목록 */} + <div className="space-y-3 max-h-80 overflow-y-auto"> + {invitableCompanies.map((company) => ( + <div key={company.id} className="flex items-center space-x-3 p-3 border rounded-lg"> + <Checkbox + id={`company-${company.id}`} + checked={selectedCompanyIds.includes(company.id)} + onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)} + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <Building2 className="w-4 h-4" /> + <span className="font-medium">{company.companyName}</span> + <Badge variant="outline" className="text-xs"> + {company.companyCode} + </Badge> + </div> + {company.notes && ( + <p className="text-sm text-muted-foreground mt-1"> + {company.notes} + </p> + )} + </div> + <Badge variant="outline"> + 대기중 + </Badge> + </div> + ))} + </div> + + {selectedCompanyIds.length > 0 && ( + <div className="mt-4 p-3 bg-primary/5 rounded-lg"> + <p className="text-sm text-primary"> + <strong>{selectedCompanyIds.length}개 업체</strong>에 사전견적 초대를 발송합니다. + </p> + </div> + )} + </> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => handleOpenChange(false)}> + 취소 + </Button> + <Button + onClick={handleSendInvitations} + disabled={isPending || selectedCompanyIds.length === 0} + > + <Mail className="w-4 h-4 mr-2" /> + 초대 발송 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx new file mode 100644 index 00000000..30cddbce --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -0,0 +1,303 @@ +"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, UserPlus +} from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +// bidding_companies 테이블 타입 정의 (company_condition_responses와 join) +export interface BiddingCompany { + id: number + biddingId: number + companyId: number + invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted' + invitedAt: Date | null + respondedAt: Date | null + preQuoteAmount: string | null + preQuoteSubmittedAt: Date | null + isPreQuoteSelected: boolean + isAttendingMeeting: boolean | null + notes: string | null + contactPerson: string | null + contactEmail: string | null + contactPhone: string | null + createdAt: Date + updatedAt: Date + + // company_condition_responses 필드들 + paymentTermsResponse: string | null + taxConditionsResponse: string | null + proposedContractDeliveryDate: string | null + priceAdjustmentResponse: boolean | null + isInitialResponse: boolean | null + incotermsResponse: string | null + proposedShippingPort: string | null + proposedDestinationPort: string | null + sparePartResponse: string | null + additionalProposals: string | null + + // 조인된 업체 정보 + companyName?: string + companyCode?: string +} + +interface GetBiddingCompanyColumnsProps { + onEdit: (company: BiddingCompany) => void + onDelete: (company: BiddingCompany) => void + onInvite: (company: BiddingCompany) => void +} + +export function getBiddingPreQuoteVendorColumns({ + onEdit, + onDelete, + onInvite +}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] { + 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: 'companyName', + header: '업체명', + cell: ({ row }) => ( + <div className="font-medium">{row.original.companyName || '-'}</div> + ), + }, + { + accessorKey: 'companyCode', + header: '업체코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.original.companyCode || '-'}</div> + ), + }, + { + accessorKey: 'invitationStatus', + header: '초대 상태', + cell: ({ row }) => { + const status = row.original.invitationStatus + const variant = status === 'accepted' ? 'default' : + status === 'declined' ? 'destructive' : 'outline' + + const label = status === 'accepted' ? '수락' : + status === 'declined' ? '거절' : '대기중' + + return <Badge variant={variant}>{label}</Badge> + }, + }, + { + accessorKey: 'preQuoteAmount', + header: '사전견적금액', + cell: ({ row }) => ( + <div className="text-right font-mono"> + {row.original.preQuoteAmount ? Number(row.original.preQuoteAmount).toLocaleString() : '-'} KRW + </div> + ), + }, + { + accessorKey: 'preQuoteSubmittedAt', + header: '사전견적 제출일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + accessorKey: 'isPreQuoteSelected', + header: '본입찰 선정', + cell: ({ row }) => ( + <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}> + {row.original.isPreQuoteSelected ? '선정' : '미선정'} + </Badge> + ), + }, + { + accessorKey: 'isAttendingMeeting', + header: '사양설명회 참석', + cell: ({ row }) => { + const isAttending = row.original.isAttendingMeeting + if (isAttending === null) return <div className="text-sm">-</div> + return ( + <Badge variant={isAttending ? 'default' : 'secondary'}> + {isAttending ? '참석' : '불참석'} + </Badge> + ) + }, + }, + { + 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 }) => { + const isInitial = row.original.isInitialResponse + if (isInitial === null) return <div className="text-sm">-</div> + return ( + <Badge variant={isInitial ? 'default' : 'secondary'}> + {isInitial ? 'Y' : 'N'} + </Badge> + ) + }, + }, + { + accessorKey: 'priceAdjustmentResponse', + header: '연동제', + cell: ({ row }) => { + const hasPriceAdjustment = row.original.priceAdjustmentResponse + if (hasPriceAdjustment === null) return <div className="text-sm">-</div> + return ( + <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}> + {hasPriceAdjustment ? '적용' : '미적용'} + </Badge> + ) + }, + }, + { + 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> + ), + }, + { + accessorKey: 'additionalProposals', + header: '추가제안', + cell: ({ row }) => ( + <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}> + {row.original.additionalProposals || '-'} + </div> + ), + }, + { + accessorKey: 'notes', + header: '특이사항', + cell: ({ row }) => ( + <div className="text-sm max-w-32 truncate" title={row.original.notes || ''}> + {row.original.notes || '-'} + </div> + ), + }, + { + id: 'actions', + header: '작업', + cell: ({ row }) => { + const company = 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(company)}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + {company.invitationStatus === 'pending' && ( + <DropdownMenuItem onClick={() => onInvite(company)}> + <UserPlus className="mr-2 h-4 w-4" /> + 초대 발송 + </DropdownMenuItem> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => onDelete(company)} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + }, + ] +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx new file mode 100644 index 00000000..e2a38547 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx @@ -0,0 +1,205 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Check, ChevronsUpDown } from 'lucide-react' +import { cn } from '@/lib/utils' +import { createBiddingCompany } from '@/lib/bidding/pre-quote/service' +import { searchVendors } from '@/lib/vendors/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorCreateDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface Vendor { + id: number + vendorName: string + vendorCode: string + status: string +} + +export function BiddingPreQuoteVendorCreateDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingPreQuoteVendorCreateDialogProps) { + 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('') + + + // 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 + } + + startTransition(async () => { + const response = await createBiddingCompany({ + biddingId, + companyId: selectedVendor.id, + }) + console.log(response) + 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('') + } + + 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> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleCreate} disabled={isPending || !selectedVendor}> + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx new file mode 100644 index 00000000..03bf2ecb --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx @@ -0,0 +1,200 @@ +'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 { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { updateBiddingCompany } from '../service' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorEditDialogProps { + company: BiddingCompany | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function BiddingPreQuoteVendorEditDialog({ + company, + open, + onOpenChange, + onSuccess +}: BiddingPreQuoteVendorEditDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 폼 상태 + const [formData, setFormData] = React.useState({ + contactPerson: '', + contactEmail: '', + contactPhone: '', + preQuoteAmount: 0, + notes: '', + invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined', + isPreQuoteSelected: false, + isAttendingMeeting: false, + }) + + // company가 변경되면 폼 데이터 업데이트 + React.useEffect(() => { + if (company) { + setFormData({ + contactPerson: company.contactPerson || '', + contactEmail: company.contactEmail || '', + contactPhone: company.contactPhone || '', + preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0, + notes: company.notes || '', + invitationStatus: company.invitationStatus, + isPreQuoteSelected: company.isPreQuoteSelected, + isAttendingMeeting: company.isAttendingMeeting || false, + }) + } + }, [company]) + + const handleEdit = () => { + if (!company) return + + startTransition(async () => { + const response = await updateBiddingCompany(company.id, formData) + + 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> + {company?.companyName} 업체의 사전견적 정보를 수정해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <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-preQuoteAmount">사전견적금액</Label> + <Input + id="edit-preQuoteAmount" + type="number" + value={formData.preQuoteAmount} + onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-invitationStatus">초대 상태</Label> + <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="pending">대기중</SelectItem> + <SelectItem value="accepted">수락</SelectItem> + <SelectItem value="declined">거절</SelectItem> + </SelectContent> + </Select> + </div> + </div> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-isPreQuoteSelected" + checked={formData.isPreQuoteSelected} + onCheckedChange={(checked) => + setFormData({ ...formData, isPreQuoteSelected: !!checked }) + } + /> + <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-isAttendingMeeting" + checked={formData.isAttendingMeeting} + onCheckedChange={(checked) => + setFormData({ ...formData, isAttendingMeeting: !!checked }) + } + /> + <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label> + </div> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-notes">특이사항</Label> + <Textarea + id="edit-notes" + value={formData.notes} + onChange={(e) => setFormData({ ...formData, notes: e.target.value })} + placeholder="특이사항을 입력해주세요..." + /> + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleEdit} disabled={isPending}> + 수정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx new file mode 100644 index 00000000..a9d12629 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx @@ -0,0 +1,189 @@ +'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 { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions' +import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog' +import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { Bidding } from '@/db/schema' +import { + deleteBiddingCompany +} from '../service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorTableContentProps { + biddingId: number + bidding: Bidding + vendors: any[] // 사용하지 않음 + biddingCompanies: BiddingCompany[] + onRefresh: () => void + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + onEdit?: (company: BiddingCompany) => void + onDelete?: (company: BiddingCompany) => void + onSelectWinner?: (company: BiddingCompany) => void +} + +const filterFields: DataTableFilterField<BiddingCompany>[] = [ + { + id: 'companyName', + label: '업체명', + placeholder: '업체명으로 검색...', + }, + { + id: 'companyCode', + label: '업체코드', + placeholder: '업체코드로 검색...', + }, + { + id: 'contactPerson', + label: '담당자', + placeholder: '담당자로 검색...', + }, +] + +const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [ + { + id: 'companyName', + label: '업체명', + type: 'text', + }, + { + id: 'companyCode', + label: '업체코드', + type: 'text', + }, + { + id: 'contactPerson', + label: '담당자', + type: 'text', + }, + { + id: 'preQuoteAmount', + label: '사전견적금액', + type: 'number', + }, + { + id: 'invitationStatus', + label: '초대 상태', + type: 'multi-select', + options: [ + { label: '수락', value: 'accepted' }, + { label: '거절', value: 'declined' }, + { label: '대기중', value: 'pending' }, + ], + }, +] + +export function BiddingPreQuoteVendorTableContent({ + biddingId, + bidding, + vendors, + biddingCompanies, + onRefresh, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onEdit, + onDelete, + onSelectWinner +}: BiddingPreQuoteVendorTableContentProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null) + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + + const handleDelete = (company: BiddingCompany) => { + if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return + + startTransition(async () => { + const response = await deleteBiddingCompany(company.id) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onRefresh() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleEdit = (company: BiddingCompany) => { + setSelectedCompany(company) + setIsEditDialogOpen(true) + } + + const handleInvite = (company: BiddingCompany) => { + // TODO: 초대 발송 로직 구현 + toast({ + title: '알림', + description: `${company.companyName} 업체에 초대를 발송했습니다.`, + }) + } + + const columns = React.useMemo( + () => getBiddingPreQuoteVendorColumns({ + onEdit: onEdit || handleEdit, + onDelete: onDelete || handleDelete, + onInvite: handleInvite + }), + [onEdit, onDelete, handleEdit, handleDelete, handleInvite] + ) + + const { table } = useDataTable({ + data: biddingCompanies, + columns, + pageCount: 1, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'companyName', 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} + > + <BiddingPreQuoteVendorToolbarActions + table={table} + biddingId={biddingId} + bidding={bidding} + biddingCompanies={biddingCompanies} + onOpenItemsDialog={onOpenItemsDialog} + onOpenTargetPriceDialog={onOpenTargetPriceDialog} + onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} + onSuccess={onRefresh} + /> + </DataTableAdvancedToolbar> + </DataTable> + + <BiddingPreQuoteVendorEditDialog + company={selectedCompany} + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + onSuccess={onRefresh} + /> + </> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx new file mode 100644 index 00000000..c1b1baa5 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx @@ -0,0 +1,92 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { useTransition } from "react" +import { Button } from "@/components/ui/button" +import { Plus, Send, Mail } from "lucide-react" +import { BiddingCompany } from "./bidding-pre-quote-vendor-columns" +import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog" +import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog" +import { Bidding } from "@/db/schema" +import { useToast } from "@/hooks/use-toast" + +interface BiddingPreQuoteVendorToolbarActionsProps { + table: Table<BiddingCompany> + biddingId: number + bidding: Bidding + biddingCompanies: BiddingCompany[] + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + onSuccess: () => void +} + +export function BiddingPreQuoteVendorToolbarActions({ + table, + biddingId, + bidding, + biddingCompanies, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onSuccess +}: BiddingPreQuoteVendorToolbarActionsProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) + const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false) + + const handleCreateCompany = () => { + setIsCreateDialogOpen(true) + } + + const handleSendInvitations = () => { + setIsInvitationDialogOpen(true) + } + + + + return ( + <> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleCreateCompany} + disabled={isPending} + > + <Plus className="mr-2 h-4 w-4" /> + 업체 추가 + </Button> + + <Button + variant="default" + size="sm" + onClick={handleSendInvitations} + disabled={isPending} + > + <Mail className="mr-2 h-4 w-4" /> + 초대 발송 + </Button> + </div> + + <BiddingPreQuoteVendorCreateDialog + biddingId={biddingId} + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + onSuccess={() => { + onSuccess() + setIsCreateDialogOpen(false) + }} + /> + + <BiddingPreQuoteInvitationDialog + open={isInvitationDialogOpen} + onOpenChange={setIsInvitationDialogOpen} + companies={biddingCompanies} + onSuccess={onSuccess} + /> + </> + ) +} |
