diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-08 11:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-08 11:23:40 +0000 |
| commit | b84621f9b2b7161a5ad4f0b194264e9df3e65dbf (patch) | |
| tree | ce5ec30b3d1e5104a3a2d942c71973779436783b /lib/b-rfq/initial | |
| parent | 97936ddf280c56a4f122dedcb8dc389d0d2e63a2 (diff) | |
(대표님) 20250708 미반영분 커밋
Diffstat (limited to 'lib/b-rfq/initial')
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 4 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx | 77 | ||||
| -rw-r--r-- | lib/b-rfq/initial/short-list-confirm-dialog.tsx | 269 |
3 files changed, 343 insertions, 7 deletions
diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx index 02dfd765..f2be425c 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx @@ -69,7 +69,7 @@ export function getInitialRfqDetailColumns({ { accessorKey: "initialRfqStatus", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="초기 RFQ 상태" /> + <DataTableColumnHeaderSimple column={column} title="RFQ 상태" /> ), cell: ({ row }) => { const status = row.getValue("initialRfqStatus") as string @@ -93,7 +93,7 @@ export function getInitialRfqDetailColumns({ { accessorKey: "rfqCode", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> + <DataTableColumnHeaderSimple column={column} title="RFQ No." /> ), cell: ({ row }) => ( <div className="text-sm"> diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx index 639d338d..c26bda28 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react" import { AddInitialRfqDialog } from "./add-initial-rfq-dialog" import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" +import { ShortListConfirmDialog } from "./short-list-confirm-dialog" import { InitialRfqDetailView } from "@/db/schema" import { sendBulkInitialRfqEmails } from "../service" @@ -40,15 +41,26 @@ export function InitialRfqDetailTableToolbarActions({ // 상태 관리 const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + const [showShortListDialog, setShowShortListDialog] = React.useState(false) const [isEmailSending, setIsEmailSending] = React.useState(false) - const handleBulkEmail = async () => { + // 전체 벤더 리스트 가져오기 (ShortList 확정용) + const allVendors = table.getRowModel().rows.map(row => row.original) + +const handleBulkEmail = async () => { if (selectedCount === 0) return setIsEmailSending(true) try { - const initialRfqIds = selectedDetails.map(detail => detail.initialRfqId); + const initialRfqIds = selectedDetails + .map(detail => detail.initialRfqId) + .filter((id): id is number => id !== null); + + if (initialRfqIds.length === 0) { + toast.error("유효한 RFQ ID가 없습니다.") + return + } const result = await sendBulkInitialRfqEmails({ initialRfqIds, @@ -113,9 +125,23 @@ export function InitialRfqDetailTableToolbarActions({ // S/L 확정 버튼 클릭 const handleSlConfirm = () => { - if (rfqId) { - router.push(`/evcp/b-rfq/${rfqId}`) + if (!rfqId || allVendors.length === 0) { + toast.error("S/L 확정할 벤더가 없습니다.") + return } + + // 진행 가능한 상태 확인 + const validVendors = allVendors.filter(vendor => + vendor.initialRfqStatus === "Init. RFQ Answered" || + vendor.initialRfqStatus === "Init. RFQ Sent" + ) + + if (validVendors.length === 0) { + toast.error("S/L 확정이 가능한 벤더가 없습니다. (RFQ 발송 또는 응답 완료된 벤더만 가능)") + return + } + + setShowShortListDialog(true) } // 초기 RFQ 추가 성공 시 처리 @@ -146,12 +172,37 @@ export function InitialRfqDetailTableToolbarActions({ } } + // Short List 확정 성공 시 처리 + const handleShortListSuccess = () => { + // 선택 해제 + table.toggleAllRowsSelected(false) + setShowShortListDialog(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + + // 최종 RFQ 페이지로 이동 + if (rfqId) { + toast.success("Short List가 확정되었습니다. 최종 RFQ 페이지로 이동합니다.") + setTimeout(() => { + router.push(`/evcp/b-rfq/${rfqId}`) + }, 1500) + } + } + // 선택된 항목 중 첫 번째를 기본값으로 사용 const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT") const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length + // S/L 확정 가능한 벤더 수 + const validForShortList = allVendors.filter(vendor => + vendor.initialRfqStatus === "Init. RFQ Answered" || + vendor.initialRfqStatus === "Init. RFQ Sent" + ).length return ( <> @@ -191,9 +242,11 @@ export function InitialRfqDetailTableToolbarActions({ size="sm" onClick={handleSlConfirm} className="h-8" + disabled={validForShortList === 0} + title={validForShortList === 0 ? "S/L 확정이 가능한 벤더가 없습니다" : `${validForShortList}개 벤더 중 Short List 선택`} > <CheckCircle2 className="mr-2 h-4 w-4" /> - S/L 확정 + S/L 확정 ({validForShortList}) </Button> )} @@ -215,6 +268,20 @@ export function InitialRfqDetailTableToolbarActions({ showTrigger={false} onSuccess={handleDeleteSuccess} /> + + {/* Short List 확정 다이얼로그 */} + {rfqId && ( + <ShortListConfirmDialog + open={showShortListDialog} + onOpenChange={setShowShortListDialog} + rfqId={rfqId} + vendors={allVendors.filter(vendor => + vendor.initialRfqStatus === "Init. RFQ Answered" || + vendor.initialRfqStatus === "Init. RFQ Sent" + )} + onSuccess={handleShortListSuccess} + /> + )} </> ) }
\ No newline at end of file diff --git a/lib/b-rfq/initial/short-list-confirm-dialog.tsx b/lib/b-rfq/initial/short-list-confirm-dialog.tsx new file mode 100644 index 00000000..92c62dc0 --- /dev/null +++ b/lib/b-rfq/initial/short-list-confirm-dialog.tsx @@ -0,0 +1,269 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { Loader2, Building, CheckCircle2, XCircle } from "lucide-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 { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { shortListConfirm } from "../service" +import { InitialRfqDetailView } from "@/db/schema" + +const shortListSchema = z.object({ + selectedVendorIds: z.array(z.number()).min(1, "최소 1개 이상의 벤더를 선택해야 합니다."), +}) + +type ShortListFormData = z.infer<typeof shortListSchema> + +interface ShortListConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + rfqId: number + vendors: InitialRfqDetailView[] + onSuccess?: () => void +} + +export function ShortListConfirmDialog({ + open, + onOpenChange, + rfqId, + vendors, + onSuccess +}: ShortListConfirmDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm<ShortListFormData>({ + resolver: zodResolver(shortListSchema), + defaultValues: { + selectedVendorIds: vendors + .filter(vendor => vendor.shortList === true) + .map(vendor => vendor.vendorId) + .filter(Boolean) as number[] + }, + }) + + const watchedSelectedIds = form.watch("selectedVendorIds") + + // 선택된/탈락된 벤더 계산 + const selectedVendors = vendors.filter(vendor => + vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId) + ) + const rejectedVendors = vendors.filter(vendor => + vendor.vendorId && !watchedSelectedIds.includes(vendor.vendorId) + ) + + async function onSubmit(data: ShortListFormData) { + if (!rfqId) return + + setIsLoading(true) + + try { + const result = await shortListConfirm({ + rfqId, + selectedVendorIds: data.selectedVendorIds, + rejectedVendorIds: vendors + .filter(v => v.vendorId && !data.selectedVendorIds.includes(v.vendorId)) + .map(v => v.vendorId!) + }) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + form.reset() + onSuccess?.() + } else { + toast.error(result.message || "Short List 확정에 실패했습니다.") + } + } catch (error) { + console.error("Short List confirm error:", error) + toast.error("Short List 확정 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleVendorToggle = (vendorId: number, checked: boolean) => { + const currentSelected = form.getValues("selectedVendorIds") + + if (checked) { + form.setValue("selectedVendorIds", [...currentSelected, vendorId]) + } else { + form.setValue("selectedVendorIds", currentSelected.filter(id => id !== vendorId)) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <CheckCircle2 className="h-5 w-5 text-green-600" /> + Short List 확정 + </DialogTitle> + <DialogDescription> + 최종 RFQ로 진행할 벤더를 선택해주세요. 선택되지 않은 벤더에게는 자동으로 Letter of Regret이 발송됩니다. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <FormField + control={form.control} + name="selectedVendorIds" + render={() => ( + <FormItem> + <FormLabel className="text-base font-semibold"> + 벤더 선택 ({vendors.length}개 업체) + </FormLabel> + <FormControl> + <ScrollArea className="h-[400px] border rounded-md p-4"> + <div className="space-y-4"> + {vendors.map((vendor) => { + const isSelected = vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId) + + return ( + <div + key={vendor.vendorId} + className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors ${ + isSelected + ? 'border-green-200 bg-green-50' + : 'border-red-100 bg-red-50' + }`} + > + <Checkbox + checked={isSelected} + onCheckedChange={(checked) => + vendor.vendorId && handleVendorToggle(vendor.vendorId, !!checked) + } + className="mt-1" + /> + <div className="flex-1 space-y-2"> + <div className="flex items-center gap-2"> + <Building className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">{vendor.vendorName}</span> + {isSelected ? ( + <Badge variant="secondary" className="bg-green-100 text-green-800"> + 선택됨 + </Badge> + ) : ( + <Badge variant="secondary" className="bg-red-100 text-red-800"> + 탈락 + </Badge> + )} + </div> + <div className="text-sm text-muted-foreground"> + <span className="font-mono">{vendor.vendorCode}</span> + {vendor.vendorCountry && ( + <> + <span className="mx-2">•</span> + <span>{vendor.vendorCountry === "KR" ? "국내" : "해외"}</span> + </> + )} + {vendor.vendorCategory && ( + <> + <span className="mx-2">•</span> + <span>{vendor.vendorCategory}</span> + </> + )} + {vendor.vendorBusinessSize && ( + <> + <span className="mx-2">•</span> + <span>{vendor.vendorBusinessSize}</span> + </> + )} + </div> + <div className="text-xs text-muted-foreground"> + RFQ 상태: <Badge variant="outline" className="text-xs"> + {vendor.initialRfqStatus} + </Badge> + </div> + </div> + </div> + ) + })} + </div> + </ScrollArea> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 요약 정보 */} + <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"> + <div className="space-y-2"> + <div className="flex items-center gap-2 text-green-700"> + <CheckCircle2 className="h-4 w-4" /> + <span className="font-medium">선택된 벤더</span> + </div> + <div className="text-2xl font-bold text-green-700"> + {selectedVendors.length}개 업체 + </div> + {selectedVendors.length > 0 && ( + <div className="text-sm text-muted-foreground"> + {selectedVendors.map(v => v.vendorName).join(", ")} + </div> + )} + </div> + <div className="space-y-2"> + <div className="flex items-center gap-2 text-red-700"> + <XCircle className="h-4 w-4" /> + <span className="font-medium">탈락 벤더</span> + </div> + <div className="text-2xl font-bold text-red-700"> + {rejectedVendors.length}개 업체 + </div> + {rejectedVendors.length > 0 && ( + <div className="text-sm text-muted-foreground"> + Letter of Regret 발송 예정 + </div> + )} + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading || selectedVendors.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Short List 확정 + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
