diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
| commit | 675b4e3d8ffcb57a041db285417d81e61284d900 (patch) | |
| tree | 254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/vendor | |
| parent | 39f12cb19f29cbc5568057e154e6adf4789ae736 (diff) | |
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/vendor')
| -rw-r--r-- | lib/rfq-last/vendor/add-vendor-dialog.tsx | 438 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/delete-vendor-dialog.tsx | 124 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/edit-contract-dialog.tsx | 237 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 463 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 739 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-detail-dialog.tsx | 695 |
6 files changed, 2494 insertions, 202 deletions
diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx index d8745298..8566763f 100644 --- a/lib/rfq-last/vendor/add-vendor-dialog.tsx +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -24,7 +24,7 @@ import { PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react"; +import { Check, ChevronsUpDown, Loader2, X, Plus, FileText, Shield, Globe, Settings } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { addVendorsToRfq } from "../service"; @@ -34,6 +34,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Info } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface VendorContract { + vendorId: number; + agreementYn: boolean; + ndaYn: boolean; + gtcType: "general" | "project" | "none"; +} interface AddVendorDialogProps { open: boolean; @@ -52,6 +63,17 @@ export function AddVendorDialog({ const [vendorOpen, setVendorOpen] = React.useState(false); const [vendorList, setVendorList] = React.useState<any[]>([]); const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]); + const [activeTab, setActiveTab] = React.useState<"vendors" | "contracts">("vendors"); + + // 각 벤더별 기본계약 요구사항 상태 + const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]); + + // 일괄 적용용 기본값 + const [defaultContract, setDefaultContract] = React.useState({ + agreementYn: true, + ndaYn: true, + gtcType: "none" as "general" | "project" | "none" + }); // 벤더 로드 const loadVendors = React.useCallback(async () => { @@ -76,13 +98,33 @@ export function AddVendorDialog({ React.useEffect(() => { if (!open) { setSelectedVendors([]); + setVendorContracts([]); + setActiveTab("vendors"); + setDefaultContract({ + agreementYn: true, + ndaYn: true, + gtcType: "none" + }); } }, [open]); // 벤더 추가 const handleAddVendor = (vendor: any) => { if (!selectedVendors.find(v => v.id === vendor.id)) { - setSelectedVendors([...selectedVendors, vendor]); + const updatedVendors = [...selectedVendors, vendor]; + setSelectedVendors(updatedVendors); + + // 해당 벤더의 기본계약 설정 추가 + const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + setVendorContracts([ + ...vendorContracts, + { + vendorId: vendor.id, + agreementYn: defaultContract.agreementYn, + ndaYn: defaultContract.ndaYn, + gtcType: isInternational ? defaultContract.gtcType : "none" + } + ]); } setVendorOpen(false); }; @@ -90,9 +132,36 @@ export function AddVendorDialog({ // 벤더 제거 const handleRemoveVendor = (vendorId: number) => { setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)); + setVendorContracts(vendorContracts.filter(c => c.vendorId !== vendorId)); }; - // 제출 처리 - 벤더만 추가 + // 개별 벤더의 계약 설정 업데이트 + const updateVendorContract = (vendorId: number, field: string, value: any) => { + setVendorContracts(contracts => + contracts.map(c => + c.vendorId === vendorId ? { ...c, [field]: value } : c + ) + ); + }; + + // 모든 벤더에 일괄 적용 + const applyToAll = () => { + setVendorContracts(contracts => + contracts.map(c => { + const vendor = selectedVendors.find(v => v.id === c.vendorId); + const isInternational = vendor?.country && vendor.country !== "KR" && vendor.country !== "한국"; + return { + ...c, + agreementYn: defaultContract.agreementYn, + ndaYn: defaultContract.ndaYn, + gtcType: isInternational ? defaultContract.gtcType : "none" + }; + }) + ); + toast.success("모든 벤더에 기본계약 설정이 적용되었습니다."); + }; + + // 제출 처리 const handleSubmit = async () => { if (selectedVendors.length === 0) { toast.error("최소 1개 이상의 벤더를 선택해주세요."); @@ -102,18 +171,32 @@ export function AddVendorDialog({ setIsLoading(true); try { - const vendorIds = selectedVendors.map(v => v.id); - const result = await addVendorsToRfq({ - rfqId, - vendorIds, - // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력) - conditions: null, - }); + // 각 벤더별로 개별 추가 + const results = await Promise.all( + selectedVendors.map(async (vendor) => { + const contract = vendorContracts.find(c => c.vendorId === vendor.id); + return addVendorsToRfq({ + rfqId, + vendorIds: [vendor.id], + conditions: null, + contractRequirements: contract || defaultContract + }); + }) + ); + + // 결과 확인 + const successCount = results.filter(r => r.success).length; + const failedCount = results.length - successCount; - if (result.success) { + if (successCount > 0) { toast.success( <div> - <p>{selectedVendors.length}개 벤더가 추가되었습니다.</p> + <p>{successCount}개 벤더가 추가되었습니다.</p> + {failedCount > 0 && ( + <p className="text-sm text-destructive mt-1"> + {failedCount}개 벤더 추가 실패 + </p> + )} <p className="text-sm text-muted-foreground mt-1"> 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. </p> @@ -122,7 +205,7 @@ export function AddVendorDialog({ onSuccess(); onOpenChange(false); } else { - toast.error(result.error || "벤더 추가에 실패했습니다."); + toast.error("벤더 추가에 실패했습니다."); } } catch (error) { console.error("Submit error:", error); @@ -137,38 +220,41 @@ export function AddVendorDialog({ return selectedVendors.some(v => v.id === vendorId); }; + // 선택된 벤더가 있고 계약 탭으로 이동 가능한지 + const canProceedToContracts = selectedVendors.length > 0; + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-2xl max-h-[80vh] p-0 flex flex-col"> + <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> {/* 헤더 */} <DialogHeader className="p-6 pb-0"> <DialogTitle>벤더 추가</DialogTitle> <DialogDescription> - 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + 견적 요청을 보낼 벤더를 선택하고 각 벤더별 기본계약 요구사항을 설정하세요. </DialogDescription> </DialogHeader> - {/* 컨텐츠 영역 */} - <div className="flex-1 px-6 py-4 overflow-y-auto"> - <div className="space-y-4"> - {/* 안내 메시지 */} - <Alert> - <Info className="h-4 w-4" /> - <AlertDescription> - 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 - '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. - </AlertDescription> - </Alert> - - {/* 벤더 선택 카드 */} + {/* 탭 */} + <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0"> + <TabsList className="mx-6 grid w-fit grid-cols-2"> + <TabsTrigger value="vendors"> + 1. 벤더 선택 + {selectedVendors.length > 0 && ( + <Badge variant="secondary" className="ml-2"> + {selectedVendors.length} + </Badge> + )} + </TabsTrigger> + <TabsTrigger value="contracts" disabled={!canProceedToContracts}> + 2. 기본계약 설정 + </TabsTrigger> + </TabsList> + + {/* 벤더 선택 탭 */} + <TabsContent value="vendors" className="flex-1 flex flex-col px-6 py-4 overflow-y-auto min-h-0"> <Card> <CardHeader> - <div className="flex items-center justify-between"> - <CardTitle className="text-lg">벤더 선택</CardTitle> - <Badge variant="outline" className="ml-2"> - {selectedVendors.length}개 선택됨 - </Badge> - </div> + <CardTitle className="text-lg">벤더 선택</CardTitle> <CardDescription> RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. </CardDescription> @@ -196,11 +282,11 @@ export function AddVendorDialog({ <Command> <CommandInput placeholder="벤더명 또는 코드로 검색..." /> <CommandList - onWheel={(e) => { - e.stopPropagation(); // 이벤트 전파 차단 - const target = e.currentTarget; - target.scrollTop += e.deltaY; // 직접 스크롤 처리 - }} + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> @@ -218,9 +304,12 @@ export function AddVendorDialog({ </Badge> <span className="truncate">{vendor.vendorName}</span> {vendor.country && ( - <span className="text-xs text-muted-foreground ml-auto"> + <Badge + variant={vendor.country === "KR" || vendor.country === "한국" ? "default" : "secondary"} + className="ml-auto" + > {vendor.country} - </span> + </Badge> )} </div> </CommandItem> @@ -234,41 +323,46 @@ export function AddVendorDialog({ {/* 선택된 벤더 목록 */} {selectedVendors.length > 0 && ( <div className="space-y-2"> - <Label className="text-sm text-muted-foreground">선택된 벤더 목록</Label> - <ScrollArea className="h-[200px] w-full rounded-md border p-4"> - <div className="space-y-2"> - {selectedVendors.map((vendor, index) => ( - <div - key={vendor.id} - className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" - > - <div className="flex items-center gap-2"> - <span className="text-sm text-muted-foreground"> - {index + 1}. - </span> - <Badge variant="outline"> - {vendor.vendorCode} + + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + {vendor.country && ( + <Badge + variant={vendor.country === "KR" || vendor.country === "한국" ? "default" : "secondary"} + className="text-xs" + > + {vendor.country} </Badge> - <span className="text-sm font-medium"> - {vendor.vendorName} - </span> - </div> - <Button - variant="ghost" - size="sm" - onClick={() => handleRemoveVendor(vendor.id)} - className="h-8 w-8 p-0" - > - <X className="h-4 w-4" /> - </Button> + )} </div> - ))} - </div> - </ScrollArea> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> </div> )} - {/* 벤더가 없는 경우 메시지 */} {selectedVendors.length === 0 && ( <div className="text-center py-8 text-muted-foreground"> <p className="text-sm">아직 선택된 벤더가 없습니다.</p> @@ -278,8 +372,177 @@ export function AddVendorDialog({ </div> </CardContent> </Card> - </div> - </div> + </TabsContent> + + {/* 기본계약 설정 탭 */} + <TabsContent value="contracts" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> + <div className="flex-1 overflow-y-auto space-y-4 min-h-0"> + {/* 일괄 적용 카드 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <Settings className="h-4 w-4" /> + 일괄 적용 설정 + </CardTitle> + <CardDescription> + 모든 벤더에 동일한 설정을 적용할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + id="default-agreement" + checked={defaultContract.agreementYn} + onCheckedChange={(checked) => + setDefaultContract({ ...defaultContract, agreementYn: !!checked }) + } + /> + <label htmlFor="default-agreement" className="text-sm font-medium"> + 기술자료 제공 동의 + </label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="default-nda" + checked={defaultContract.ndaYn} + onCheckedChange={(checked) => + setDefaultContract({ ...defaultContract, ndaYn: !!checked }) + } + /> + <label htmlFor="default-nda" className="text-sm font-medium"> + 비밀유지 계약 (NDA) + </label> + </div> + </div> + <div className="space-y-2"> + <Label className="text-sm">GTC (국외 업체용)</Label> + <RadioGroup + value={defaultContract.gtcType} + onValueChange={(value: any) => + setDefaultContract({ ...defaultContract, gtcType: value }) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id="default-gtc-none" /> + <label htmlFor="default-gtc-none" className="text-sm">없음</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id="default-gtc-general" /> + <label htmlFor="default-gtc-general" className="text-sm">General GTC</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id="default-gtc-project" /> + <label htmlFor="default-gtc-project" className="text-sm">Project GTC</label> + </div> + </RadioGroup> + </div> + </div> + <Button + variant="secondary" + size="sm" + onClick={applyToAll} + className="w-full" + > + 모든 벤더에 적용 + </Button> + </CardContent> + </Card> + + {/* 개별 벤더 설정 */} + <Card className="flex flex-col min-h-0"> + <CardHeader className="pb-3"> + <CardTitle className="text-base">개별 벤더 기본계약 설정</CardTitle> + <CardDescription> + 각 벤더별로 다른 기본계약을 요구할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="flex-1 min-h-0"> + <ScrollArea className="h-[250px] pr-4"> + <div className="space-y-4"> + {selectedVendors.map((vendor) => { + const contract = vendorContracts.find(c => c.vendorId === vendor.id); + const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + + return ( + <div key={vendor.id} className="border rounded-lg p-4 space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span className="font-medium">{vendor.vendorName}</span> + <Badge + variant={isInternational ? "secondary" : "default"} + className="text-xs" + > + {vendor.country || "미지정"} + </Badge> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + checked={contract?.agreementYn || false} + onCheckedChange={(checked) => + updateVendorContract(vendor.id, "agreementYn", !!checked) + } + /> + <label className="text-sm">기술자료 제공</label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + checked={contract?.ndaYn || false} + onCheckedChange={(checked) => + updateVendorContract(vendor.id, "ndaYn", !!checked) + } + /> + <label className="text-sm">NDA</label> + </div> + </div> + + {isInternational && ( + <div className="space-y-1"> + <Label className="text-xs">GTC</Label> + <RadioGroup + value={contract?.gtcType || "none"} + onValueChange={(value) => + updateVendorContract(vendor.id, "gtcType", value) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id={`${vendor.id}-none`} /> + <label htmlFor={`${vendor.id}-none`} className="text-xs">없음</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id={`${vendor.id}-general`} /> + <label htmlFor={`${vendor.id}-general`} className="text-xs">General</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id={`${vendor.id}-project`} /> + <label htmlFor={`${vendor.id}-project`} className="text-xs">Project</label> + </div> + </RadioGroup> + </div> + )} + + {!isInternational && ( + <div className="text-xs text-muted-foreground"> + 국내 업체 - GTC 불필요 + </div> + )} + </div> + </div> + ); + })} + </div> + </ScrollArea> + </CardContent> + </Card> + </div> + </TabsContent> + </Tabs> {/* 푸터 */} <DialogFooter className="p-6 pt-0 border-t"> @@ -290,16 +553,25 @@ export function AddVendorDialog({ > 취소 </Button> - <Button - onClick={handleSubmit} - disabled={isLoading || selectedVendors.length === 0} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {selectedVendors.length > 0 - ? `${selectedVendors.length}개 벤더 추가` - : '벤더 추가' - } - </Button> + {activeTab === "vendors" && canProceedToContracts && ( + <Button + onClick={() => setActiveTab("contracts")} + > + 다음: 기본계약 설정 + </Button> + )} + {activeTab === "contracts" && ( + <Button + onClick={handleSubmit} + disabled={isLoading || selectedVendors.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendors.length > 0 + ? `${selectedVendors.length}개 벤더 추가` + : '벤더 추가' + } + </Button> + )} </DialogFooter> </DialogContent> </Dialog> diff --git a/lib/rfq-last/vendor/delete-vendor-dialog.tsx b/lib/rfq-last/vendor/delete-vendor-dialog.tsx new file mode 100644 index 00000000..7634509e --- /dev/null +++ b/lib/rfq-last/vendor/delete-vendor-dialog.tsx @@ -0,0 +1,124 @@ +// components/delete-vendor-dialog.tsx +"use client"; + +import * as React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { deleteRfqVendor } from "../service"; + +interface DeleteVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + vendorData: { + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string | null; + hasQuotation: boolean; // quotationStatus가 있는지 여부 + }; + onSuccess?: () => void; +} + +export function DeleteVendorDialog({ + open, + onOpenChange, + rfqId, + vendorData, + onSuccess, +}: DeleteVendorDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false); + + const handleDelete = async () => { + // quotationStatus가 있으면 삭제 불가 (추가 보호) + if (vendorData.hasQuotation) { + toast.error("견적서가 제출된 벤더는 삭제할 수 없습니다."); + return; + } + + try { + setIsDeleting(true); + + const result = await deleteRfqVendor({ + rfqId, + detailId: vendorData.detailId, + vendorId: vendorData.vendorId, + }); + + if (result.success) { + toast.success(result.message || "벤더가 삭제되었습니다."); + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(result.message || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("벤더 삭제 실패:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + } + }; + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-destructive" /> + 벤더 삭제 확인 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-2"> + <p> + <strong>{vendorData.vendorName}</strong> + {vendorData.vendorCode && ` (${vendorData.vendorCode})`}을(를) + RFQ 목록에서 삭제하시겠습니까? + </p> + + {vendorData.hasQuotation && ( + <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"> + <p className="font-semibold">⚠️ 주의: 견적서가 제출된 벤더입니다.</p> + <p>견적서가 제출된 벤더는 삭제할 수 없습니다.</p> + </div> + )} + + {!vendorData.hasQuotation && ( + <p className="text-sm text-muted-foreground"> + 이 작업은 되돌릴 수 없습니다. 삭제 후에는 해당 벤더의 모든 RFQ 관련 정보가 제거됩니다. + </p> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + disabled={isDeleting || vendorData.hasQuotation} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 삭제 중... + </> + ) : ( + "삭제" + )} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/edit-contract-dialog.tsx b/lib/rfq-last/vendor/edit-contract-dialog.tsx new file mode 100644 index 00000000..62b851fa --- /dev/null +++ b/lib/rfq-last/vendor/edit-contract-dialog.tsx @@ -0,0 +1,237 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { FileText, Shield, Globe, Info, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { updateVendorContractRequirements } from "../service"; + +interface EditContractDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + vendor: { + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string; + vendorCountry?: string; + agreementYn?: boolean; + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + gtcType?: "general" | "project" | "none"; + }; + onSuccess: () => void; +} + +export function EditContractDialog({ + open, + onOpenChange, + rfqId, + vendor, + onSuccess, +}: EditContractDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + + // 기본계약 상태 + const [contractAgreement, setContractAgreement] = React.useState(false); + const [contractNDA, setContractNDA] = React.useState(false); + const [contractGTC, setContractGTC] = React.useState<"general" | "project" | "none">("none"); + + // 국외 업체 확인 + const isInternational = React.useMemo(() => { + return vendor?.vendorCountry && + vendor.vendorCountry !== "KR" && + vendor.vendorCountry !== "한국"; + }, [vendor]); + + // 초기값 설정 + React.useEffect(() => { + if (open && vendor) { + setContractAgreement(vendor.agreementYn || false); + setContractNDA(vendor.ndaYn || false); + + // GTC 타입 결정 + if (vendor.gtcType) { + setContractGTC(vendor.gtcType); + } else if (vendor.generalGtcYn) { + setContractGTC("general"); + } else if (vendor.projectGtcYn) { + setContractGTC("project"); + } else { + setContractGTC("none"); + } + } + }, [open, vendor]); + + // 제출 처리 + const handleSubmit = async () => { + setIsLoading(true); + + try { + const result = await updateVendorContractRequirements({ + rfqId, + detailId: vendor.detailId, + contractRequirements: { + agreementYn: contractAgreement, + ndaYn: contractNDA, + gtcType: isInternational ? contractGTC : "none", + }, + }); + + if (result.success) { + toast.success("기본계약 요구사항이 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "업데이트에 실패했습니다."); + } + } catch (error) { + console.error("Update error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>기본계약 수정</DialogTitle> + <DialogDescription> + <div className="flex items-center gap-2 mt-2"> + <Badge variant="outline">{vendor?.vendorCode}</Badge> + <span className="text-sm font-medium">{vendor?.vendorName}</span> + {vendor?.vendorCountry && ( + <Badge + variant={isInternational ? "secondary" : "default"} + className="text-xs" + > + {vendor.vendorCountry} + </Badge> + )} + </div> + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 필수 계약 */} + <div className="space-y-3"> + <Label className="text-sm font-semibold">필수 계약</Label> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-agreement" + checked={contractAgreement} + onCheckedChange={(checked) => setContractAgreement(!!checked)} + /> + <label + htmlFor="edit-agreement" + className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + <FileText className="h-4 w-4 text-blue-500" /> + 기술자료 제공 동의 + </label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-nda" + checked={contractNDA} + onCheckedChange={(checked) => setContractNDA(!!checked)} + /> + <label + htmlFor="edit-nda" + className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + <Shield className="h-4 w-4 text-green-500" /> + 비밀유지 계약 (NDA) + </label> + </div> + </div> + </div> + + {/* GTC 선택 (국외 업체만) */} + {isInternational && ( + <> + <Separator /> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-semibold flex items-center gap-2"> + <Globe className="h-4 w-4" /> + GTC (General Terms & Conditions) + </Label> + <Badge variant="outline" className="text-xs"> + 국외 업체 + </Badge> + </div> + <RadioGroup + value={contractGTC} + onValueChange={(value: any) => setContractGTC(value)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id="edit-gtc-none" /> + <label htmlFor="edit-gtc-none" className="text-sm"> + GTC 요구하지 않음 + </label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id="edit-gtc-general" /> + <label htmlFor="edit-gtc-general" className="text-sm"> + General GTC + </label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id="edit-gtc-project" /> + <label htmlFor="edit-gtc-project" className="text-sm"> + Project GTC + </label> + </div> + </RadioGroup> + </div> + </> + )} + + {/* 국내 업체 안내 */} + {!isInternational && ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 국내 업체는 GTC가 적용되지 않습니다. + </AlertDescription> + </Alert> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isLoading}> + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index b2ea7588..830fd448 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -25,7 +25,9 @@ import { Package, MapPin, Info, - Loader2 + Loader2, + Router, + Shield } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -52,14 +54,18 @@ import { toast } from "sonner"; import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; -// import { VendorDetailDialog } from "./vendor-detail-dialog"; -// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action"; + import { getRfqSendData, getSelectedVendorsWithEmails, + sendRfqToVendors, type RfqSendData, type VendorEmailInfo } from "../service" +import { VendorResponseDetailDialog } from "./vendor-detail-dialog"; +import { DeleteVendorDialog } from "./delete-vendor-dialog"; +import { useRouter } from "next/navigation" +import { EditContractDialog } from "./edit-contract-dialog"; // 타입 정의 interface RfqDetail { @@ -91,20 +97,64 @@ interface RfqDetail { sparepartDescription?: string | null; updatedAt?: Date | null; updatedByUserName?: string | null; + emailSentAt: string | null; + emailSentTo: string | null; // JSON string + emailResentCount: number; + lastEmailSentAt: string | null; + emailStatus: string | null; } interface VendorResponse { id: number; - vendorId: number; - status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + rfqsLastId: number; + rfqLastDetailsId: number; responseVersion: number; isLatest: boolean; - submittedAt: Date | null; - totalAmount: number | null; - currency: string | null; - vendorDeliveryDate: Date | null; - quotedItemCount?: number; - attachmentCount?: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + vendor: { + id: number; + code: string | null; + name: string; + email: string; + }; + submission: { + submittedAt: Date | null; + submittedBy: string | null; + submittedByName: string | null; + }; + pricing: { + totalAmount: number | null; + currency: string | null; + vendorCurrency: string | null; + }; + vendorTerms: { + paymentTermsCode: string | null; + incotermsCode: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + }; + additionalRequirements: { + firstArticle: { + required: boolean | null; + acceptance: boolean | null; + }; + sparePart: { + required: boolean | null; + acceptance: boolean | null; + }; + }; + counts: { + quotedItems: number; + attachments: number; + }; + remarks: { + general: string | null; + technical: string | null; + }; + timestamps: { + createdAt: string; + updatedAt: string; + }; } // Props 타입 정의 @@ -178,7 +228,7 @@ const mergeVendorData = ( ): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { return rfqDetails.map(detail => { const response = vendorResponses.find( - r => r.vendorId === detail.vendorId && r.isLatest + r => r.vendor.id === detail.vendorId && r.isLatest ); return { ...detail, response, rfqCode }; }); @@ -208,6 +258,14 @@ export function RfqVendorTable({ const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); const [isLoadingSendData, setIsLoadingSendData] = React.useState(false); + const [deleteVendorData, setDeleteVendorData] = React.useState<{ + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string | null; + hasResponse?: boolean; + responseStatus?: string | null; + } | null>(null); const [sendDialogData, setSendDialogData] = React.useState<{ rfqInfo: RfqSendData['rfqInfo'] | null; @@ -219,12 +277,19 @@ export function RfqVendorTable({ selectedVendors: [], }); + const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null); + + + const router = useRouter() + // 데이터 병합 const mergedData = React.useMemo( () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), [rfqDetails, vendorResponses, rfqCode] ); + console.log(mergedData, "mergedData") + // 일괄 발송 핸들러 const handleBulkSend = React.useCallback(async () => { if (selectedRows.length === 0) { @@ -277,6 +342,11 @@ export function RfqVendorTable({ contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, + ndaYn: v.ndaYn, + generalGtcYn: v.generalGtcYn, + projectGtcYn: v.projectGtcYn, + agreementYn: v.agreementYn, + sendVersion: v.sendVersion })), }); @@ -297,25 +367,38 @@ export function RfqVendorTable({ vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; - vendorEmail?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; currency?: string | null; - additionalRecipients: string[]; + contractRequirements?: { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; + }; + isResend: boolean; + sendVersion?: number; }>; attachments: number[]; message?: string; + generatedPdfs?: Array<{ // 타입 추가 + key: string; + buffer: number[]; + fileName: string; + }>; }) => { try { // 서버 액션 호출 - // const result = await sendRfqToVendors({ - // rfqId, - // rfqCode, - // vendors: data.vendors, - // attachmentIds: data.attachments, - // message: data.message, - // }); - - // 임시 성공 처리 - console.log("RFQ 발송 데이터:", data); + const result = await sendRfqToVendors({ + rfqId, + rfqCode, + vendors: data.vendors, + attachmentIds: data.attachments, + message: data.message, + generatedPdfs: data.generatedPdfs, + }); // 성공 후 처리 setSelectedRows([]); @@ -324,14 +407,23 @@ export function RfqVendorTable({ attachments: [], selectedVendors: [], }); + + // 기본계약 생성 결과 표시 + if (result.contractResults && result.contractResults.length > 0) { + const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`); + } else { + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + } - toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + // 페이지 새로고침 + router.refresh(); } catch (error) { console.error("RFQ 발송 실패:", error); toast.error("RFQ 발송에 실패했습니다."); throw error; } - }, [rfqId, rfqCode]); + }, [rfqId, rfqCode, router]); // 액션 처리 const handleAction = React.useCallback(async (action: string, vendor: any) => { @@ -344,7 +436,7 @@ export function RfqVendorTable({ // 개별 RFQ 발송 try { setIsLoadingSendData(true); - + const [rfqSendData, vendorEmailInfos] = await Promise.all([ getRfqSendData(rfqId), getSelectedVendorsWithEmails(rfqId, [vendor.vendorId]) @@ -369,6 +461,11 @@ export function RfqVendorTable({ contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, + ndaYn: v.ndaYn, + generalGtcYn: v.generalGtcYn, + projectGtcYn: v.projectGtcYn, + agreementYn: v.agreementYn, + sendVersion: v.sendVersion, })), }); @@ -385,10 +482,29 @@ export function RfqVendorTable({ toast.info("수정 기능은 준비중입니다."); break; + case "edit-contract": + // 기본계약 수정 + setEditContractVendor(vendor); + break; + case "delete": - if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { - toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + // quotationStatus 체크 + const hasQuotation = !!vendor.quotationStatus; + + if (hasQuotation) { + // 견적서가 있으면 즉시 에러 토스트 표시 + toast.error("이미 발송된 벤더는 삭제할 수 없습니다."); + return; } + + // 삭제 다이얼로그 열기 + setDeleteVendorData({ + detailId: vendor.detailId, + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + hasQuotation: hasQuotation, + }); break; case "response-detail": @@ -486,12 +602,188 @@ export function RfqVendorTable({ }, size: 100, }, + { - accessorKey: "basicContract", - header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, - cell: ({ row }) => row.original.basicContract || "-", - size: 100, + accessorKey: "contractRequirements", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, + cell: ({ row }) => { + const vendor = row.original; + const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; + + // 기본계약 상태 확인 + const requirements = []; + + // 필수 계약들 + if (vendor.agreementYn) { + requirements.push({ + name: "기술자료", + icon: <FileText className="h-3 w-3" />, + color: "text-blue-500" + }); + } + + if (vendor.ndaYn) { + requirements.push({ + name: "NDA", + icon: <Shield className="h-3 w-3" />, + color: "text-green-500" + }); + } + + // GTC (국외 업체만) + if (!isKorean) { + if (vendor.generalGtcYn || vendor.gtcType === "general") { + requirements.push({ + name: "General GTC", + icon: <Globe className="h-3 w-3" />, + color: "text-purple-500" + }); + } else if (vendor.projectGtcYn || vendor.gtcType === "project") { + requirements.push({ + name: "Project GTC", + icon: <Globe className="h-3 w-3" />, + color: "text-indigo-500" + }); + } + } + + if (requirements.length === 0) { + return <span className="text-xs text-muted-foreground">없음</span>; + } + + return ( + <div className="flex flex-wrap gap-1"> + {requirements.map((req, idx) => ( + <TooltipProvider key={idx}> + <Tooltip> + <TooltipTrigger asChild> + <Badge variant="outline" className="text-xs px-1.5 py-0"> + <span className={cn("mr-1", req.color)}> + {req.icon} + </span> + {req.name} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {req.name === "기술자료" && "기술자료 제공 동의서"} + {req.name === "NDA" && "비밀유지 계약서"} + {req.name === "General GTC" && "일반 거래 약관"} + {req.name === "Project GTC" && "프로젝트별 거래 약관"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ))} + </div> + ); + }, + size: 150, }, + + { + accessorKey: "sendVersion", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />, + cell: ({ row }) => { + const version = row.original.sendVersion; + + + return <span>{version}</span>; + }, + size: 80, + }, + { + accessorKey: "emailStatus", + header: "이메일 상태", + cell: ({ row }) => { + const response = row.original; + const emailSentAt = response?.emailSentAt; + const emailResentCount = response?.emailResentCount || 0; + const emailStatus = response?.emailStatus; + const status = response?.status; + + if (!emailSentAt) { + return ( + <Badge variant="outline" className="bg-gray-50"> + <Mail className="h-3 w-3 mr-1" /> + 미발송 + </Badge> + ); + } + + // 이메일 상태 표시 (failed인 경우 특별 처리) + const getEmailStatusBadge = () => { + if (emailStatus === "failed") { + return ( + <Badge variant="destructive"> + <XCircle className="h-3 w-3 mr-1" /> + 발송 실패 + </Badge> + ); + } + return ( + <Badge variant={status === "제출완료" ? "success" : "default"}> + {getStatusIcon(status || "")} + {status} + </Badge> + ); + }; + + // emailSentTo JSON 파싱 + let recipients = { to: [], cc: [], sentBy: "" }; + try { + if (response?.email?.emailSentTo) { + recipients = JSON.parse(response.email.emailSentTo); + } + } catch (e) { + console.error("Failed to parse emailSentTo", e); + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-col gap-1"> + {getEmailStatusBadge()} + {emailResentCount > 1 && ( + <Badge variant="secondary" className="text-xs"> + 재발송 {emailResentCount - 1}회 + </Badge> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p>최초 발송: {format(new Date(emailSentAt), "yyyy-MM-dd HH:mm")}</p> + {response?.email?.lastEmailSentAt && ( + <p>최근 발송: {format(new Date(response.email.lastEmailSentAt), "yyyy-MM-dd HH:mm")}</p> + )} + {recipients.to.length > 0 && ( + <p>수신자: {recipients.to.join(", ")}</p> + )} + {recipients.cc.length > 0 && ( + <p>참조: {recipients.cc.join(", ")}</p> + )} + {recipients.sentBy && ( + <p>발신자: {recipients.sentBy}</p> + )} + {emailStatus === "failed" && ( + <p className="text-red-500 font-semibold">⚠️ 이메일 발송 실패</p> + )} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + // { + // accessorKey: "basicContract", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + // cell: ({ row }) => row.original.basicContract || "-", + // size: 100, + // }, { accessorKey: "currency", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, @@ -641,20 +933,23 @@ export function RfqVendorTable({ size: 120, }, { - accessorKey: "response.submittedAt", + accessorKey: "response.submission.submittedAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, cell: ({ row }) => { - const submittedAt = row.original.response?.submittedAt; + const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; - if (!submittedAt) { - return <Badge variant="outline">미참여</Badge>; + if (!participationRepliedAt) { + return <Badge variant="outline">미응답</Badge>; } + + const participationStatus = row.original.response?.attend?.participationStatus; + return ( <div className="flex flex-col gap-0.5"> - <Badge variant="default" className="text-xs">참여</Badge> + <Badge variant="default" className="text-xs">{participationStatus}</Badge> <span className="text-xs text-muted-foreground"> - {format(new Date(submittedAt), "MM-dd")} + {format(new Date(participationRepliedAt), "yyyy-MM-dd")} </span> </div> ); @@ -665,7 +960,7 @@ export function RfqVendorTable({ id: "responseDetail", header: "회신상세", cell: ({ row }) => { - const hasResponse = !!row.original.response?.submittedAt; + const hasResponse = !!row.original.response?.submission?.submittedAt; if (!hasResponse) { return <span className="text-muted-foreground text-xs">-</span>; @@ -731,6 +1026,10 @@ export function RfqVendorTable({ cell: ({ row }) => { const vendor = row.original; const hasResponse = !!vendor.response; + const emailSentAt = vendor.response?.email?.emailSentAt; + const emailResentCount = vendor.response?.email?.emailResentCount || 0; + const hasQuotation = !!vendor.quotationStatus; + const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; return ( <DropdownMenu> @@ -747,8 +1046,33 @@ export function RfqVendorTable({ <Eye className="mr-2 h-4 w-4" /> 상세보기 </DropdownMenuItem> - {!hasResponse && ( - <DropdownMenuItem + + {/* 기본계약 수정 메뉴 추가 */} + <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> + <FileText className="mr-2 h-4 w-4" /> + 기본계약 수정 + </DropdownMenuItem> + + {emailSentAt && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("resend", vendor)} + disabled={isLoadingSendData} + > + <RefreshCw className="mr-2 h-4 w-4" /> + 이메일 재발송 + {emailResentCount > 0 && ( + <Badge variant="outline" className="ml-2 text-xs"> + {emailResentCount} + </Badge> + )} + </DropdownMenuItem> + </> + )} + + {!emailSentAt && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)} disabled={isLoadingSendData} > @@ -756,24 +1080,28 @@ export function RfqVendorTable({ RFQ 발송 </DropdownMenuItem> )} - <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> - <Edit className="mr-2 h-4 w-4" /> - 조건 수정 - </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => handleAction("delete", vendor)} - className="text-red-600" + className={cn( + "text-red-600", + hasQuotation && "opacity-50 cursor-not-allowed" + )} + disabled={hasQuotation} > <Trash2 className="mr-2 h-4 w-4" /> 삭제 + {hasQuotation && ( + <span className="ml-2 text-xs">(불가)</span> + )} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }, size: 60, - }, + } ], [handleAction, rfqCode, isLoadingSendData]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ @@ -850,7 +1178,7 @@ export function RfqVendorTable({ ) : ( <> <Send className="h-4 w-4 mr-2" /> - 선택 발송 ({selectedRows.length}) + RFQ 발송 ({selectedRows.length}) </> )} </Button> @@ -924,14 +1252,43 @@ export function RfqVendorTable({ /> {/* 벤더 상세 다이얼로그 */} - {/* {selectedVendor && ( - <VendorDetailDialog + {selectedVendor && ( + <VendorResponseDetailDialog open={!!selectedVendor} onOpenChange={(open) => !open && setSelectedVendor(null)} - vendor={selectedVendor} + data={selectedVendor} + rfqId={rfqId} + /> + )} + + {/* 삭제 다이얼로그 추가 */} + {deleteVendorData && ( + <DeleteVendorDialog + open={!!deleteVendorData} + onOpenChange={(open) => !open && setDeleteVendorData(null)} rfqId={rfqId} + vendorData={deleteVendorData} + onSuccess={() => { + setDeleteVendorData(null); + router.refresh(); + // 데이터 새로고침 + }} /> - )} */} + )} + + {/* 기본계약 수정 다이얼로그 - 새로 추가 */} + {editContractVendor && ( + <EditContractDialog + open={!!editContractVendor} + onOpenChange={(open) => !open && setEditContractVendor(null)} + rfqId={rfqId} + vendor={editContractVendor} + onSuccess={() => { + setEditContractVendor(null); + router.refresh(); + }} + /> + )} </> ); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index 9d88bdc9..619ea749 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -39,7 +39,9 @@ import { Building, ChevronDown, ChevronRight, - UserPlus + UserPlus, + Shield, + Globe } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -74,6 +76,25 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Progress } from "@/components/ui/progress"; + +interface ContractToGenerate { + vendorId: number; + vendorName: string; + type: string; + templateName: string; +} + // 타입 정의 interface ContactDetail { id: number; @@ -102,6 +123,15 @@ interface Vendor { contactsByPosition?: Record<string, ContactDetail[]>; primaryEmail?: string | null; currency?: string | null; + + // 기본계약 정보 + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + agreementYn?: boolean; + + // 발송 정보 + sendVersion?: number; } interface Attachment { @@ -149,9 +179,29 @@ interface SendRfqDialogProps { rfqInfo: RfqInfo; attachments?: Attachment[]; onSend: (data: { - vendors: VendorWithRecipients[]; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; + currency?: string | null; + contractRequirements?: { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; + }; + isResend: boolean; + sendVersion?: number; + contractsSkipped?: boolean; + }>; attachments: number[]; message?: string; + generatedPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>; }) => Promise<void>; } @@ -175,49 +225,6 @@ const getAttachmentIcon = (type: string) => { } }; -// 파일 크기 포맷 -const formatFileSize = (bytes?: number) => { - if (!bytes) return "0 KB"; - const kb = bytes / 1024; - const mb = kb / 1024; - if (mb >= 1) return `${mb.toFixed(2)} MB`; - return `${kb.toFixed(2)} KB`; -}; - -// 포지션별 아이콘 -const getPositionIcon = (position?: string | null) => { - if (!position) return <User className="h-3 w-3" />; - - const lowerPosition = position.toLowerCase(); - if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { - return <Building2 className="h-3 w-3" />; - } - if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { - return <Briefcase className="h-3 w-3" />; - } - if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { - return <Package className="h-3 w-3" />; - } - return <User className="h-3 w-3" />; -}; - -// 포지션별 색상 -const getPositionColor = (position?: string | null) => { - if (!position) return 'default'; - - const lowerPosition = position.toLowerCase(); - if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { - return 'destructive'; - } - if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { - return 'success'; - } - if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { - return 'secondary'; - } - return 'default'; -}; - export function SendRfqDialog({ open, onOpenChange, @@ -226,6 +233,7 @@ export function SendRfqDialog({ attachments = [], onSend, }: SendRfqDialogProps) { + const [isSending, setIsSending] = React.useState(false); const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]); const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); @@ -233,6 +241,118 @@ export function SendRfqDialog({ const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]); const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({}); const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); + const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false); + const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] }); + + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false); + const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0); + const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState(""); + const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map()); + + // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 + const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({}); + + const generateContractPdf = async ( + vendor: VendorWithRecipients, + contractType: string, + templateName: string + ): Promise<{ buffer: number[], fileName: string }> => { + try { + // 1. 템플릿 데이터 준비 (서버 액션 호출) + const prepareResponse = await fetch("/api/contracts/prepare-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templateName, + vendorId: vendor.vendorId, + }), + }); + + if (!prepareResponse.ok) { + throw new Error("템플릿 준비 실패"); + } + + const { template, templateData } = await prepareResponse.json(); + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }); + + const templateBlob = await templateResponse.blob(); + const templateFile = new window.File([templateBlob], "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + + // 3. PDFtron WebViewer로 PDF 변환 + const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData); + + const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`; + + return { + buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 + fileName + }; + } catch (error) { + console.error(`PDF 생성 실패 (${vendor.vendorName} - ${contractType}):`, error); + throw error; + } + }; + + // PDFtron WebViewer 변환 함수 + const convertToPdfWithWebViewer = async ( + templateFile: File, + templateData: Record<string, string> + ): Promise<Uint8Array> => { + const { default: WebViewer } = await import("@pdftron/webviewer"); + + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + tempDiv.style.position = 'absolute'; + tempDiv.style.top = '-9999px'; + tempDiv.style.left = '-9999px'; + tempDiv.style.width = '1px'; + tempDiv.style.height = '1px'; + document.body.appendChild(tempDiv); + + try { + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const { Core } = instance; + const { createDocument } = Core; + + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }); + + await templateDoc.applyTemplateValues(templateData); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const fileData = await templateDoc.getFileData(); + const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); + + instance.UI.dispose(); + return new Uint8Array(pdfBuffer); + + } finally { + if (tempDiv.parentNode) { + document.body.removeChild(tempDiv); + } + } + }; // 초기화 React.useEffect(() => { @@ -254,6 +374,15 @@ export function SendRfqDialog({ // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); + + // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) + const skipOptions: Record<number, boolean> = {}; + selectedVendors.forEach(v => { + if (v.sendVersion && v.sendVersion > 0) { + skipOptions[v.vendorId] = false; // 기본값은 재생성 + } + }); + setSkipContractsForVendor(skipOptions); } }, [open, selectedVendors, attachments]); @@ -378,11 +507,145 @@ export function SendRfqDialog({ ); }; - // 전송 처리 - const handleSend = async () => { + // 실제 발송 처리 함수 (재발송 확인 후 또는 바로 실행) + const proceedWithSend = React.useCallback(async () => { try { setIsSending(true); + + // 기본계약이 필요한 계약서 목록 수집 + const contractsToGenerate: ContractToGenerate[] = []; + + for (const vendor of vendorsWithRecipients) { + // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 + const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; + if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { + continue; // 이 벤더의 계약서 생성을 스킵 + } + + if (vendor.ndaYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "NDA", + templateName: "비밀" + }); + } + if (vendor.generalGtcYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "General_GTC", + templateName: "General GTC" + }); + } + if (vendor.projectGtcYn && rfqInfo?.projectCode) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "Project_GTC", + templateName: rfqInfo.projectCode + }); + } + if (vendor.agreementYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "기술자료", + templateName: "기술" + }); + } + } + + let pdfsMap = new Map<string, { buffer: number[], fileName: string }>(); + // PDF 생성이 필요한 경우 + if (contractsToGenerate.length > 0) { + setIsGeneratingPdfs(true); + setPdfGenerationProgress(0); + + try { + let completed = 0; + + for (const contract of contractsToGenerate) { + setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); + + const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); + if (!vendor) continue; + + const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); + pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); + + completed++; + setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + setGeneratedPdfs(pdfsMap); // UI 업데이트용 + } catch (error) { + console.error("PDF 생성 실패:", error); + toast.error("기본계약서 생성에 실패했습니다."); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + return; + } + } + + // RFQ 발송 - pdfsMap을 직접 사용 + setIsGeneratingPdfs(false); + setIsSending(true); + + await onSend({ + vendors: vendorsWithRecipients.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + selectedMainEmail: v.selectedMainEmail, + additionalEmails: v.additionalEmails, + customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), + currency: v.currency, + contractRequirements: { + ndaYn: v.ndaYn || false, + generalGtcYn: v.generalGtcYn || false, + projectGtcYn: v.projectGtcYn || false, + agreementYn: v.agreementYn || false, + projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, + }, + isResend: (v.sendVersion || 0) > 0, + sendVersion: v.sendVersion, + contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], + })), + attachments: selectedAttachments, + message: additionalMessage, + // 생성된 PDF 데이터 추가 + generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ + key, + ...data + })), + }); + + toast.success( + `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + + (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') + ); + onOpenChange(false); + + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + setCurrentGeneratingContract(""); + setSkipContractsForVendor({}); // 초기화 + } +}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); + + // 전송 처리 + const handleSend = async () => { + try { // 유효성 검사 const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail); if (vendorsWithoutEmail.length > 0) { @@ -395,22 +658,23 @@ export function SendRfqDialog({ return; } - await onSend({ - vendors: vendorsWithRecipients.map(v => ({ - ...v, - additionalRecipients: v.additionalEmails, - })), - attachments: selectedAttachments, - message: additionalMessage, - }); + // 재발송 업체 확인 + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + if (resendVendors.length > 0) { + // AlertDialog를 표시하기 위해 상태 설정 + setResendVendorsInfo({ + count: resendVendors.length, + names: resendVendors.map(v => v.vendorName) + }); + setShowResendConfirmDialog(true); + return; // 여기서 일단 중단하고 다이얼로그 응답을 기다림 + } - toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`); - onOpenChange(false); + // 재발송 업체가 없으면 바로 진행 + await proceedWithSend(); } catch (error) { - console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); - } finally { - setIsSending(false); + console.error("RFQ 발송 준비 실패:", error); + toast.error("RFQ 발송 준비에 실패했습니다."); } }; @@ -437,6 +701,35 @@ export function SendRfqDialog({ {/* ScrollArea 대신 div 사용 */} <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}> <div className="space-y-6 pr-4"> + {/* 재발송 경고 메시지 - 재발송 업체가 있을 때만 표시 */} + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( + <Alert className="border-yellow-500 bg-yellow-50"> + <AlertCircle className="h-4 w-4 text-yellow-600" /> + <AlertTitle className="text-yellow-800">재발송 경고</AlertTitle> + <AlertDescription className="text-yellow-700 space-y-3"> + <ul className="list-disc list-inside space-y-1"> + <li>재발송 대상 업체의 기존 견적 데이터가 초기화됩니다.</li> + <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li> + <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> + </ul> + + {/* 기본계약 재발송 정보 */} + <div className="mt-3 pt-3 border-t border-yellow-400"> + <div className="space-y-2"> + <p className="text-sm font-medium flex items-center gap-2"> + <FileText className="h-4 w-4" /> + 기본계약서 재발송 설정 + </p> + <p className="text-xs text-yellow-600"> + 각 재발송 업체별로 기본계약서 재생성 여부를 선택할 수 있습니다. + 아래 표에서 업체별로 설정해주세요. + </p> + </div> + </div> + </AlertDescription> + </Alert> + )} + {/* RFQ 정보 섹션 */} <div className="space-y-4"> <div className="flex items-center gap-2 text-sm font-medium"> @@ -521,6 +814,40 @@ export function SendRfqDialog({ <tr> <th className="text-left p-2 text-xs font-medium">No.</th> <th className="text-left p-2 text-xs font-medium">업체명</th> + <th className="text-left p-2 text-xs font-medium">기본계약</th> + <th className="text-left p-2 text-xs font-medium"> + <div className="flex items-center gap-2"> + <span>계약서 재발송</span> + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-5 px-1 text-xs" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const allChecked = resendVendors.every(v => !skipContractsForVendor[v.vendorId]); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = allChecked; + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : + Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} + </Button> + </TooltipTrigger> + <TooltipContent> + 재발송 업체 전체 선택/해제 + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + </th> <th className="text-left p-2 text-xs font-medium">주 수신자</th> <th className="text-left p-2 text-xs font-medium">CC</th> <th className="text-left p-2 text-xs font-medium">작업</th> @@ -559,13 +886,41 @@ export function SendRfqDialog({ const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); const isFormOpen = showCustomEmailForm[vendor.vendorId]; + const isResend = vendor.sendVersion && vendor.sendVersion > 0; + + // 기본계약 요구사항 확인 + const contracts = []; + if (vendor.ndaYn) contracts.push({ name: "NDA", icon: <Shield className="h-3 w-3" /> }); + if (vendor.generalGtcYn) contracts.push({ name: "General GTC", icon: <Globe className="h-3 w-3" /> }); + if (vendor.projectGtcYn) contracts.push({ name: "Project GTC", icon: <Building className="h-3 w-3" /> }); + if (vendor.agreementYn) contracts.push({ name: "기술자료", icon: <FileText className="h-3 w-3" /> }); return ( <React.Fragment key={vendor.vendorId}> <tr className="border-b hover:bg-muted/20"> <td className="p-2"> - <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> - {index + 1} + <div className="flex items-center gap-1"> + <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> + {index + 1} + </div> + {isResend && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="warning" className="text-xs"> + 재발송 + </Badge> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-semibold">⚠️ 재발송 경고</p> + <p className="text-xs">발송 회차: {vendor.sendVersion + 1}회차</p> + <p className="text-xs text-yellow-600">기존 견적 데이터가 초기화됩니다</p> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} </div> </td> <td className="p-2"> @@ -582,6 +937,86 @@ export function SendRfqDialog({ </div> </td> <td className="p-2"> + {contracts.length > 0 ? ( + <div className="flex flex-wrap gap-1"> + {/* 재전송이고 스킵 옵션이 켜져 있으면 표시 */} + {isResend && skipContractsForVendor[vendor.vendorId] ? ( + <Badge variant="secondary" className="text-xs px-1"> + <CheckCircle className="h-3 w-3 mr-1 text-green-500" /> + <span>기존 계약서 유지</span> + </Badge> + ) : ( + contracts.map((contract, idx) => ( + <TooltipProvider key={idx}> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="text-xs px-1"> + {contract.icon} + <span className="ml-1">{contract.name}</span> + {isResend && !skipContractsForVendor[vendor.vendorId] && ( + <RefreshCw className="h-3 w-3 ml-1 text-orange-500" /> + )} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {contract.name === "NDA" && "비밀유지 계약서 요청"} + {contract.name === "General GTC" && "일반 거래약관 요청"} + {contract.name === "Project GTC" && `프로젝트 거래약관 요청 (${rfqInfo?.projectCode})`} + {contract.name === "기술자료" && "기술자료 제공 동의서 요청"} + {isResend && !skipContractsForVendor[vendor.vendorId] && ( + <span className="block mt-1 text-orange-400">⚠️ 재생성됨</span> + )} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + )} + </div> + ) : ( + <span className="text-xs text-muted-foreground">없음</span> + )} + </td> + <td className="p-2"> + {isResend && contracts.length > 0 ? ( + <div className="flex items-center justify-center"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-2"> + <Checkbox + checked={!skipContractsForVendor[vendor.vendorId]} + onCheckedChange={(checked) => { + setSkipContractsForVendor(prev => ({ + ...prev, + [vendor.vendorId]: !checked + })); + }} + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + /> + <span className="text-xs"> + {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"} + </span> + </div> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {skipContractsForVendor[vendor.vendorId] + ? "기존 계약서를 그대로 유지합니다" + : "기존 계약서를 삭제하고 새로 생성합니다"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ) : ( + <span className="text-xs text-muted-foreground text-center block"> + {isResend ? "계약서 없음" : "-"} + </span> + )} + </td> + <td className="p-2"> <Select value={vendor.selectedMainEmail} onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)} @@ -676,7 +1111,7 @@ export function SendRfqDialog({ {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} {isFormOpen && ( <tr className="bg-muted/10 border-b"> - <td colSpan={5} className="p-4"> + <td colSpan={7} className="p-4"> <div className="space-y-3"> <div className="flex items-center justify-between mb-2"> <div className="flex items-center gap-2 text-sm font-medium"> @@ -871,6 +1306,29 @@ export function SendRfqDialog({ onChange={(e) => setAdditionalMessage(e.target.value)} /> </div> + + {/* PDF 생성 진행 상황 표시 */} + {isGeneratingPdfs && ( + <Alert className="border-blue-500 bg-blue-50"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <RefreshCw className="h-4 w-4 animate-spin text-blue-600" /> + <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle> + </div> + <AlertDescription> + <div className="space-y-2"> + <p className="text-sm text-blue-700">{currentGeneratingContract}</p> + <Progress value={pdfGenerationProgress} className="h-2" /> + <p className="text-xs text-blue-600"> + {Math.round(pdfGenerationProgress)}% 완료 + </p> + </div> + </AlertDescription> + </div> + </Alert> + )} + + </div> </div> @@ -892,9 +1350,14 @@ export function SendRfqDialog({ </Button> <Button onClick={handleSend} - disabled={isSending || selectedAttachments.length === 0} + disabled={isSending || isGeneratingPdfs || selectedAttachments.length === 0} > - {isSending ? ( + {isGeneratingPdfs ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + 계약서 생성중... ({Math.round(pdfGenerationProgress)}%) + </> + ) : isSending ? ( <> <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> 발송중... @@ -908,6 +1371,150 @@ export function SendRfqDialog({ </Button> </DialogFooter> </DialogContent> + + {/* 재발송 확인 다이얼로그 */} + <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}> + <AlertDialogContent className="max-w-2xl"> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertCircle className="h-5 w-5 text-yellow-600" /> + 재발송 확인 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-4"> + <p className="text-sm"> + <span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다. + </p> + + {/* 재발송 대상 업체 목록 및 계약서 설정 */} + <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3"> + <p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p> + <div className="space-y-2"> + {vendorsWithRecipients + .filter(v => v.sendVersion && v.sendVersion > 0) + .map(vendor => { + const contracts = []; + if (vendor.ndaYn) contracts.push("NDA"); + if (vendor.generalGtcYn) contracts.push("General GTC"); + if (vendor.projectGtcYn) contracts.push("Project GTC"); + if (vendor.agreementYn) contracts.push("기술자료"); + + return ( + <div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100"> + <div className="flex items-center gap-3"> + <span className="w-1.5 h-1.5 bg-yellow-600 rounded-full" /> + <div> + <span className="text-sm font-medium text-yellow-900">{vendor.vendorName}</span> + {contracts.length > 0 && ( + <div className="text-xs text-yellow-700 mt-0.5"> + 계약서: {contracts.join(", ")} + </div> + )} + </div> + </div> + {contracts.length > 0 && ( + <div className="flex items-center gap-2"> + <Checkbox + checked={!skipContractsForVendor[vendor.vendorId]} + onCheckedChange={(checked) => { + setSkipContractsForVendor(prev => ({ + ...prev, + [vendor.vendorId]: !checked + })); + }} + className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + /> + <span className="text-xs text-yellow-800"> + {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"} + </span> + </div> + )} + </div> + ); + })} + </div> + + {/* 전체 선택 버튼 */} + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && + (v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && ( + <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = true; // 모두 유지 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 유지 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = false; // 모두 재생성 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 재생성 + </Button> + </div> + )} + </div> + + {/* 경고 메시지 */} + <Alert className="border-red-200 bg-red-50"> + <AlertCircle className="h-4 w-4 text-red-600" /> + <AlertTitle className="text-red-800">중요 안내사항</AlertTitle> + <AlertDescription className="text-red-700 space-y-2"> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li> + <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li> + <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> + {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && + vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && ( + <li className="text-orange-700 font-medium"> + ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. + </li> + )} + <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li> + </ul> + </AlertDescription> + </Alert> + + <p className="text-sm text-muted-foreground"> + 재발송을 진행하시겠습니까? + </p> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => { + setShowResendConfirmDialog(false); + // 취소 시 옵션은 유지 (사용자가 설정한 상태 그대로) + }}> + 취소 + </AlertDialogCancel> + <AlertDialogAction + onClick={() => { + setShowResendConfirmDialog(false); + proceedWithSend(); + }} + > + <RefreshCw className="h-4 w-4 mr-2" /> + 재발송 진행 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </Dialog> ); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index e69de29b..e4c78656 100644 --- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx @@ -0,0 +1,695 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { + Building2, + Calendar, + DollarSign, + FileText, + Package, + Globe, + MapPin, + Clock, + CheckCircle, + XCircle, + AlertCircle, + Download, + Eye, + User, + Mail, + Phone, + CreditCard, + Truck, + Shield, + Paperclip, + Info, + Edit, +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +// Props 타입 정의 +interface VendorResponseDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + data: any; // mergedData의 row + rfqId: number; +} + +// 상태별 설정 +const getStatusConfig = (status: string) => { + switch (status) { + case "초대됨": + return { + icon: <Mail className="h-4 w-4" />, + color: "text-blue-600", + bgColor: "bg-blue-50", + variant: "secondary" as const, + }; + case "작성중": + return { + icon: <Clock className="h-4 w-4" />, + color: "text-yellow-600", + bgColor: "bg-yellow-50", + variant: "outline" as const, + }; + case "제출완료": + return { + icon: <CheckCircle className="h-4 w-4" />, + color: "text-green-600", + bgColor: "bg-green-50", + variant: "default" as const, + }; + case "수정요청": + return { + icon: <AlertCircle className="h-4 w-4" />, + color: "text-orange-600", + bgColor: "bg-orange-50", + variant: "warning" as const, + }; + case "최종확정": + return { + icon: <Shield className="h-4 w-4" />, + color: "text-indigo-600", + bgColor: "bg-indigo-50", + variant: "success" as const, + }; + case "취소": + return { + icon: <XCircle className="h-4 w-4" />, + color: "text-red-600", + bgColor: "bg-red-50", + variant: "destructive" as const, + }; + default: + return { + icon: <Info className="h-4 w-4" />, + color: "text-gray-600", + bgColor: "bg-gray-50", + variant: "outline" as const, + }; + } +}; + +export function VendorResponseDetailDialog({ + open, + onOpenChange, + data, + rfqId, +}: VendorResponseDetailDialogProps) { + if (!data) return null; + + const response = data.response; + const statusConfig = getStatusConfig(response?.status || "초대됨"); + const hasSubmitted = !!response?.submission?.submittedAt; + + // 이메일 발송 정보 파싱 + let emailRecipients = { to: [], cc: [], sentBy: "" }; + try { + if (data.emailSentTo) { + emailRecipients = JSON.parse(data.emailSentTo); + } + } catch (e) { + console.error("Failed to parse emailSentTo"); + } + + // 견적 아이템 (실제로는 response.quotationItems에서 가져옴) + const quotationItems = response?.quotationItems || []; + + // 첨부파일 (실제로는 response.attachments에서 가져옴) + const attachments = response?.attachments || []; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <div className="flex items-center justify-between"> + <div> + <DialogTitle className="text-xl font-bold"> + 벤더 응답 상세 + </DialogTitle> + <DialogDescription className="mt-1"> + {data.vendorName} ({data.vendorCode || "코드없음"}) - {data.rfqCode} + </DialogDescription> + </div> + <div className="flex items-center gap-2"> + {/* {onEdit && ( + <Button variant="outline" size="sm" onClick={onEdit}> + <Edit className="h-4 w-4 mr-2" /> + 수정 + </Button> + )} */} + </div> + </div> + </DialogHeader> + + <Tabs defaultValue="overview" className="mt-4"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="overview">개요</TabsTrigger> + <TabsTrigger value="quotation">견적정보</TabsTrigger> + <TabsTrigger value="items">품목상세</TabsTrigger> + <TabsTrigger value="attachments">첨부파일</TabsTrigger> + </TabsList> + + {/* 개요 탭 */} + <TabsContent value="overview" className="space-y-4"> + {/* 상태 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">응답 상태</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">현재 상태</span> + <Badge variant={statusConfig.variant}> + {statusConfig.icon} + <span className="ml-1">{response?.status || "초대됨"}</span> + </Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">응답 버전</span> + <span className="font-medium">v{response?.responseVersion || 1}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">Short List</span> + <Badge variant={data.shortList ? "default" : "outline"}> + {data.shortList ? "선정" : "대기"} + </Badge> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제출일시</span> + <span className="text-sm"> + {response?.submission?.submittedAt + ? format(new Date(response.submission.submittedAt), "yyyy-MM-dd HH:mm", { locale: ko }) + : "-"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제출자</span> + <span className="text-sm">{response?.submission?.submittedByName || "-"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">최종 수정일</span> + <span className="text-sm"> + {data.updatedAt + ? format(new Date(data.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) + : "-"} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 벤더 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">벤더 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">업체명</span> + <span className="font-medium ml-auto">{data.vendorName}</span> + </div> + <div className="flex items-center gap-2"> + <Package className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">업체코드</span> + <span className="font-medium ml-auto">{data.vendorCode || "-"}</span> + </div> + <div className="flex items-center gap-2"> + <Globe className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">국가</span> + <Badge variant={data.vendorCountry === "KR" ? "default" : "secondary"} className="ml-auto"> + {data.vendorCountry} + </Badge> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Mail className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">이메일</span> + <span className="text-sm ml-auto">{response?.vendor?.email || "-"}</span> + </div> + <div className="flex items-center gap-2"> + <Shield className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">업체분류</span> + <span className="font-medium ml-auto">{data.vendorCategory || "-"}</span> + </div> + <div className="flex items-center gap-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">AVL 등급</span> + <span className="font-medium ml-auto">{data.vendorGrade || "-"}</span> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 이메일 발송 정보 */} + {data.emailSentAt && ( + <Card> + <CardHeader> + <CardTitle className="text-base">이메일 발송 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">최초 발송일시</span> + <span className="text-sm"> + {format(new Date(data.emailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })} + </span> + </div> + {data.lastEmailSentAt && data.emailResentCount > 1 && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">최근 재발송일시</span> + <span className="text-sm"> + {format(new Date(data.lastEmailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })} + <Badge variant="secondary" className="ml-2"> + 재발송 {data.emailResentCount - 1}회 + </Badge> + </span> + </div> + )} + {emailRecipients.to.length > 0 && ( + <div className="flex items-start justify-between"> + <span className="text-sm text-muted-foreground">수신자</span> + <span className="text-sm text-right">{emailRecipients.to.join(", ")}</span> + </div> + )} + {emailRecipients.cc.length > 0 && ( + <div className="flex items-start justify-between"> + <span className="text-sm text-muted-foreground">참조</span> + <span className="text-sm text-right">{emailRecipients.cc.join(", ")}</span> + </div> + )} + {emailRecipients.sentBy && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">발신자</span> + <span className="text-sm">{emailRecipients.sentBy}</span> + </div> + )} + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">발송 상태</span> + <Badge variant={data.emailStatus === "failed" ? "destructive" : "default"}> + {data.emailStatus === "failed" ? "발송 실패" : "발송 완료"} + </Badge> + </div> + </div> + </CardContent> + </Card> + )} + </TabsContent> + + {/* 견적정보 탭 */} + <TabsContent value="quotation" className="space-y-4"> + {/* 요청 조건 */} + <Card> + <CardHeader> + <CardTitle className="text-base">요청 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">통화</span> + <Badge variant="outline">{data.currency}</Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">지급조건</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <span className="font-medium">{data.paymentTermsCode}</span> + </TooltipTrigger> + {data.paymentTermsDescription && ( + <TooltipContent> + <p>{data.paymentTermsDescription}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">인코텀즈</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <span className="font-medium">{data.incotermsCode}</span> + </TooltipTrigger> + {data.incotermsDescription && ( + <TooltipContent> + <p>{data.incotermsDescription}</p> + {data.incotermsDetail && <p className="text-xs">{data.incotermsDetail}</p>} + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">Tax</span> + <span className="font-medium">{data.taxCode || "-"}</span> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">납기일</span> + <span className="text-sm"> + {data.deliveryDate + ? format(new Date(data.deliveryDate), "yyyy-MM-dd") + : "-"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">계약기간</span> + <span className="text-sm">{data.contractDuration || "-"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">선적지</span> + <span className="text-sm">{data.placeOfShipping || "-"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">도착지</span> + <span className="text-sm">{data.placeOfDestination || "-"}</span> + </div> + </div> + </div> + + {/* 추가 조건 */} + <Separator className="my-4" /> + <div className="space-y-3"> + {data.firstYn && ( + <div className="flex items-start justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">초도품</Badge> + <span className="text-sm text-muted-foreground">요구사항</span> + </div> + <span className="text-sm text-right max-w-xs">{data.firstDescription || "초도품 제출 필요"}</span> + </div> + )} + {data.sparepartYn && ( + <div className="flex items-start justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">스페어파트</Badge> + <span className="text-sm text-muted-foreground">요구사항</span> + </div> + <span className="text-sm text-right max-w-xs">{data.sparepartDescription || "스페어파트 제공 필요"}</span> + </div> + )} + {data.materialPriceRelatedYn && ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">연동제</Badge> + <span className="text-sm text-muted-foreground">적용</span> + </div> + <span className="text-sm">적용</span> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 벤더 제안 조건 (제출된 경우) */} + {hasSubmitted && ( + <Card> + <CardHeader> + <CardTitle className="text-base">벤더 제안 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 통화</span> + <Badge variant={response?.pricing?.vendorCurrency === data.currency ? "outline" : "default"}> + {response?.pricing?.vendorCurrency || data.currency} + </Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 지급조건</span> + <span className="font-medium"> + {response?.vendorTerms?.paymentTermsCode || data.paymentTermsCode} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 인코텀즈</span> + <span className="font-medium"> + {response?.vendorTerms?.incotermsCode || data.incotermsCode} + </span> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 납기일</span> + <span className="text-sm"> + {response?.vendorTerms?.deliveryDate + ? format(new Date(response.vendorTerms.deliveryDate), "yyyy-MM-dd") + : data.deliveryDate + ? format(new Date(data.deliveryDate), "yyyy-MM-dd") + : "-"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">총 견적금액</span> + <span className="font-bold text-lg"> + {response?.pricing?.totalAmount + ? new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: response.pricing.vendorCurrency || data.currency, + }).format(response.pricing.totalAmount) + : "-"} + </span> + </div> + </div> + </div> + + {/* 벤더 추가 응답 */} + {(response?.additionalRequirements?.firstArticle?.acceptance || + response?.additionalRequirements?.sparePart?.acceptance) && ( + <> + <Separator className="my-4" /> + <div className="space-y-3"> + {response?.additionalRequirements?.firstArticle?.acceptance && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">초도품 수용여부</span> + <Badge + variant={ + response.additionalRequirements.firstArticle.acceptance === "수용" + ? "default" + : response.additionalRequirements.firstArticle.acceptance === "부분수용" + ? "secondary" + : "destructive" + } + > + {response.additionalRequirements.firstArticle.acceptance} + </Badge> + </div> + )} + {response?.additionalRequirements?.sparePart?.acceptance && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">스페어파트 수용여부</span> + <Badge + variant={ + response.additionalRequirements.sparePart.acceptance === "수용" + ? "default" + : response.additionalRequirements.sparePart.acceptance === "부분수용" + ? "secondary" + : "destructive" + } + > + {response.additionalRequirements.sparePart.acceptance} + </Badge> + </div> + )} + </div> + </> + )} + + {/* 벤더 비고 */} + {(response?.remarks?.general || response?.remarks?.technical) && ( + <> + <Separator className="my-4" /> + <div className="space-y-3"> + {response?.remarks?.general && ( + <div> + <span className="text-sm text-muted-foreground">일반 비고</span> + <p className="mt-1 text-sm">{response.remarks.general}</p> + </div> + )} + {response?.remarks?.technical && ( + <div> + <span className="text-sm text-muted-foreground">기술 제안</span> + <p className="mt-1 text-sm">{response.remarks.technical}</p> + </div> + )} + </div> + </> + )} + </CardContent> + </Card> + )} + </TabsContent> + + {/* 품목상세 탭 */} + <TabsContent value="items" className="space-y-4"> + {quotationItems.length > 0 ? ( + <Card> + <CardHeader> + <CardTitle className="text-base">견적 품목 상세</CardTitle> + <CardDescription> + 총 {quotationItems.length}개 품목 + </CardDescription> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>PR No.</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> + {quotationItems.map((item: any) => ( + <TableRow key={item.id}> + <TableCell className="font-mono text-xs">{item.prNo}</TableCell> + <TableCell className="font-mono text-xs">{item.materialCode}</TableCell> + <TableCell className="text-xs">{item.materialDescription}</TableCell> + <TableCell className="text-right">{item.quantity}</TableCell> + <TableCell>{item.uom}</TableCell> + <TableCell className="text-right"> + {new Intl.NumberFormat("ko-KR").format(item.unitPrice)} + </TableCell> + <TableCell className="text-right font-medium"> + {new Intl.NumberFormat("ko-KR").format(item.totalPrice)} + </TableCell> + <TableCell> + {item.vendorDeliveryDate + ? format(new Date(item.vendorDeliveryDate), "MM-dd") + : "-"} + </TableCell> + <TableCell>{item.manufacturer || "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + ) : ( + <Card> + <CardContent className="pt-6"> + <div className="text-center text-muted-foreground"> + 아직 제출된 견적 품목이 없습니다. + </div> + </CardContent> + </Card> + )} + </TabsContent> + + {/* 첨부파일 탭 */} + <TabsContent value="attachments" className="space-y-4"> + {attachments.length > 0 ? ( + <Card> + <CardHeader> + <CardTitle className="text-base">첨부파일</CardTitle> + <CardDescription> + 총 {attachments.length}개 파일 + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-2"> + {attachments.map((file: any) => ( + <div + key={file.id} + className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent" + > + <div className="flex items-center gap-3"> + <Paperclip className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{file.originalFileName}</p> + <p className="text-xs text-muted-foreground"> + {file.attachmentType} • {file.fileSize ? `${(file.fileSize / 1024).toFixed(2)} KB` : "크기 미상"} + {file.description && ` • ${file.description}`} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => { + // 파일 미리보기 로직 + console.log("Preview file:", file.filePath); + }} + > + <Eye className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => { + // 파일 다운로드 로직 + window.open(file.filePath, "_blank"); + }} + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </CardContent> + </Card> + ) : ( + <Card> + <CardContent className="pt-6"> + <div className="text-center text-muted-foreground"> + 아직 제출된 첨부파일이 없습니다. + </div> + </CardContent> + </Card> + )} + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
