diff options
Diffstat (limited to 'lib/procurement-rfqs/table/detail-table')
7 files changed, 0 insertions, 3208 deletions
diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx deleted file mode 100644 index 79524f58..00000000 --- a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx +++ /dev/null @@ -1,512 +0,0 @@ -"use client" - -import * as React from "react" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { toast } from "sonner" -import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react" - -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { ProcurementRfqsView } from "@/db/schema" -import { addVendorToRfq } from "@/lib/procurement-rfqs/services" -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { ScrollArea } from "@/components/ui/scroll-area" - -// 필수 필드를 위한 커스텀 레이블 컴포넌트 -const RequiredLabel = ({ children }: { children: React.ReactNode }) => ( - <FormLabel> - {children} <span className="text-red-500">*</span> - </FormLabel> -); - -// 폼 유효성 검증 스키마 -const vendorFormSchema = z.object({ - vendorId: z.string().min(1, "벤더를 선택해주세요"), - currency: z.string().min(1, "통화를 선택해주세요"), - paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), - incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), - incotermsDetail: z.string().optional(), - deliveryDate: z.string().optional(), - taxCode: z.string().optional(), - placeOfShipping: z.string().optional(), - placeOfDestination: z.string().optional(), - materialPriceRelatedYn: z.boolean().default(false), -}) - -type VendorFormValues = z.infer<typeof vendorFormSchema> - -interface AddVendorDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: ProcurementRfqsView | null - // 벤더 및 기타 옵션 데이터를 prop으로 받음 - vendors?: { id: number; vendorName: string; vendorCode: string }[] - currencies?: { code: string; name: string }[] - paymentTerms?: { code: string; description: string }[] - incoterms?: { code: string; description: string }[] - onSuccess?: () => void - existingVendorIds?: number[] - -} - -export function AddVendorDialog({ - open, - onOpenChange, - selectedRfq, - vendors = [], - currencies = [], - paymentTerms = [], - incoterms = [], - onSuccess, - existingVendorIds = [], // 기본값 빈 배열 -}: AddVendorDialogProps) { - - - const availableVendors = React.useMemo(() => { - return vendors.filter(vendor => !existingVendorIds.includes(vendor.id)); - }, [vendors, existingVendorIds]); - - - // 파일 업로드 상태 관리 - const [attachments, setAttachments] = useState<File[]>([]) - const [isSubmitting, setIsSubmitting] = useState(false) - - // 벤더 선택을 위한 팝오버 상태 - const [vendorOpen, setVendorOpen] = useState(false) - - const form = useForm<VendorFormValues>({ - resolver: zodResolver(vendorFormSchema), - defaultValues: { - vendorId: "", - currency: "", - paymentTermsCode: "", - incotermsCode: "", - incotermsDetail: "", - deliveryDate: "", - taxCode: "", - placeOfShipping: "", - placeOfDestination: "", - materialPriceRelatedYn: false, - }, - }) - - // 폼 제출 핸들러 - async function onSubmit(values: VendorFormValues) { - if (!selectedRfq) { - toast.error("선택된 RFQ가 없습니다") - return - } - - try { - setIsSubmitting(true) - - // FormData 생성 - const formData = new FormData() - formData.append("rfqId", selectedRfq.id.toString()) - - // 폼 데이터 추가 - Object.entries(values).forEach(([key, value]) => { - formData.append(key, value.toString()) - }) - - // 첨부파일 추가 - attachments.forEach((file, index) => { - formData.append(`attachment-${index}`, file) - }) - - // 서버 액션 호출 - const result = await addVendorToRfq(formData) - - if (result.success) { - toast.success("벤더가 성공적으로 추가되었습니다") - onOpenChange(false) - form.reset() - setAttachments([]) - onSuccess?.() - } else { - toast.error(result.message || "벤더 추가 중 오류가 발생했습니다") - } - } catch (error) { - console.error("벤더 추가 오류:", error) - toast.error("벤더 추가 중 오류가 발생했습니다") - } finally { - setIsSubmitting(false) - } - } - - // 파일 업로드 핸들러 - const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.files && event.target.files.length > 0) { - const newFiles = Array.from(event.target.files) - setAttachments((prev) => [...prev, ...newFiles]) - } - } - - // 파일 삭제 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments((prev) => prev.filter((_, i) => i !== index)) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} - <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}> - {/* 고정 헤더 */} - <div className="p-6 border-b"> - <DialogHeader> - <DialogTitle>벤더 추가</DialogTitle> - <DialogDescription> - {selectedRfq ? ( - <> - <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. - </> - ) : ( - "RFQ에 벤더를 추가합니다." - )} - </DialogDescription> - </DialogHeader> - </div> - - {/* 스크롤 가능한 콘텐츠 영역 */} - <div className="flex-1 overflow-y-auto p-6"> - <Form {...form}> - <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 검색 가능한 벤더 선택 필드 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <RequiredLabel>벤더</RequiredLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - > - {field.value - ? vendors.find((vendor) => String(vendor.id) === field.value) - ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` - : "벤더를 선택하세요" - : "벤더를 선택하세요"} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - <CommandList> - <ScrollArea className="h-60"> - <CommandGroup> - {availableVendors.length > 0 ? ( - availableVendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - String(vendor.id) === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {vendor.vendorName} ({vendor.vendorCode}) - </CommandItem> - )) - ) : ( - <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem> - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <RequiredLabel>통화</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.name} ({currency.code}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <RequiredLabel>지불 조건</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <RequiredLabel>인코텀즈</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((incoterm) => ( - <SelectItem key={incoterm.code} value={incoterm.code}> - {incoterm.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* 나머지 필드들은 동일하게 유지 */} - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 세부사항</FormLabel> - <FormControl> - <Input {...field} placeholder="인코텀즈 세부사항" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="deliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel>납품 예정일</FormLabel> - <FormControl> - <Input {...field} type="date" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="taxCode" - render={({ field }) => ( - <FormItem> - <FormLabel>세금 코드</FormLabel> - <FormControl> - <Input {...field} placeholder="세금 코드" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="placeOfShipping" - render={({ field }) => ( - <FormItem> - <FormLabel>선적지</FormLabel> - <FormControl> - <Input {...field} placeholder="선적지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="placeOfDestination" - render={({ field }) => ( - <FormItem> - <FormLabel>도착지</FormLabel> - <FormControl> - <Input {...field} placeholder="도착지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="materialPriceRelatedYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <input - type="checkbox" - checked={field.value} - onChange={field.onChange} - className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>하도급대금 연동제 여부</FormLabel> - </div> - </FormItem> - )} - /> - - {/* 파일 업로드 섹션 */} - <div className="space-y-2"> - <Label>첨부 파일</Label> - <div className="border rounded-md p-4"> - <div className="flex items-center justify-center w-full"> - <label - htmlFor="file-upload" - className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" - > - <div className="flex flex-col items-center justify-center pt-5 pb-6"> - <Upload className="w-8 h-8 mb-2 text-gray-500" /> - <p className="mb-2 text-sm text-gray-500"> - <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요 - </p> - <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p> - </div> - <input - id="file-upload" - type="file" - className="hidden" - multiple - onChange={handleFileUpload} - /> - </label> - </div> - - {/* 업로드된 파일 목록 */} - {attachments.length > 0 && ( - <div className="mt-4 space-y-2"> - <h4 className="text-sm font-medium">업로드된 파일</h4> - <ul className="space-y-2"> - {attachments.map((file, index) => ( - <li - key={index} - className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md" - > - <div className="flex items-center space-x-2"> - <File className="w-4 h-4 text-gray-500" /> - <span className="truncate max-w-[250px]">{file.name}</span> - <span className="text-gray-500 text-xs"> - ({(file.size / 1024).toFixed(1)} KB) - </span> - </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => handleRemoveFile(index)} - > - <X className="w-4 h-4 text-gray-500" /> - </Button> - </li> - ))} - </ul> - </div> - )} - </div> - </div> - </form> - </Form> - </div> - - {/* 고정 푸터 */} - <div className="p-6 border-t"> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - form="vendor-form" - disabled={isSubmitting} - > - {isSubmitting ? "처리 중..." : "벤더 추가"} - </Button> - </DialogFooter> - </div> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx deleted file mode 100644 index 49d982e1..00000000 --- a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client" - -import * as React from "react" -import { type RfqDetailView } from "./rfq-detail-column" -import { type Row } from "@tanstack/react-table" -import { Loader, Trash } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" -import { deleteRfqDetail } from "@/lib/procurement-rfqs/services" - - -interface DeleteRfqDetailDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - detail: RfqDetailView | null - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteRfqDetailDialog({ - detail, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqDetailDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - if (!detail) return - - startDeleteTransition(async () => { - try { - const result = await deleteRfqDetail(detail.detailId) - - if (!result.success) { - toast.error(result.message || "삭제 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 삭제되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 삭제 오류:", error) - toast.error("삭제 중 오류가 발생했습니다") - } - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - 삭제 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx deleted file mode 100644 index bc257202..00000000 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx +++ /dev/null @@ -1,393 +0,0 @@ -"use client" - -import * as React from "react" -import type { ColumnDef, Row } from "@tanstack/react-table"; -import { formatDate, formatDateTime } from "@/lib/utils" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Ellipsis, MessageCircle, ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; - -export interface DataTableRowAction<TData> { - row: Row<TData>; - type: "delete" | "update" | "communicate"; // communicate 타입 추가 -} - -// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요) -export interface RfqDetailView { - detailId: number - rfqId: number - rfqCode: string - vendorId?: number | null // 벤더 ID 필드 추가 - projectCode: string | null - projectName: string | null - vendorCountry: string | null - itemCode: string | null - itemName: string | null - vendorName: string | null - vendorCode: string | null - currency: string | null - paymentTermsCode: string | null - paymentTermsDescription: string | null - incotermsCode: string | null - incotermsDescription: string | null - incotermsDetail: string | null - deliveryDate: Date | null - taxCode: string | null - placeOfShipping: string | null - placeOfDestination: string | null - materialPriceRelatedYn: boolean | null - hasQuotation: boolean | null - updatedByUserName: string | null - quotationStatus: string | null - updatedAt: Date | null - prItemsCount: number - majorItemsCount: number - quotationVersion:number | null - // 커뮤니케이션 관련 필드 추가 - commentCount?: number // 전체 코멘트 수 - unreadCount?: number // 읽지 않은 코멘트 수 - lastCommentDate?: Date // 마지막 코멘트 날짜 -} - -interface GetColumnsProps<TData> { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<TData> | null> - >; - unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수 -} - -export function getRfqDetailColumns({ - setRowAction, - unreadMessages = {}, -}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { - return [ - { - accessorKey: "quotationStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 상태" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>, - meta: { - excelHeader: "견적 상태" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "quotationVersion", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 버전" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>, - meta: { - excelHeader: "견적 버전" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>, - meta: { - excelHeader: "벤더 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더명" /> - ), - cell: ({ row }) => { - const vendorName = row.getValue("vendorName") as string; - const vendorId = row.original.vendorId; - - if (!vendorName || !vendorId) { - return <div>{vendorName}</div>; - } - - const handleVendorClick = () => { - window.open(`/evcp/vendors/${vendorId}/info`, '_blank'); - }; - - return ( - <Button - variant="link" - className="h-auto p-0 text-left justify-start font-normal text-foreground underline-offset-4 hover:underline" - onClick={handleVendorClick} - > - <span className="flex items-center gap-1"> - {vendorName} - {/* <ExternalLink className="h-3 w-3 opacity-50" /> */} - </span> - </Button> - ); - }, - meta: { - excelHeader: "벤더명" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "vendorType", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="내외자" /> - ), - cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>, - meta: { - excelHeader: "내외자" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="통화" /> - ), - cell: ({ row }) => <div>{row.getValue("currency")}</div>, - meta: { - excelHeader: "통화" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "paymentTermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>, - meta: { - excelHeader: "지불 조건 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "paymentTermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="지불 조건" /> - ), - cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>, - meta: { - excelHeader: "지불 조건" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>, - meta: { - excelHeader: "인코텀스 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "incotermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>, - meta: { - excelHeader: "인코텀스" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsDetail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>, - meta: { - excelHeader: "인코텀스 상세" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "deliveryDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="납품일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "납품일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "taxCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="세금 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("taxCode")}</div>, - meta: { - excelHeader: "세금 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "placeOfShipping", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="선적지" /> - ), - cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>, - meta: { - excelHeader: "선적지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "placeOfDestination", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="도착지" /> - ), - cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>, - meta: { - excelHeader: "도착지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "materialPriceRelatedYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" /> - ), - cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>, - meta: { - excelHeader: "하도급대금 연동" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "updatedByUserName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정자" /> - ), - cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>, - meta: { - excelHeader: "수정자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정일시" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDateTime(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "수정일시" - }, - enableResizing: true, - size: 140, - }, - // 커뮤니케이션 컬럼 추가 - { - id: "communication", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" /> - ), - cell: ({ row }) => { - const vendorId = row.original.vendorId || 0; - const unreadCount = unreadMessages[vendorId] || 0; - - return ( - <Button - variant="ghost" - size="sm" - className="relative p-0 h-8 w-8 flex items-center justify-center" - onClick={() => setRowAction({ row, type: "communicate" })} - > - <MessageCircle className="h-4 w-4" /> - {unreadCount > 0 && ( - <Badge - variant="destructive" - className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs" - > - {unreadCount} - </Badge> - )} - </Button> - ); - }, - enableResizing: false, - size: 80, - }, - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-7 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - ] -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx deleted file mode 100644 index ad9a19e7..00000000 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx +++ /dev/null @@ -1,521 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - DataTableRowAction, - getRfqDetailColumns, - RfqDetailView -} from "./rfq-detail-column" -import { toast } from "sonner" - -import { Skeleton } from "@/components/ui/skeleton" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { ProcurementRfqsView } from "@/db/schema" -import { - fetchCurrencies, - fetchIncoterms, - fetchPaymentTerms, - fetchRfqDetails, - fetchVendors, - fetchUnreadMessages -} from "@/lib/procurement-rfqs/services" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { AddVendorDialog } from "./add-vendor-dialog" -import { Button } from "@/components/ui/button" -import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가 -import { DeleteRfqDetailDialog } from "./delete-vendor-dialog" -import { UpdateRfqDetailSheet } from "./update-vendor-sheet" -import { VendorCommunicationDrawer } from "./vendor-communication-drawer" -import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트 - -// 프로퍼티 정의 -interface RfqDetailTablesProps { - selectedRfq: ProcurementRfqsView | null - maxHeight?: string | number -} - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string | null; // Update this to allow null - // 기타 필요한 벤더 속성들 -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) { - - console.log("selectedRfq", selectedRfq) - // 상태 관리 - const [isLoading, setIsLoading] = useState(false) - const [isRefreshing, setIsRefreshing] = useState(false) - const [details, setDetails] = useState<RfqDetailView[]>([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null) - - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [currencies, setCurrencies] = React.useState<Currency[]>([]) - const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([]) - const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) - const [isUnreadLoading, setIsUnreadLoading] = useState(false) - - // 견적 비교 다이얼로그 상태 관리 (추가) - const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) - - const existingVendorIds = React.useMemo(() => { - return details.map(detail => Number(detail.vendorId)).filter(Boolean); - }, [details]); - - const handleAddVendor = async () => { - try { - setIsAdddialogLoading(true) - - // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - fetchVendors(), - fetchCurrencies(), - fetchPaymentTerms(), - fetchIncoterms() - ]) - - setVendors(vendorsData.data || []) - setCurrencies(currenciesData.data || []) - setPaymentTerms(paymentTermsData.data || []) - setIncoterms(incotermsData.data || []) - - setVendorDialogOpen(true) - } catch (error) { - console.error("데이터 로드 오류:", error) - toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsAdddialogLoading(false) - } - } - - // 견적 비교 다이얼로그 열기 핸들러 (추가) - const handleOpenComparisonDialog = () => { - // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 - const hasSubmittedQuotations = details.some(detail => - detail.hasQuotation && detail.quotationStatus === "Submitted" - ); - - if (!hasSubmittedQuotations) { - toast.warning("제출된 견적이 없습니다."); - return; - } - - setComparisonDialogOpen(true); - } - - // 읽지 않은 메시지 로드 - const loadUnreadMessages = async () => { - if (!selectedRfq || !selectedRfq.id) return; - - try { - setIsUnreadLoading(true); - - // 읽지 않은 메시지 수 가져오기 - const unreadData = await fetchUnreadMessages(selectedRfq.id); - setUnreadMessages(unreadData); - } catch (error) { - console.error("읽지 않은 메시지 로드 오류:", error); - // 조용히 실패 - 사용자에게 알림 표시하지 않음 - } finally { - setIsUnreadLoading(false); - } - }; - - // 칼럼 정의 - unreadMessages 상태 전달 - const columns = React.useMemo(() => - getRfqDetailColumns({ - setRowAction, - unreadMessages - }), [unreadMessages]) - - // 필터 필드 정의 (필터 사용 시) - const advancedFilterFields = React.useMemo( - () => [ - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "text", - }, - ], - [] - ) - - // RFQ ID가 변경될 때 데이터 로드 - useEffect(() => { - async function loadRfqDetails() { - if (!selectedRfq || !selectedRfq.id) { - setDetails([]) - return - } - - try { - setIsLoading(true) - const transformRfqDetails = (data: any[]): RfqDetailView[] => { - return data.map(item => ({ - ...item, - // Convert vendorId from string|null to number|undefined - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // Transform any other fields that need type conversion - })); - }; - - // Then in your useEffect: - const result = await fetchRfqDetails(selectedRfq.id); - setDetails(transformRfqDetails(result.data)); - - // 읽지 않은 메시지 개수 로드 - await loadUnreadMessages(); - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - setDetails([]) - toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsLoading(false) - } - } - - loadRfqDetails() - }, [selectedRfq]) - - // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - useEffect(() => { - if (!selectedRfq || !selectedRfq.id) return; - - const intervalId = setInterval(() => { - loadUnreadMessages(); - }, 60000); // 60초마다 갱신 - - return () => clearInterval(intervalId); - }, [selectedRfq]); - - // rowAction 처리 - useEffect(() => { - if (!rowAction) return - - const handleRowAction = async () => { - try { - // 통신 액션인 경우 드로어 열기 - if (rowAction.type === "communicate") { - setSelectedVendor(rowAction.row.original); - setCommunicationDrawerOpen(true); - - // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) - const vendorId = rowAction.row.original.vendorId; - if (vendorId) { - setUnreadMessages(prev => ({ - ...prev, - [vendorId]: 0 - })); - } - - // rowAction 초기화 - setRowAction(null); - return; - } - - // 다른 액션들은 기존과 동일하게 처리 - setIsAdddialogLoading(true); - - // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - fetchVendors(), - fetchCurrencies(), - fetchPaymentTerms(), - fetchIncoterms() - ]); - - setVendors(vendorsData.data || []); - setCurrencies(currenciesData.data || []); - setPaymentTerms(paymentTermsData.data || []); - setIncoterms(incotermsData.data || []); - - // 이제 데이터가 로드되었으므로 필요한 작업 수행 - if (rowAction.type === "update") { - setSelectedDetail(rowAction.row.original); - setUpdateSheetOpen(true); - } else if (rowAction.type === "delete") { - setSelectedDetail(rowAction.row.original); - setDeleteDialogOpen(true); - } - } catch (error) { - console.error("데이터 로드 오류:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다"); - } finally { - // communicate 타입이 아닌 경우에만 로딩 상태 변경 - if (rowAction && rowAction.type !== "communicate") { - setIsAdddialogLoading(false); - } - } - }; - - handleRowAction(); - }, [rowAction]) - - // RFQ가 선택되지 않은 경우 - if (!selectedRfq) { - return ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - RFQ를 선택하세요 - </div> - ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( - <div className="p-4 space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-24 w-full" /> - <Skeleton className="h-48 w-full" /> - </div> - ) - } - - const handleRefreshData = async () => { - if (!selectedRfq || !selectedRfq.id) return - - try { - setIsRefreshing(true) - - const transformRfqDetails = (data: any[]): RfqDetailView[] => { - return data.map(item => ({ - ...item, - // Convert vendorId from string|null to number|undefined - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // Transform any other fields that need type conversion - })); - }; - - // Then in your useEffect: - const result = await fetchRfqDetails(selectedRfq.id); - setDetails(transformRfqDetails(result.data)); - - // 읽지 않은 메시지 개수 업데이트 - await loadUnreadMessages(); - - toast.success("데이터가 새로고침되었습니다") - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - toast.error("데이터 새로고침 중 오류가 발생했습니다") - } finally { - setIsRefreshing(false) - } - } - - // 전체 읽지 않은 메시지 수 계산 - const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0); - - // 견적이 있는 벤더 수 계산 - const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length; - - return ( - <div className="h-full overflow-hidden pt-4"> - - {/* 메시지 및 새로고침 영역 */} - - - {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - - <ClientDataTable - columns={columns} - data={details} - advancedFilterFields={advancedFilterFields} - maxHeight={maxHeight} - > - - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2 mr-2"> - {totalUnreadMessages > 0 && ( - <Badge variant="destructive" className="h-6"> - 읽지 않은 메시지: {totalUnreadMessages}건 - </Badge> - )} - {vendorsWithQuotations > 0 && ( - <Badge variant="outline" className="h-6"> - 견적 제출: {vendorsWithQuotations}개 벤더 - </Badge> - )} - </div> - <div className="flex gap-2"> - {/* 견적 비교 버튼 추가 */} - <Button - variant="outline" - size="sm" - onClick={handleOpenComparisonDialog} - className="gap-2" - disabled={ - !selectedRfq || - details.length === 0 || - (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate)) - } - > - <BarChart2 className="size-4" aria-hidden="true" /> - <span>견적 비교</span> - </Button> - <Button - variant="outline" - size="sm" - onClick={handleRefreshData} - disabled={isRefreshing} - > - {isRefreshing ? ( - <> - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - 새로고침 중... - </> - ) : ( - '새로고침' - )} - </Button> - </div> - </div> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - className="gap-2" - disabled={!selectedRfq || isAdddialogLoading} - > - {isAdddialogLoading ? ( - <> - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - <span>로딩 중...</span> - </> - ) : ( - <> - <UserPlus className="size-4" aria-hidden="true" /> - <span>벤더 추가</span> - </> - )} - </Button> - </ClientDataTable> - - ) : ( - <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4"> - <div className="flex flex-col items-center gap-4"> - <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - className="gap-2" - disabled={!selectedRfq || isAdddialogLoading} - > - {isAdddialogLoading ? ( - <> - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - <span>로딩 중...</span> - </> - ) : ( - <> - <UserPlus className="size-4" aria-hidden="true" /> - <span>협력업체 추가</span> - </> - )} - </Button> - </div> - </div> - )} - - {/* 벤더 추가 다이얼로그 */} - <AddVendorDialog - open={vendorDialogOpen} - onOpenChange={(open) => { - setVendorDialogOpen(open); - if (!open) setIsAdddialogLoading(false); - }} - selectedRfq={selectedRfq} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - existingVendorIds={existingVendorIds} - /> - - {/* 벤더 정보 수정 시트 */} - <UpdateRfqDetailSheet - open={updateSheetOpen} - onOpenChange={setUpdateSheetOpen} - detail={selectedDetail} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - /> - - {/* 벤더 정보 삭제 다이얼로그 */} - <DeleteRfqDetailDialog - open={deleteDialogOpen} - onOpenChange={setDeleteDialogOpen} - detail={selectedDetail} - showTrigger={false} - onSuccess={handleRefreshData} - /> - - {/* 벤더 커뮤니케이션 드로어 */} - <VendorCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={(open) => { - setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 - if (!open) loadUnreadMessages(); - }} - selectedRfq={selectedRfq} - selectedVendor={selectedVendor} - onSuccess={handleRefreshData} - /> - - {/* 견적 비교 다이얼로그 추가 */} - <VendorQuotationComparisonDialog - open={comparisonDialogOpen} - onOpenChange={setComparisonDialogOpen} - selectedRfq={selectedRfq} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx deleted file mode 100644 index edc04788..00000000 --- a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx +++ /dev/null @@ -1,449 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Check, ChevronsUpDown, Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Checkbox } from "@/components/ui/checkbox" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { RfqDetailView } from "./rfq-detail-column" -import { updateRfqDetail } from "@/lib/procurement-rfqs/services" - -// 폼 유효성 검증 스키마 -const updateRfqDetailSchema = z.object({ - vendorId: z.string().min(1, "벤더를 선택해주세요"), - currency: z.string().min(1, "통화를 선택해주세요"), - paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), - incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), - incotermsDetail: z.string().optional(), - deliveryDate: z.string().optional(), - taxCode: z.string().optional(), - placeOfShipping: z.string().optional(), - placeOfDestination: z.string().optional(), - materialPriceRelatedYn: z.boolean().default(false), -}) - -type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema> - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string; -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -interface UpdateRfqDetailSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - detail: RfqDetailView | null; - vendors: Vendor[]; - currencies: Currency[]; - paymentTerms: PaymentTerm[]; - incoterms: Incoterm[]; - onSuccess?: () => void; -} - -export function UpdateRfqDetailSheet({ - detail, - vendors, - currencies, - paymentTerms, - incoterms, - onSuccess, - ...props -}: UpdateRfqDetailSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [vendorOpen, setVendorOpen] = React.useState(false) - - const form = useForm<UpdateRfqDetailFormValues>({ - resolver: zodResolver(updateRfqDetailSchema), - defaultValues: { - vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", - currency: detail?.currency || "", - paymentTermsCode: detail?.paymentTermsCode || "", - incotermsCode: detail?.incotermsCode || "", - incotermsDetail: detail?.incotermsDetail || "", - deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail?.taxCode || "", - placeOfShipping: detail?.placeOfShipping || "", - placeOfDestination: detail?.placeOfDestination || "", - materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, - }, - }) - - // detail이 변경될 때 form 값 업데이트 - React.useEffect(() => { - if (detail) { - const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id - - form.reset({ - vendorId: vendorId ? String(vendorId) : "", - currency: detail.currency || "", - paymentTermsCode: detail.paymentTermsCode || "", - incotermsCode: detail.incotermsCode || "", - incotermsDetail: detail.incotermsDetail || "", - deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail.taxCode || "", - placeOfShipping: detail.placeOfShipping || "", - placeOfDestination: detail.placeOfDestination || "", - materialPriceRelatedYn: detail.materialPriceRelatedYn || false, - }) - } - }, [detail, form, vendors]) - - function onSubmit(values: UpdateRfqDetailFormValues) { - if (!detail) return - - startUpdateTransition(async () => { - try { - const result = await updateRfqDetail(detail.detailId, values) - - if (!result.success) { - toast.error(result.message || "수정 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 수정되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 수정 오류:", error) - toast.error("수정 중 오류가 발생했습니다") - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl"> - <SheetHeader className="text-left"> - <SheetTitle>RFQ 벤더 정보 수정</SheetTitle> - <SheetDescription> - 벤더 정보를 수정하고 저장하세요 - </SheetDescription> - </SheetHeader> - <ScrollArea className="flex-1 pr-4"> - <Form {...form}> - <form - id="update-rfq-detail-form" - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - > - {/* 검색 가능한 벤더 선택 필드 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - > - {field.value - ? vendors.find((vendor) => String(vendor.id) === field.value) - ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` - : "벤더를 선택하세요" - : "벤더를 선택하세요"} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - <ScrollArea className="h-60"> - <CommandGroup> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - String(vendor.id) === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {vendor.vendorName} ({vendor.vendorCode}) - </CommandItem> - ))} - </CommandGroup> - </ScrollArea> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>통화 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.name} ({currency.code}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((incoterm) => ( - <SelectItem key={incoterm.code} value={incoterm.code}> - {incoterm.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 세부사항</FormLabel> - <FormControl> - <Input {...field} placeholder="인코텀즈 세부사항" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="deliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel>납품 예정일</FormLabel> - <FormControl> - <Input {...field} type="date" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="taxCode" - render={({ field }) => ( - <FormItem> - <FormLabel>세금 코드</FormLabel> - <FormControl> - <Input {...field} placeholder="세금 코드" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="placeOfShipping" - render={({ field }) => ( - <FormItem> - <FormLabel>선적지</FormLabel> - <FormControl> - <Input {...field} placeholder="선적지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="placeOfDestination" - render={({ field }) => ( - <FormItem> - <FormLabel>도착지</FormLabel> - <FormControl> - <Input {...field} placeholder="도착지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="materialPriceRelatedYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>하도급 대금 연동 여부</FormLabel> - </div> - </FormItem> - )} - /> - </form> - </Form> - </ScrollArea> - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 취소 - </Button> - </SheetClose> - <Button - type="submit" - form="update-rfq-detail-form" - disabled={isUpdatePending} - > - {isUpdatePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 저장 - </Button> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx deleted file mode 100644 index e43fc676..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx +++ /dev/null @@ -1,518 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { ProcurementRfqsView } from "@/db/schema" -import { RfqDetailView } from "./rfq-detail-column" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Badge } from "@/components/ui/badge" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X -} from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime } from "@/lib/utils" -import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 -import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services" - -// 타입 정의 -interface Comment { - id: number; - rfqId: number; - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string; - isVendorComment: boolean | null; // null 허용으로 변경 - createdAt: Date; - updatedAt: Date; - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[]; - isRead: boolean | null // null 허용으로 변경 -} - -interface Attachment { - id: number; - fileName: string; - fileSize: number; - fileType: string; - filePath: string; - uploadedAt: Date; -} - -// 프롭스 정의 -interface VendorCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedRfq: ProcurementRfqsView | null; - selectedVendor: RfqDetailView | null; - onSuccess?: () => void; -} - -async function sendComment(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<Comment> { - try { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'false'); - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - // API 호출 - const response = await fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API 요청 실패: ${response.status} ${errorText}`); - } - - // 응답 데이터 파싱 - const result = await response.json(); - - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - - return result.data.comment; - } catch (error) { - console.error('코멘트 전송 오류:', error); - throw error; - } -} - -export function VendorCommunicationDrawer({ - open, - onOpenChange, - selectedRfq, - selectedVendor, - onSuccess -}: VendorCommunicationDrawerProps) { - // 상태 관리 - const [comments, setComments] = useState<Comment[]>([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState<File[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - const messagesEndRef = useRef<HTMLDivElement>(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); - - // 드로어가 열릴 때 데이터 로드 - useEffect(() => { - if (open && selectedRfq && selectedVendor) { - loadComments(); - } - }, [open, selectedRfq, selectedVendor]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 코멘트 로드 함수 - const loadComments = async () => { - if (!selectedRfq || !selectedVendor) return; - - try { - setIsLoading(true); - - // Server Action을 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId); - setComments(commentsData); - - // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 - await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId); - } catch (error) { - console.error("코멘트 로드 오류:", error); - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); - } finally { - setIsLoading(false); - } - }; - - // 파일 선택 핸들러 - const handleFileSelect = () => { - fileInputRef.current?.click(); - }; - - // 파일 변경 핸들러 - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files && e.target.files.length > 0) { - const newFiles = Array.from(e.target.files); - setAttachments(prev => [...prev, ...newFiles]); - } - }; - - // 파일 제거 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)); - }; - - console.log(newComment) - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) - console.log(!newComment.trim() && attachments.length === 0) - - if (!newComment.trim() && attachments.length === 0) return; - if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; - - console.log("버튼 클릭") - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendComment({ - rfqId: selectedRfq.id, - vendorId: selectedVendor.vendorId, - content: newComment, - attachments: attachments - }); - - // 상태 업데이트 - setComments(prev => [...prev, newCommentObj]); - setNewComment(""); - setAttachments([]); - - toast.success("메시지가 전송되었습니다"); - - // 데이터 새로고침 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("코멘트 전송 오류:", error); - toast.error("메시지 전송 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - }; - - // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: Attachment) => { - setSelectedAttachment(attachment); - setPreviewDialogOpen(true); - }; - - // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: Attachment) => { - // TODO: 실제 다운로드 구현 - window.open(attachment.filePath, '_blank'); - }; - - // 파일 아이콘 선택 - const getFileIcon = (fileType: string) => { - if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; - if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return <FileText className="h-5 w-5 text-green-500" />; - if (fileType.includes("document") || fileType.includes("word")) - return <FileText className="h-5 w-5 text-blue-500" />; - return <File className="h-5 w-5 text-gray-500" />; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); - - return ( - <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {getFileIcon(selectedAttachment.fileType)} - {selectedAttachment.fileName} - </DialogTitle> - <DialogDescription> - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")} - </DialogDescription> - </DialogHeader> - - <div className="min-h-[300px] flex items-center justify-center p-4"> - {isImage ? ( - <img - src={selectedAttachment.filePath} - alt={selectedAttachment.fileName} - className="max-h-[500px] max-w-full object-contain" - /> - ) : isPdf ? ( - <iframe - src={`${selectedAttachment.filePath}#toolbar=0`} - className="w-full h-[500px]" - title={selectedAttachment.fileName} - /> - ) : ( - <div className="flex flex-col items-center gap-4 p-8"> - {getFileIcon(selectedAttachment.fileType)} - <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> - <Button - variant="outline" - onClick={() => handleAttachmentDownload(selectedAttachment)} - > - <DownloadCloud className="h-4 w-4 mr-2" /> - 다운로드 - </Button> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); - }; - - if (!selectedRfq || !selectedVendor) { - return null; - } - - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[85vh]"> - <DrawerHeader className="border-b"> - <DrawerTitle className="flex items-center gap-2"> - <Avatar className="h-8 w-8"> - <AvatarFallback className="bg-primary/10"> - {selectedVendor.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - <div> - <span>{selectedVendor.vendorName}</span> - <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName} - </DrawerDescription> - </DrawerHeader> - - <div className="p-0 flex flex-col h-[60vh]"> - {/* 메시지 목록 */} - <ScrollArea className="flex-1 p-4"> - {isLoading ? ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground">메시지 로딩 중...</p> - </div> - ) : comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <AlertCircle className="h-6 w-6 text-muted-foreground" /> - <p className="text-muted-foreground">아직 메시지가 없습니다</p> - </div> - </div> - ) : ( - <div className="space-y-4"> - {comments.map(comment => ( - <div - key={comment.id} - className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`} - > - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - {comment.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${ - comment.isVendorComment - ? 'bg-muted' - : 'bg-primary text-primary-foreground' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? comment.vendorName : comment.userName} - </div> - - {comment.content && ( - <div className="text-sm whitespace-pre-wrap break-words"> - {comment.content} - </div> - )} - - {/* 첨부파일 표시 */} - {comment.attachments.length > 0 && ( - <div className={`mt-2 pt-2 ${ - comment.isVendorComment - ? 'border-t border-t-border/30' - : 'border-t border-t-primary-foreground/20' - }`}> - {comment.attachments.map(attachment => ( - <div - key={attachment.id} - className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" - onClick={() => handleAttachmentPreview(attachment)} - > - {getFileIcon(attachment.fileType)} - <span className="flex-1 truncate">{attachment.fileName}</span> - <span className="text-xs opacity-70"> - {formatFileSize(attachment.fileSize)} - </span> - <Button - variant="ghost" - size="icon" - className="h-6 w-6 rounded-full" - onClick={(e) => { - e.stopPropagation(); - handleAttachmentDownload(attachment); - }} - > - <DownloadCloud className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - )} - - <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> - {formatDateTime(comment.createdAt, "KR")} - </div> - </div> - - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - {comment.userName?.[0] || 'U'} - </AvatarFallback> - </Avatar> - )} - </div> - ))} - <div ref={messagesEndRef} /> - </div> - )} - </ScrollArea> - - {/* 선택된 첨부파일 표시 */} - {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2"> - <div className="text-xs font-medium mb-1">첨부파일</div> - <div className="flex flex-wrap gap-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> - {file.type.startsWith("image/") ? ( - <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> - ) : ( - <File className="h-4 w-4 mr-1 text-gray-500" /> - )} - <span className="truncate max-w-[100px]">{file.name}</span> - <Button - variant="ghost" - size="icon" - className="h-4 w-4 ml-1 p-0" - onClick={() => handleRemoveFile(index)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 메시지 입력 영역 */} - <div className="p-4 border-t"> - <div className="flex gap-2 items-end"> - <div className="flex-1"> - <Textarea - placeholder="메시지를 입력하세요..." - className="min-h-[80px] resize-none" - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - </div> - <div className="flex flex-col gap-2"> - <input - type="file" - ref={fileInputRef} - className="hidden" - multiple - onChange={handleFileChange} - /> - <Button - variant="outline" - size="icon" - onClick={handleFileSelect} - title="파일 첨부" - > - <Paperclip className="h-4 w-4" /> - </Button> - <Button - onClick={handleSubmitComment} - disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - </div> - - <DrawerFooter className="border-t"> - <div className="flex justify-between"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> - <DrawerClose asChild> - <Button variant="outline">닫기</Button> - </DrawerClose> - </div> - </DrawerFooter> - </DrawerContent> - - {renderAttachmentPreviewDialog()} - </Drawer> - ); -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx deleted file mode 100644 index 72cf187c..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { toast } from "sonner" - -// Lucide 아이콘 -import { Plus, Minus } from "lucide-react" - -import { ProcurementRfqsView } from "@/db/schema" -import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services" -import { formatCurrency, formatDate } from "@/lib/utils" - -// 견적 정보 타입 -interface VendorQuotation { - id: number - rfqId: number - vendorId: number - vendorName?: string | null - quotationCode: string - quotationVersion: number - totalItemsCount: number - subTotal: string - taxTotal: string - discountTotal: string - totalPrice: string - currency: string - validUntil: string | Date // 수정: string | Date 허용 - estimatedDeliveryDate: string | Date // 수정: string | Date 허용 - paymentTermsCode: string - paymentTermsDescription?: string | null - incotermsCode: string - incotermsDescription?: string | null - incotermsDetail: string - status: string - remark: string - rejectionReason: string - submittedAt: string | Date // 수정: string | Date 허용 - acceptedAt: string | Date // 수정: string | Date 허용 - createdAt: string | Date // 수정: string | Date 허용 - updatedAt: string | Date // 수정: string | Date 허용 -} - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null // Changed from string to string | null - materialDescription: string | null // Changed from string to string | null - quantity: string - uom: string | null // Changed assuming this might be null - unitPrice: string - totalPrice: string - currency: string | null // Changed from string to string | null - vendorMaterialCode: string | null // Changed from string to string | null - vendorMaterialDescription: string | null // Changed from string to string | null - deliveryDate: Date | null // Changed from string to string | null - leadTimeInDays: number | null // Changed from number to number | null - taxRate: string | null // Changed from string to string | null - taxAmount: string | null // Changed from string to string | null - discountRate: string | null // Changed from string to string | null - discountAmount: string | null // Changed from string to string | null - remark: string | null // Changed from string to string | null - isAlternative: boolean | null // Changed from boolean to boolean | null - isRecommended: boolean | null // Changed from boolean to boolean | null -} - -interface VendorQuotationComparisonDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: ProcurementRfqsView | null -} - -export function VendorQuotationComparisonDialog({ - open, - onOpenChange, - selectedRfq, -}: VendorQuotationComparisonDialogProps) { - const [isLoading, setIsLoading] = useState(false) - const [quotations, setQuotations] = useState<VendorQuotation[]>([]) - const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({}) - const [activeTab, setActiveTab] = useState("summary") - - // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘 - const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({}) - - useEffect(() => { - async function loadQuotationData() { - if (!open || !selectedRfq?.id) return - - try { - setIsLoading(true) - // 1) 견적 목록 - const quotationsResult = await fetchVendorQuotations(selectedRfq.id) - const rawQuotationsData = quotationsResult.data || [] - - const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({ - id: rawData.id, - rfqId: rawData.rfqId, - vendorId: rawData.vendorId, - vendorName: rawData.vendorName || null, - quotationCode: rawData.quotationCode || '', - quotationVersion: rawData.quotationVersion || 0, - totalItemsCount: rawData.totalItemsCount || 0, - subTotal: rawData.subTotal || '0', - taxTotal: rawData.taxTotal || '0', - discountTotal: rawData.discountTotal || '0', - totalPrice: rawData.totalPrice || '0', - currency: rawData.currency || 'KRW', - validUntil: rawData.validUntil || '', - estimatedDeliveryDate: rawData.estimatedDeliveryDate || '', - paymentTermsCode: rawData.paymentTermsCode || '', - paymentTermsDescription: rawData.paymentTermsDescription || null, - incotermsCode: rawData.incotermsCode || '', - incotermsDescription: rawData.incotermsDescription || null, - incotermsDetail: rawData.incotermsDetail || '', - status: rawData.status || '', - remark: rawData.remark || '', - rejectionReason: rawData.rejectionReason || '', - submittedAt: rawData.submittedAt || '', - acceptedAt: rawData.acceptedAt || '', - createdAt: rawData.createdAt || '', - updatedAt: rawData.updatedAt || '', - })); - - setQuotations(quotationsData); - - // 벤더별로 접힘 상태 기본값(true) 설정 - const collapsedInit: Record<number, boolean> = {} - quotationsData.forEach((q) => { - collapsedInit[q.id] = true - }) - setCollapsedVendors(collapsedInit) - - // 2) 견적 아이템 - const qIds = quotationsData.map((q) => q.id) - if (qIds.length > 0) { - const itemsResult = await fetchQuotationItems(qIds) - const itemsData = itemsResult.data || [] - - const itemsByQuotation: Record<number, QuotationItem[]> = {} - itemsData.forEach((item) => { - if (!itemsByQuotation[item.quotationId]) { - itemsByQuotation[item.quotationId] = [] - } - itemsByQuotation[item.quotationId].push(item) - }) - setQuotationItems(itemsByQuotation) - } - } catch (error) { - console.error("견적 데이터 로드 오류:", error) - toast.error("견적 데이터를 불러오는 데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadQuotationData() - }, [open, selectedRfq]) - - // 견적 상태 -> 뱃지 색 - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Submitted": - return "default" - case "Accepted": - return "default" - case "Rejected": - return "destructive" - case "Revised": - return "destructive" - default: - return "secondary" - } - } - - // 모든 prItemId 모음 - const allItemIds = React.useMemo(() => { - const itemSet = new Set<number>() - Object.values(quotationItems).forEach((items) => { - items.forEach((it) => itemSet.add(it.prItemId)) - }) - return Array.from(itemSet) - }, [quotationItems]) - - // 아이템 찾는 함수 - const findItemByQuotationId = (prItemId: number, qid: number) => { - const items = quotationItems[qid] || [] - return items.find((i) => i.prItemId === prItemId) - } - - // 접힘 상태 토글 - const toggleVendor = (qid: number) => { - setCollapsedVendors((prev) => ({ - ...prev, - [qid]: !prev[qid], - })) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */} - <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}> - <DialogHeader> - <DialogTitle>벤더 견적 비교</DialogTitle> - <DialogDescription> - {selectedRfq - ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}` - : ""} - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-48 w-full" /> - </div> - ) : quotations.length === 0 ? ( - <div className="py-8 text-center text-muted-foreground"> - 제출된(Submitted) 견적이 없습니다 - </div> - ) : ( - <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="summary">견적 요약 비교</TabsTrigger> - <TabsTrigger value="items">아이템별 비교</TabsTrigger> - </TabsList> - - {/* ======================== 요약 비교 탭 ======================== */} - <TabsContent value="summary" className="mt-4"> - {/* - table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px]) - -> 컨테이너보다 넓으면 수평 스크롤 발생. - */} - <div className="border rounded-md max-h-[60vh] overflow-auto"> - <table className="table-fixed w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - <TableRow> - <TableHead - className="sticky left-0 top-0 z-20 bg-background p-2" - > - 항목 - </TableHead> - {quotations.map((q) => ( - <TableHead key={q.id} className="p-2 text-center whitespace-nowrap"> - {q.vendorName || `벤더 ID: ${q.vendorId}`} - </TableHead> - ))} - </TableRow> - </thead> - <tbody> - {/* 견적 상태 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 상태 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`status-${q.id}`} className="p-2"> - <Badge variant={getStatusBadgeVariant(q.status)}> - {q.status} - </Badge> - </TableCell> - ))} - </TableRow> - - {/* 견적 버전 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 버전 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`version-${q.id}`} className="p-2"> - v{q.quotationVersion} - </TableCell> - ))} - </TableRow> - - {/* 총 금액 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 총 금액 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`total-${q.id}`} className="p-2 font-semibold"> - {formatCurrency(Number(q.totalPrice), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 소계 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 소계 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`subtotal-${q.id}`} className="p-2"> - {formatCurrency(Number(q.subTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 세금 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 세금 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`tax-${q.id}`} className="p-2"> - {formatCurrency(Number(q.taxTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 할인 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 할인 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`discount-${q.id}`} className="p-2"> - {formatCurrency(Number(q.discountTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 통화 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 통화 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`currency-${q.id}`} className="p-2"> - {q.currency} - </TableCell> - ))} - </TableRow> - - {/* 유효기간 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 유효 기간 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`valid-${q.id}`} className="p-2"> - {formatDate(q.validUntil, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 예상 배송일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 예상 배송일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`delivery-${q.id}`} className="p-2"> - {formatDate(q.estimatedDeliveryDate, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 지불 조건 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 지불 조건 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`payment-${q.id}`} className="p-2"> - {q.paymentTermsDescription || q.paymentTermsCode} - </TableCell> - ))} - </TableRow> - - {/* 인코텀즈 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 인코텀즈 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`incoterms-${q.id}`} className="p-2"> - {q.incotermsDescription || q.incotermsCode} - {q.incotermsDetail && ( - <div className="text-xs text-muted-foreground mt-1"> - {q.incotermsDetail} - </div> - )} - </TableCell> - ))} - </TableRow> - - {/* 제출일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 제출일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`submitted-${q.id}`} className="p-2"> - {formatDate(q.submittedAt, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 비고 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 비고 - </TableCell> - {quotations.map((q) => ( - <TableCell - key={`remark-${q.id}`} - className="p-2 whitespace-pre-wrap" - > - {q.remark || "-"} - </TableCell> - ))} - </TableRow> - </tbody> - </table> - </div> - </TabsContent> - - {/* ====================== 아이템별 비교 탭 ====================== */} - <TabsContent value="items" className="mt-4"> - {/* 컨테이너에 테이블 관련 클래스 직접 적용 */} - <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" > - <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}> - <table className="w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - {/* 첫 번째 헤더 행 */} - <tr> - {/* 첫 행: 자재(코드) 컬럼 */} - <th - rowSpan={2} - className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left" - style={{ - width: '250px', - minWidth: '250px', - backgroundColor: 'white', - }} - > - 자재 (코드) - </th> - - {/* 벤더 헤더 (접힘/펼침) */} - {quotations.map((q, index) => { - const collapsed = collapsedVendors[q.id] - // 접힌 상태면 1칸, 펼친 상태면 6칸 - return ( - <th - key={q.id} - className="p-2 text-center whitespace-nowrap border border-gray-200" - colSpan={collapsed ? 1 : 6} - style={{ - borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '', - backgroundColor: 'white', - }} - > - {/* + / - 버튼 */} - <div className="flex items-center gap-2 justify-center"> - <Button - variant="ghost" - size="sm" - className="h-7 w-7 p-1" - onClick={() => toggleVendor(q.id)} - > - {collapsed ? <Plus size={16} /> : <Minus size={16} />} - </Button> - <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> - </div> - </th> - ) - })} - </tr> - - {/* 두 번째 헤더 행 - 하위 컬럼들 */} - <tr className="border-b border-b-gray-200"> - {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */} - {quotations.flatMap((q, qIndex) => { - // 접힌 상태면 추가 헤더 없음 - if (collapsedVendors[q.id]) { - return [ - <th - key={`${q.id}-collapsed`} - className="p-2 text-center whitespace-nowrap border border-gray-200" - style={{ backgroundColor: 'white' }} - > - 총액 - </th> - ]; - } - - // 펼친 상태면 6개 컬럼 표시 - const columns = [ - { key: 'unitprice', label: '단가' }, - { key: 'totalprice', label: '총액' }, - { key: 'tax', label: '세금' }, - { key: 'discount', label: '할인' }, - { key: 'leadtime', label: '리드타임' }, - { key: 'alternative', label: '대체품' }, - ]; - - return columns.map((col, colIndex) => { - const isFirstInGroup = colIndex === 0; - const isLastInGroup = colIndex === columns.length - 1; - - return ( - <th - key={`${q.id}-${col.key}`} - className={`p-2 text-center whitespace-nowrap border border-gray-200 ${ - isFirstInGroup ? 'border-l border-l-gray-200' : '' - } ${ - isLastInGroup ? 'border-r border-r-gray-200' : '' - }`} - style={{ backgroundColor: 'white' }} - > - {col.label} - </th> - ); - }); - })} - </tr> - </thead> - - {/* 테이블 바디 */} - <tbody> - {allItemIds.map((itemId) => { - // 자재 기본 정보는 첫 번째 벤더 아이템 기준 - const firstQid = quotations[0]?.id - const sampleItem = firstQid - ? findItemByQuotationId(itemId, firstQid) - : undefined - - return ( - <tr key={itemId} className="border-b border-gray-100"> - {/* 자재 (코드) 셀 */} - <td - className="sticky left-0 z-10 p-2 align-top border-r border-gray-100" - style={{ - width: '250px', - minWidth: '250px', - backgroundColor: 'white', - }} - > - {sampleItem?.materialDescription || sampleItem?.materialCode || ""} - {sampleItem && ( - <div className="text-xs text-muted-foreground mt-1"> - 코드: {sampleItem.materialCode} | 수량:{" "} - {sampleItem.quantity} {sampleItem.uom} - </div> - )} - </td> - - {/* 벤더별 아이템 데이터 */} - {quotations.flatMap((q, qIndex) => { - const collapsed = collapsedVendors[q.id] - const itemData = findItemByQuotationId(itemId, q.id) - - // 접힌 상태면 총액만 표시 - if (collapsed) { - return [ - <td - key={`${q.id}-collapsed`} - className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100" - > - {itemData - ? formatCurrency(Number(itemData.totalPrice), itemData.currency) - : "N/A"} - </td> - ]; - } - - // 펼친 상태 - 아이템 없음 - if (!itemData) { - return [ - <td - key={`${q.id}-empty`} - colSpan={6} - className="p-2 text-center text-sm border-r border-gray-100" - > - 없음 - </td> - ]; - } - - // 펼친 상태 - 모든 컬럼 표시 - const columns = [ - { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' }, - { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true }, - { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' }, - { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' }, - { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' }, - { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' }, - ]; - - return columns.map((col, colIndex) => { - const isFirstInGroup = colIndex === 0; - const isLastInGroup = colIndex === columns.length - 1; - - return ( - <td - key={`${q.id}-${col.key}`} - className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${ - isFirstInGroup ? 'border-l border-l-gray-100' : '' - } ${ - isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100' - }`} - > - {col.render()} - </td> - ); - }); - })} - </tr> - ); - })} - - {/* 아이템이 전혀 없는 경우 */} - {allItemIds.length === 0 && ( - <tr> - <td - colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버 - className="text-center p-4 border border-gray-100" - > - 아이템 정보가 없습니다 - </td> - </tr> - )} - </tbody> - </table> - </div> - </div> - </TabsContent> - </Tabs> - )} - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 닫기 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} |
