diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
| commit | 5036cf2908792cef45f06256e71f10920f647f49 (patch) | |
| tree | 3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/table | |
| parent | 7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff) | |
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/table')
| -rw-r--r-- | lib/techsales-rfq/table/README.md | 41 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/create-rfq-dialog.tsx | 537 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx | 357 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx | 150 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx | 291 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | 654 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx | 449 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx | 521 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx | 340 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/project-detail-dialog.tsx | 322 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-filter-sheet.tsx | 759 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-column.tsx | 409 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx | 63 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table.tsx | 524 |
14 files changed, 5417 insertions, 0 deletions
diff --git a/lib/techsales-rfq/table/README.md b/lib/techsales-rfq/table/README.md new file mode 100644 index 00000000..74d0005f --- /dev/null +++ b/lib/techsales-rfq/table/README.md @@ -0,0 +1,41 @@ + +# 기술영업 RFQ + +1. 마스터 테이블 +---컬럼--- +상태 +견적프로젝트 이름 +rfqCode (RFQ-YYYY-001) +프로젝트 상세보기 액션컬럼 >> 다이얼로그로 해당 프로젝트 정보 보여줌. (SHI/벤더 동일) + +- 견적 프로젝트명 +- 척수 +- 선주명 +- 선급코드(선급명) +- 선종명 +- 선형명 +- 시리즈 상세보기 >> 시리즈별 K/L 연도분기 >> 2026.2Q 형식 +dueDate (마감일) +sentDate (발송일) +sentBy (발송자) +createdBy (생성자) +updatedBy (수정자) +createdAt (생성일) +updatedAt (수정일) +첨부파일 첨부 테이블 +취소 이유 (삼중이 취소했을 때) +데이터 없으면 취소하기 버튼으로 보여주기. +코멘트 액션컬럼 +---컬럼--- + +2. 디테일 테이블 +디테일 테이블에서는 마스터 테이블의 레코드를 선택했을 때 해당 레코드의 상세내역을 보여줌. +여기서는 벤더별 rfq 송신 및 현황 확인을 응답을 확인할 수 있도록, 발주용 견적과 유사하게 처리 +---컬럼--- +벤더명 +상태 +응답 (가격) +발송일 +발송자 +응답일 +응답자 diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx new file mode 100644 index 00000000..cc652b44 --- /dev/null +++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx @@ -0,0 +1,537 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { CalendarIcon } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { createTechSalesRfq } from "@/lib/techsales-rfq/service" +import { useSession } from "next-auth/react" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 실제 데이터 서비스 import +import { + getShipbuildingItemsByWorkType, + searchShipbuildingItems, + getWorkTypes, + type ShipbuildingItem, + type WorkType +} from "@/lib/items-tech/service" + +// 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경 +const createRfqSchema = z.object({ + biddingProjectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + materialCodes: z.array(z.string()).min(1, { + message: "적어도 하나의 자재코드를 선택해야 합니다.", + }), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), +}) + +// 폼 데이터 타입 +type CreateRfqFormValues = z.infer<typeof createRfqSchema> + +// 공종 타입 정의 +interface WorkTypeOption { + code: WorkType + name: string + description: string +} + +interface CreateRfqDialogProps { + onCreated?: () => void; +} + +export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { + const { data: session } = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState<WorkType | null>(null) + const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([]) + const [isSearchingItems, setIsSearchingItems] = React.useState(false) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([]) + const [availableItems, setAvailableItems] = React.useState<ShipbuildingItem[]>([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + + // RFQ 생성 폼 + const form = useForm<CreateRfqFormValues>({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + biddingProjectId: undefined, + materialCodes: [], + dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후 + } + }) + + // 공종 목록 로드 + React.useEffect(() => { + const loadWorkTypes = async () => { + const types = await getWorkTypes() + setWorkTypes(types) + } + loadWorkTypes() + }, []) + + // 아이템 데이터 로드 + const loadItems = React.useCallback(async () => { + setIsLoadingItems(true) + try { + let result + if (itemSearchQuery.trim()) { + result = await searchShipbuildingItems(itemSearchQuery, selectedWorkType || undefined) + } else { + result = await getShipbuildingItemsByWorkType(selectedWorkType || undefined) + } + + if (result.error) { + toast.error(`아이템 로드 오류: ${result.error}`) + setAvailableItems([]) + } else { + setAvailableItems(result.data || []) + } + } catch (error) { + console.error("아이템 로드 오류:", error) + toast.error("아이템을 불러오는 중 오류가 발생했습니다") + setAvailableItems([]) + } finally { + setIsLoadingItems(false) + } + }, [itemSearchQuery, selectedWorkType]) + + // 아이템 검색 디바운스 + React.useEffect(() => { + setIsSearchingItems(true) + const timer = setTimeout(() => { + loadItems() + setIsSearchingItems(false) + }, 300) + + return () => clearTimeout(timer) + }, [loadItems]) + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project) + form.setValue("biddingProjectId", project.id) + // 선택 초기화 + setSelectedItems([]) + form.setValue("materialCodes", []) + } + + // 아이템 선택/해제 처리 + const handleItemToggle = (item: ShipbuildingItem) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + if (isSelected) { + // 아이템 선택 해제 + const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) + setSelectedItems(newSelectedItems) + form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + } else { + // 아이템 선택 추가 + const newSelectedItems = [...selectedItems, item] + setSelectedItems(newSelectedItems) + form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + } + } + + // 아이템 제거 처리 + const handleRemoveItem = (itemId: number) => { + const newSelectedItems = selectedItems.filter(item => item.id !== itemId) + setSelectedItems(newSelectedItems) + form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + } + + // RFQ 생성 함수 + const handleCreateRfq = async (data: CreateRfqFormValues) => { + try { + setIsProcessing(true) + + // 사용자 인증 확인 + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다") + } + + // 자재코드(item_code) 배열을 materialGroupCodes로 전달 + const result = await createTechSalesRfq({ + biddingProjectId: data.biddingProjectId, + materialGroupCodes: data.materialCodes, // item_code를 자재코드로 사용 + createdBy: Number(session.user.id), + dueDate: data.dueDate, + }) + + if (result.error) { + throw new Error(result.error) + } + + // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 + toast.success(`${result.data?.length || 0}개의 RFQ가 성공적으로 생성되었습니다`) + setIsDialogOpen(false) + form.reset({ + biddingProjectId: undefined, + materialCodes: [], + dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setAvailableItems([]) + + // 생성 후 콜백 실행 + if (onCreated) { + onCreated() + } + + } catch (error) { + console.error("RFQ 생성 오류:", error) + toast.error(`RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsProcessing(false) + } + } + + return ( + <Dialog + open={isDialogOpen} + onOpenChange={(open) => { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + materialCodes: [], + dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setAvailableItems([]) + } + }} + > + <DialogTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2" + disabled={isProcessing} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">RFQ 생성</span> + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl w-[90vw] h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle>RFQ 생성</DialogTitle> + </DialogHeader> + + <div className="flex-1 overflow-y-auto"> + <Form {...form}> + <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-4"> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="biddingProjectId" + render={({ field }) => ( + <FormItem> + <FormLabel>입찰 프로젝트</FormLabel> + <FormControl> + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="입찰 프로젝트를 선택하세요" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + {/* 마감일 설정 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>마감일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormDescription> + 벤더가 견적을 제출해야 하는 마감일입니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + {!selectedProject ? ( + <div className="text-sm text-muted-foreground italic text-center py-8"> + 먼저 프로젝트를 선택해주세요 + </div> + ) : ( + <div className="space-y-6"> + {/* 아이템 선택 영역 */} + <div className="space-y-4"> + <div> + <FormLabel>조선 아이템 선택</FormLabel> + <FormDescription> + 공종별 아이템을 선택하세요 + </FormDescription> + </div> + + {/* 아이템 검색 및 필터 */} + <div className="space-y-2"> + <div className="flex space-x-2"> + <div className="relative flex-1"> + <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="아이템 검색..." + value={itemSearchQuery} + onChange={(e) => setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + /> + {itemSearchQuery && ( + <Button + variant="ghost" + size="sm" + className="absolute right-0 top-0 h-full px-3" + onClick={() => setItemSearchQuery("")} + > + <X className="h-4 w-4" /> + </Button> + )} + {isSearchingItems && ( + <Loader2 className="absolute right-8 top-2.5 h-4 w-4 animate-spin text-muted-foreground" /> + )} + </div> + + {/* 공종 필터 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" className="gap-1"> + {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"} + <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuCheckboxItem + checked={selectedWorkType === null} + onCheckedChange={() => setSelectedWorkType(null)} + > + 전체 공종 + </DropdownMenuCheckboxItem> + {workTypes.map(workType => ( + <DropdownMenuCheckboxItem + key={workType.code} + checked={selectedWorkType === workType.code} + onCheckedChange={() => setSelectedWorkType(workType.code)} + > + {workType.name} + </DropdownMenuCheckboxItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + + {/* 아이템 목록 */} + <div className="border rounded-md"> + <ScrollArea className="h-[300px]"> + <div className="p-2 space-y-1"> + {isLoadingItems ? ( + <div className="text-center py-8 text-muted-foreground"> + <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" /> + 아이템을 불러오는 중... + </div> + ) : availableItems.length > 0 ? ( + availableItems.map((item) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + return ( + <div + key={item.id} + className={cn( + "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted", + isSelected && "bg-muted" + )} + onClick={() => handleItemToggle(item)} + > + <div className="flex items-center space-x-2 flex-1"> + {isSelected ? ( + <CheckSquare className="h-4 w-4" /> + ) : ( + <Square className="h-4 w-4" /> + )} + <div className="flex-1"> + <div className="font-medium"> + {item.itemList || item.itemName} + </div> + <div className="text-sm text-muted-foreground"> + {item.itemCode} • {item.description || '설명 없음'} + </div> + <div className="text-xs text-muted-foreground"> + 공종: {item.workType} • 선종: {item.shipTypes} + </div> + </div> + </div> + </div> + ) + }) + ) : ( + <div className="text-center py-8 text-muted-foreground"> + {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} + </div> + )} + </div> + </ScrollArea> + </div> + + {/* 선택된 아이템 목록 */} + <FormField + control={form.control} + name="materialCodes" + render={() => ( + <FormItem> + <FormLabel>선택된 아이템 ({selectedItems.length}개)</FormLabel> + <div className="min-h-[80px] p-3 border rounded-md bg-muted/50"> + {selectedItems.length > 0 ? ( + <div className="flex flex-wrap gap-2"> + {selectedItems.map((item) => ( + <Badge + key={item.id} + variant="secondary" + className="flex items-center gap-1" + > + {item.itemList || item.itemName} ({item.itemCode}) + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => handleRemoveItem(item.id)} + /> + </Badge> + ))} + </div> + ) : ( + <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + 선택된 아이템이 없습니다 + </div> + )} + </div> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + )} + + {/* 안내 메시지 */} + {selectedProject && ( + <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> + <p>• 공종별 조선 아이템을 선택하세요.</p> + <p>• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.</p> + <p>• 아이템 코드가 자재 그룹 코드로 사용됩니다.</p> + <p>• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.</p> + </div> + )} + + <div className="flex justify-end space-x-2 pt-4"> + <Button + type="button" + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isProcessing} + > + 취소 + </Button> + <Button + type="submit" + disabled={isProcessing || !selectedProject || selectedItems.length === 0} + > + {isProcessing ? "처리 중..." : `${selectedItems.length}개 자재코드로 생성하기`} + </Button> + </div> + </form> + </Form> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..b66f4d77 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Check, X, Search, Loader2 } from "lucide-react" +import { useSession } from "next-auth/react" + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { addVendorsToTechSalesRfq } from "@/lib/techsales-rfq/service" +import { searchVendors } from "@/lib/vendors/service" + +// 폼 유효성 검증 스키마 - 간단화 +const vendorFormSchema = z.object({ + vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"), +}) + +type VendorFormValues = z.infer<typeof vendorFormSchema> + +// 기술영업 RFQ 타입 정의 +type TechSalesRfq = { + id: number + rfqCode: string | null + status: string + [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치) +type VendorSearchResult = { + id: number + vendorName: string + vendorCode: string | null + status: string + country: string | null +} + +interface AddVendorDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: TechSalesRfq | null + onSuccess?: () => void + existingVendorIds?: number[] +} + +export function AddVendorDialog({ + open, + onOpenChange, + selectedRfq, + onSuccess, + existingVendorIds = [], +}: AddVendorDialogProps) { + const { data: session } = useSession() + const [isSubmitting, setIsSubmitting] = useState(false) + const [searchTerm, setSearchTerm] = useState("") + const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 + const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([]) + + const form = useForm<VendorFormValues>({ + resolver: zodResolver(vendorFormSchema), + defaultValues: { + vendorIds: [], + }, + }) + + const selectedVendorIds = form.watch("vendorIds") + + // 검색 함수 (디바운스 적용) + const searchVendorsDebounced = useCallback( + async (term: string) => { + if (!term.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + setIsSearching(true) + try { + const results = await searchVendors(term, 100) + // 이미 추가된 벤더 제외 + const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id)) + setSearchResults(filteredResults) + setHasSearched(true) + } catch (error) { + console.error("벤더 검색 오류:", error) + toast.error("벤더 검색 중 오류가 발생했습니다") + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, + [existingVendorIds] + ) + + // 검색어 변경 시 디바운스 적용 + useEffect(() => { + const timer = setTimeout(() => { + searchVendorsDebounced(searchTerm) + }, 300) + + return () => clearTimeout(timer) + }, [searchTerm, searchVendorsDebounced]) + + // 벤더 선택/해제 핸들러 + const handleVendorToggle = (vendor: VendorSearchResult) => { + const currentIds = form.getValues("vendorIds") + const isSelected = currentIds.includes(vendor.id) + + if (isSelected) { + // 선택 해제 + const newIds = currentIds.filter(id => id !== vendor.id) + const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id) + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } else { + // 선택 추가 + const newIds = [...currentIds, vendor.id] + const newSelectedData = [...selectedVendorData, vendor] + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } + } + + // 선택된 벤더 제거 핸들러 + const handleRemoveVendor = (vendorId: number) => { + const currentIds = form.getValues("vendorIds") + const newIds = currentIds.filter(id => id !== vendorId) + const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId) + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } + + // 폼 제출 핸들러 + async function onSubmit(values: VendorFormValues) { + if (!selectedRfq) { + toast.error("선택된 RFQ가 없습니다") + return + } + + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + try { + setIsSubmitting(true) + + // 서비스 함수 호출 + const result = await addVendorsToTechSalesRfq({ + rfqId: selectedRfq.id, + vendorIds: values.vendorIds, + createdBy: Number(session.user.id), + }) + + if (result.error) { + toast.error(result.error) + } else { + const successMessage = `${result.successCount}개의 벤더가 성공적으로 추가되었습니다` + const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : "" + toast.success(successMessage + errorMessage) + + onOpenChange(false) + form.reset() + setSearchTerm("") + setSearchResults([]) + setHasSearched(false) + setSelectedVendorData([]) + onSuccess?.() + } + } catch (error) { + console.error("벤더 추가 오류:", error) + toast.error("벤더 추가 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 시 폼 리셋 + React.useEffect(() => { + if (!open) { + form.reset() + setSearchTerm("") + setSearchResults([]) + setHasSearched(false) + setSelectedVendorData([]) + } + }, [open, form]) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col"> + {/* 헤더 */} + <DialogHeader> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + {selectedRfq ? ( + <> + <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. + </> + ) : ( + "RFQ에 벤더를 추가합니다." + )} + </DialogDescription> + </DialogHeader> + + {/* 콘텐츠 */} + <div className="flex-1 overflow-y-auto"> + <Form {...form}> + <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 벤더 검색 필드 */} + <div className="space-y-2"> + <label className="text-sm font-medium">벤더 검색</label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="벤더명 또는 벤더코드로 검색..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" /> + )} + </div> + </div> + + {/* 검색 결과 */} + {hasSearched && ( + <div className="space-y-2"> + <div className="text-sm font-medium"> + 검색 결과 ({searchResults.length}개) + </div> + <ScrollArea className="h-60 border rounded-md"> + <div className="p-2 space-y-1"> + {searchResults.length > 0 ? ( + searchResults.map((vendor) => ( + <div + key={vendor.id} + className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${ + selectedVendorIds.includes(vendor.id) ? "bg-muted" : "" + }`} + onClick={() => handleVendorToggle(vendor)} + > + <div className="flex items-center space-x-2 flex-1"> + <Check + className={`h-4 w-4 ${ + selectedVendorIds.includes(vendor.id) + ? "opacity-100" + : "opacity-0" + }`} + /> + <div className="flex-1"> + <div className="font-medium">{vendor.vendorName}</div> + <div className="text-sm text-muted-foreground"> + {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} + </div> + </div> + </div> + </div> + )) + ) : ( + <div className="text-center py-8 text-muted-foreground"> + 검색 결과가 없습니다 + </div> + )} + </div> + </ScrollArea> + </div> + )} + + {/* 검색 안내 메시지 */} + {!hasSearched && !searchTerm && ( + <div className="text-center py-8 text-muted-foreground border rounded-md"> + 벤더명 또는 벤더코드를 입력하여 검색해주세요 + </div> + )} + + {/* 선택된 벤더 목록 - 하단에 항상 표시 */} + <FormField + control={form.control} + name="vendorIds" + render={() => ( + <FormItem> + <div className="space-y-2"> + <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel> + <div className="min-h-[60px] p-3 border rounded-md bg-muted/50"> + {selectedVendorData.length > 0 ? ( + <div className="flex flex-wrap gap-2"> + {selectedVendorData.map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="flex items-center gap-1" + > + {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => handleRemoveVendor(vendor.id)} + /> + </Badge> + ))} + </div> + ) : ( + <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + 선택된 벤더가 없습니다 + </div> + )} + </div> + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* 안내 메시지 */} + <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> + {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */} + <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p> + <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p> + <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p> + </div> + </form> + </Form> + </div> + + {/* 푸터 */} + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + form="vendor-form" + disabled={isSubmitting || selectedVendorIds.length === 0} + > + {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx new file mode 100644 index 00000000..d7e3403b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx @@ -0,0 +1,150 @@ +"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 DeleteVendorDialog({ + 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.id) + + 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/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx new file mode 100644 index 00000000..c4a7edde --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { formatDate } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Ellipsis, MessageCircle } 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"; +} + +// 벤더 견적 데이터 타입 정의 +export interface RfqDetailView { + id: number + rfqId: number + vendorId?: number | null + vendorName: string | null + vendorCode: string | null + totalPrice: string | number | null + currency: string | null + validUntil: Date | null + status: string | null + remark: string | null + submittedAt: Date | null + acceptedAt: Date | null + rejectionReason: string | null + createdAt: Date | null + updatedAt: Date | null + createdByName: string | null +} + +interface GetColumnsProps<TData> { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TData> | null> + >; + unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수 +} + +export function getRfqDetailColumns({ + setRowAction, + unreadMessages = {} +}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + /> + ), + cell: ({ row }) => { + const status = row.original.status; + const isDraft = status === "Draft"; + + return ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + disabled={!isDraft} + aria-label="행 선택" + className={!isDraft ? "opacity-50 cursor-not-allowed" : ""} + /> + ); + }, + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + // 상태에 따른 배지 색상 설정 + let variant: "default" | "secondary" | "outline" | "destructive" = "outline"; + + if (status === "Submitted") { + variant = "default"; // 제출됨 - 기본 색상 + } else if (status === "Accepted") { + variant = "secondary"; // 승인됨 - 보조 색상 + } else if (status === "Rejected") { + variant = "destructive"; // 거부됨 - 위험 색상 + } + + return ( + <Badge variant={variant}>{status || "Draft"}</Badge> + ); + }, + 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 }) => <div>{row.getValue("vendorName")}</div>, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "totalPrice", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 금액" /> + ), + cell: ({ row }) => { + const value = row.getValue("totalPrice") as string | number | null; + const currency = row.getValue("currency") as string | null; + + if (value === null || value === undefined) return "-"; + + // 숫자로 변환 시도 + const numValue = typeof value === 'string' ? parseFloat(value) : value; + + return ( + <div className="font-medium"> + {isNaN(numValue) ? value : numValue.toLocaleString()} {currency} + </div> + ); + }, + meta: { + excelHeader: "견적 금액" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="통화" /> + ), + cell: ({ row }) => <div>{row.getValue("currency")}</div>, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "validUntil", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="유효기간" /> + ), + cell: ({ cell }) => { + const value = cell.getValue() as Date | null; + return value ? formatDate(value, "KR") : "-"; + }, + meta: { + excelHeader: "유효기간" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "submittedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="제출일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue() as Date | null; + return value ? formatDate(value, "KR") : "-"; + }, + meta: { + excelHeader: "제출일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "createdByName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등록자" /> + ), + cell: ({ row }) => <div>{row.getValue("createdByName")}</div>, + meta: { + excelHeader: "등록자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "remark", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="비고" /> + ), + cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>, + meta: { + excelHeader: "비고" + }, + enableResizing: true, + size: 200, + }, + { + id: "actions", + header: () => <div className="text-right">동작</div>, + cell: function Cell({ row }) { + const vendorId = row.original.vendorId; + const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0; + + return ( + <div className="text-right flex items-center justify-end gap-1"> + {/* 커뮤니케이션 버튼 */} + <div className="relative"> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + onClick={() => setRowAction({ row, type: "communicate" })} + title="벤더와 커뮤니케이션" + > + <MessageCircle className="h-4 w-4" /> + </Button> + {unreadCount > 0 && ( + <Badge + variant="destructive" + className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center" + > + {unreadCount > 9 ? '9+' : unreadCount} + </Badge> + )} + </div> + + {/* 기존 드롭다운 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + className="flex h-8 w-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="h-4 w-4" /> + <span className="sr-only">메뉴 열기</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-[160px]"> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "update" })} + > + 벤더 수정 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "delete" })} + className="text-destructive focus:text-destructive" + > + 벤더 제거 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + ); + }, + enableResizing: false, + size: 80, + }, + ]; +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx new file mode 100644 index 00000000..4f8ac37b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -0,0 +1,654 @@ +"use client" + +import * as React from "react" +import { useEffect, useState, useCallback, useMemo } from "react" +import { + DataTableRowAction, + getRfqDetailColumns, + RfqDetailView +} from "./rfq-detail-column" +import { toast } from "sonner" + +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Loader2, UserPlus, BarChart2, Send, Trash2, MessageCircle } from "lucide-react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { AddVendorDialog } from "./add-vendor-dialog" +import { DeleteVendorDialog } from "./delete-vendor-dialog" +import { UpdateVendorSheet } from "./update-vendor-sheet" +import { VendorCommunicationDrawer } from "./vendor-communication-drawer" +import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" + +// 기본적인 RFQ 타입 정의 +interface TechSalesRfq { + id: number + rfqCode: string | null + status: string + materialCode?: string | null + itemName?: string | null + remark?: string | null + rfqSendDate?: Date | null + dueDate?: Date | null + createdByName?: string | null + // 필요에 따라 다른 필드들 추가 + [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// 프로퍼티 정의 +interface RfqDetailTablesProps { + selectedRfq: TechSalesRfq | null + maxHeight?: string | number +} + +// 데이터 타입 정의 +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; +} + +export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) { + // console.log("selectedRfq", selectedRfq) + + // 상태 관리 + const [isLoading, setIsLoading] = 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 [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) + + // 테이블 선택 상태 관리 + const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([]) + const [isSendingRfq, setIsSendingRfq] = useState(false) + const [isDeletingVendors, setIsDeletingVendors] = useState(false) + + // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) + const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) + + // existingVendorIds 메모이제이션 + const existingVendorIds = useMemo(() => { + return details.map(detail => Number(detail.vendorId)).filter(Boolean); + }, [details]); + + // 읽지 않은 메시지 로드 함수 메모이제이션 + const loadUnreadMessages = useCallback(async () => { + if (!selectedRfqId) return; + + try { + // TODO: 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 필요 + // const unreadData = await fetchUnreadMessages(selectedRfqId); + // setUnreadMessages(unreadData); + setUnreadMessages({}); + } catch (error) { + console.error("읽지 않은 메시지 로드 오류:", error); + } + }, [selectedRfqId]); + + // 데이터 새로고침 함수 메모이제이션 + const handleRefreshData = useCallback(async () => { + if (!selectedRfqId) return + + try { + // 실제 벤더 견적 데이터 다시 로딩 + const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") + + const result = await getTechSalesVendorQuotationsWithJoin({ + rfqId: selectedRfqId, + page: 1, + perPage: 1000, + }) + + // 데이터 변환 + const transformedData = result.data?.map(item => ({ + ...item, + detailId: item.id, + rfqId: selectedRfqId, + rfqCode: selectedRfq?.rfqCode || null, + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + })) || [] + + setDetails(transformedData) + + // 읽지 않은 메시지 개수 업데이트 + await loadUnreadMessages(); + + toast.success("데이터를 성공적으로 새로고침했습니다") + } catch (error) { + console.error("데이터 새로고침 오류:", error) + toast.error("데이터를 새로고침하는 중 오류가 발생했습니다") + } + }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) + + // 벤더 추가 핸들러 메모이제이션 + const handleAddVendor = useCallback(async () => { + try { + setIsAdddialogLoading(true) + + // TODO: 기술영업용 벤더, 통화, 지불조건, 인코텀즈 데이터 로드 함수 구현 필요 + // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + // fetchVendors(), + // fetchCurrencies(), + // fetchPaymentTerms(), + // fetchIncoterms() + // ]) + + // 임시 데이터 + setVendors([]) + setCurrencies([]) + setPaymentTerms([]) + setIncoterms([]) + + setVendorDialogOpen(true) + } catch (error) { + console.error("데이터 로드 오류:", error) + toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsAdddialogLoading(false) + } + }, []) + + // RFQ 발송 핸들러 메모이제이션 + const handleSendRfq = useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + if (!selectedRfqId) { + toast.error("선택된 RFQ가 없습니다."); + return; + } + + try { + setIsSendingRfq(true); + + // 기술영업 RFQ 발송 서비스 함수 호출 + const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean); + const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service"); + + const result = await sendTechSalesRfqToVendors({ + rfqId: selectedRfqId, + vendorIds: vendorIds as number[] + }); + + if (result.success) { + toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`); + } else { + toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다."); + } + + // 선택 해제 + setSelectedRows([]); + + // 데이터 새로고침 + await handleRefreshData(); + + } catch (error) { + console.error("RFQ 발송 오류:", error); + toast.error("RFQ 발송 중 오류가 발생했습니다."); + } finally { + setIsSendingRfq(false); + } + }, [selectedRows, selectedRfqId, handleRefreshData]); + + // 벤더 삭제 핸들러 메모이제이션 + const handleDeleteVendors = useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("삭제할 벤더를 선택해주세요."); + return; + } + + if (!selectedRfqId) { + toast.error("선택된 RFQ가 없습니다."); + return; + } + + try { + setIsDeletingVendors(true); + + const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[]; + + if (vendorIds.length === 0) { + toast.error("유효한 벤더 ID가 없습니다."); + return; + } + + // 서비스 함수 호출 + const { removeVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); + + const result = await removeVendorsFromTechSalesRfq({ + rfqId: selectedRfqId, + vendorIds: vendorIds + }); + + if (result.error) { + toast.error(result.error); + } else { + const successMessage = `${result.successCount}개의 벤더가 성공적으로 삭제되었습니다`; + const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : ""; + toast.success(successMessage + errorMessage); + } + + // 선택 해제 + setSelectedRows([]); + + // 데이터 새로고침 + await handleRefreshData(); + + } catch (error) { + console.error("벤더 삭제 오류:", error); + toast.error("벤더 삭제 중 오류가 발생했습니다."); + } finally { + setIsDeletingVendors(false); + } + }, [selectedRows, selectedRfqId, handleRefreshData]); + + // 견적 비교 다이얼로그 열기 핸들러 메모이제이션 + const handleOpenComparisonDialog = useCallback(() => { + // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 + const hasSubmittedQuotations = details.some(detail => + detail.status === "Submitted" // RfqDetailView의 실제 필드 사용 + ); + + if (!hasSubmittedQuotations) { + toast.warning("제출된 견적이 없습니다."); + return; + } + + setComparisonDialogOpen(true); + }, [details]) + + // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션) + const columns = useMemo(() => + getRfqDetailColumns({ + setRowAction, + unreadMessages + }), [unreadMessages]) + + // 필터 필드 정의 (메모이제이션) + const advancedFilterFields = useMemo( + () => [ + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "currency", + label: "통화", + type: "text", + }, + ], + [] + ) + + // 계산된 값들 메모이제이션 + const totalUnreadMessages = useMemo(() => + Object.values(unreadMessages).reduce((sum, count) => sum + count, 0), + [unreadMessages] + ); + + const vendorsWithQuotations = useMemo(() => + details.filter(detail => detail.status === "Submitted").length, + [details] + ); + + // RFQ ID가 변경될 때 데이터 로드 + useEffect(() => { + async function loadRfqDetails() { + if (!selectedRfqId) { + setDetails([]) + return + } + + try { + setIsLoading(true) + + // 실제 벤더 견적 데이터 로딩 + const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") + + const result = await getTechSalesVendorQuotationsWithJoin({ + rfqId: selectedRfqId, + page: 1, + perPage: 1000, // 모든 데이터 가져오기 + }) + + // 데이터 변환 (procurement 패턴에 맞게) + const transformedData = result.data?.map(item => ({ + ...item, + detailId: item.id, + rfqId: selectedRfqId, + rfqCode: selectedRfq?.rfqCode || null, + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + // 기타 필요한 필드 변환 + })) || [] + + setDetails(transformedData) + + // 읽지 않은 메시지 개수 로드 + await loadUnreadMessages(); + + } catch (error) { + console.error("RFQ 디테일 로드 오류:", error) + setDetails([]) + toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + loadRfqDetails() + }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) + + // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용 + useEffect(() => { + if (!selectedRfqId) return; + + const intervalId = setInterval(() => { + loadUnreadMessages(); + }, 60000); // 60초마다 갱신 + + return () => clearInterval(intervalId); + }, [selectedRfqId, loadUnreadMessages]); + + // rowAction 처리 - procurement 패턴 적용 (메모이제이션) + 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); + + // TODO: 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) + // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + // fetchVendors(), + // fetchCurrencies(), + // fetchPaymentTerms(), + // fetchIncoterms() + // ]); + + // 임시 데이터 + setVendors([]); + setCurrencies([]); + setPaymentTerms([]); + setIncoterms([]); + + // 이제 데이터가 로드되었으므로 필요한 작업 수행 + 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]) + + // 선택된 행 변경 핸들러 메모이제이션 + const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => { + setSelectedRows(selectedRowsData); + }, []); + + // 커뮤니케이션 드로어 변경 핸들러 메모이제이션 + const handleCommunicationDrawerChange = useCallback((open: boolean) => { + setCommunicationDrawerOpen(open); + // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 + if (!open) loadUnreadMessages(); + }, [loadUnreadMessages]); + + if (!selectedRfq) { + return ( + <div className="flex items-center justify-center h-full 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> + ) + } + + return ( + <div className="h-full overflow-hidden pt-4"> + {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + <ClientDataTable + columns={columns} + data={details} + advancedFilterFields={advancedFilterFields} + maxHeight={maxHeight} + onSelectedRowsChange={handleSelectedRowsChange} + > + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2 mr-2"> + {selectedRows.length > 0 && ( + <Badge variant="default" className="h-6"> + {selectedRows.length}개 선택됨 + </Badge> + )} + {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"> + {/* RFQ 발송 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleSendRfq} + disabled={selectedRows.length === 0 || isSendingRfq} + className="gap-2" + > + {isSendingRfq ? ( + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Send className="size-4" aria-hidden="true" /> + )} + <span>RFQ 발송</span> + </Button> + + {/* 벤더 삭제 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleDeleteVendors} + disabled={selectedRows.length === 0 || isDeletingVendors} + className="gap-2" + > + {isDeletingVendors ? ( + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Trash2 className="size-4" aria-hidden="true" /> + )} + <span>벤더 삭제</span> + </Button> + + {/* 견적 비교 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleOpenComparisonDialog} + className="gap-2" + disabled={ + !selectedRfq || + details.length === 0 || + vendorsWithQuotations === 0 + } + > + <BarChart2 className="size-4" aria-hidden="true" /> + <span>견적 비교/선택</span> + </Button> + + {/* 벤더 추가 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleAddVendor} + disabled={isAdddialogLoading} + className="gap-2" + > + {isAdddialogLoading ? ( + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <UserPlus className="size-4" aria-hidden="true" /> + )} + <span>벤더 추가</span> + </Button> + </div> + </div> + </ClientDataTable> + ) : ( + <div className="flex h-full items-center justify-center text-muted-foreground"> + <div className="text-center"> + <p className="text-lg font-medium">벤더가 없습니다</p> + <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p> + <Button + variant="outline" + size="sm" + onClick={handleAddVendor} + disabled={isAdddialogLoading} + className="mt-4 gap-2" + > + {isAdddialogLoading ? ( + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <UserPlus className="size-4" aria-hidden="true" /> + )} + <span>벤더 추가</span> + </Button> + </div> + </div> + )} + + {/* 다이얼로그들 */} + <AddVendorDialog + open={vendorDialogOpen} + onOpenChange={setVendorDialogOpen} + selectedRfq={selectedRfq} + existingVendorIds={existingVendorIds} + onSuccess={handleRefreshData} + /> + + <UpdateVendorSheet + open={updateSheetOpen} + onOpenChange={setUpdateSheetOpen} + detail={selectedDetail} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + /> + + <DeleteVendorDialog + open={deleteDialogOpen} + onOpenChange={setDeleteDialogOpen} + detail={selectedDetail} + showTrigger={false} + onSuccess={handleRefreshData} + /> + + {/* 벤더 커뮤니케이션 드로어 */} + <VendorCommunicationDrawer + open={communicationDrawerOpen} + onOpenChange={handleCommunicationDrawerChange} + selectedRfq={selectedRfq} + selectedVendor={selectedVendor} + onSuccess={handleRefreshData} + /> + + {/* 견적 비교 다이얼로그 */} + <VendorQuotationComparisonDialog + open={comparisonDialogOpen} + onOpenChange={setComparisonDialogOpen} + selectedRfq={selectedRfq} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx b/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx new file mode 100644 index 00000000..0399f4df --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx @@ -0,0 +1,449 @@ +"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 UpdateVendorSheet({ + 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/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx new file mode 100644 index 00000000..51ef7b38 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx @@ -0,0 +1,521 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { RfqDetailView } from "./rfq-detail-column" +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: { + id: number; + rfqCode: string | null; + status: string; + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any + } | 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 || 0); + setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅 + + // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 + await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0); + } 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)} + </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)} + </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/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx new file mode 100644 index 00000000..d58dbd00 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx @@ -0,0 +1,340 @@ +"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" +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" + +// Lucide 아이콘 +import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react" + +import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service" +import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions" +import { formatCurrency, formatDate } from "@/lib/utils" + +// 기술영업 견적 정보 타입 +interface TechSalesVendorQuotation { + id: number + rfqId: number + vendorId: number + vendorName?: string | null + totalPrice: string | null + currency: string | null + validUntil: Date | null + status: string + remark: string | null + submittedAt: Date | null + acceptedAt: Date | null + createdAt: Date + updatedAt: Date +} + +interface VendorQuotationComparisonDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: { + id: number; + rfqCode: string | null; + status: string; + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any + } | null +} + +export function VendorQuotationComparisonDialog({ + open, + onOpenChange, + selectedRfq, +}: VendorQuotationComparisonDialogProps) { + const [isLoading, setIsLoading] = useState(false) + const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([]) + const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null) + const [isAccepting, setIsAccepting] = useState(false) + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + + useEffect(() => { + async function loadQuotationData() { + if (!open || !selectedRfq?.id) return + + try { + setIsLoading(true) + // 기술영업 견적 목록 조회 (제출된 견적만) + const result = await getTechSalesVendorQuotationsWithJoin({ + rfqId: selectedRfq.id, + page: 1, + perPage: 100, + filters: [ + { + id: "status" as keyof typeof techSalesVendorQuotations, + value: "Submitted", + type: "select" as const, + operator: "eq" as const, + rowId: "status" + } + ] + }) + + setQuotations(result.data || []) + } 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" + } + } + + // 벤더 선택 핸들러 + const handleSelectVendor = (vendorId: number) => { + setSelectedVendorId(vendorId) + setShowConfirmDialog(true) + } + + // 벤더 선택 확정 + const handleConfirmSelection = async () => { + if (!selectedVendorId) return + + try { + setIsAccepting(true) + + // 선택된 견적의 ID 찾기 + const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId) + if (!selectedQuotation) { + toast.error("선택된 견적을 찾을 수 없습니다") + return + } + + // 벤더 선택 API 호출 + const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id) + + if (result.success) { + toast.success(result.message || "벤더가 선택되었습니다") + setShowConfirmDialog(false) + onOpenChange(false) + + // 페이지 새로고침 또는 데이터 재로드 + window.location.reload() + } else { + toast.error(result.error || "벤더 선택에 실패했습니다") + } + } catch (error) { + console.error("벤더 선택 오류:", error) + toast.error("벤더 선택에 실패했습니다") + } finally { + setIsAccepting(false) + } + } + + const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId) + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle>벤더 견적 비교 및 선택</DialogTitle> + <DialogDescription> + {selectedRfq + ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요` + : ""} + </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> + ) : ( + <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 w-32"> + 항목 + </TableHead> + {quotations.map((q) => ( + <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48"> + <div className="flex flex-col items-center gap-2"> + <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> + <Button + size="sm" + variant={q.status === "Accepted" ? "default" : "outline"} + onClick={() => handleSelectVendor(q.vendorId)} + disabled={q.status === "Accepted"} + className="gap-1" + > + {q.status === "Accepted" ? ( + <> + <CheckCircle className="h-4 w-4" /> + 선택됨 + </> + ) : ( + "선택" + )} + </Button> + </div> + </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 text-center"> + <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={`total-${q.id}`} className="p-2 font-semibold text-center"> + {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'} + </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 text-center"> + {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 text-center"> + {q.validUntil ? 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={`submitted-${q.id}`} className="p-2 text-center"> + {q.submittedAt ? 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 text-center" + > + {q.remark || "-"} + </TableCell> + ))} + </TableRow> + </tbody> + </table> + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 벤더 선택 확인 다이얼로그 */} + <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle> + <AlertDialogDescription> + <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까? + <br /> + <br /> + 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다. + 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleConfirmSelection} + disabled={isAccepting} + className="gap-2" + > + {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />} + 확인 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ) +} diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx new file mode 100644 index 00000000..b8219d7f --- /dev/null +++ b/lib/techsales-rfq/table/project-detail-dialog.tsx @@ -0,0 +1,322 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Button } from "@/components/ui/button" +import { formatDateToQuarter } from "@/lib/utils" + +// 프로젝트 스냅샷 타입 정의 +interface ProjectSnapshot { + scDt?: string + klDt?: string + lcDt?: string + dlDt?: string + dockNo?: string + dockNm?: string + projNo?: string + projNm?: string + ownerNm?: string + kunnrNm?: string + cls1Nm?: string + projMsrm?: number + ptypeNm?: string + sector?: string + estmPm?: string +} + +// 시리즈 스냅샷 타입 정의 +interface SeriesSnapshot { + sersNo?: string + scDt?: string + klDt?: string + lcDt?: string + dlDt?: string + dockNo?: string + dockNm?: string +} + +// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치) +interface TechSalesRfq { + id: number + rfqCode: string | null + itemId: number + itemName: string | null + materialCode: string | null + dueDate: Date + rfqSendDate: Date | null + status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" + picCode: string | null + remark: string | null + cancelReason: string | null + createdAt: Date + updatedAt: Date + createdBy: number | null + createdByName: string + updatedBy: number | null + updatedByName: string + sentBy: number | null + sentByName: string | null + projectSnapshot: ProjectSnapshot | null + seriesSnapshot: SeriesSnapshot[] | null + pspid: string + projNm: string + sector: string + projMsrm: number + ptypeNm: string + attachmentCount: number + quotationCount: number +} + +interface ProjectDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: TechSalesRfq | null +} + +export function ProjectDetailDialog({ + open, + onOpenChange, + selectedRfq, +}: ProjectDetailDialogProps) { + if (!selectedRfq) { + return null + } + + const projectSnapshot = selectedRfq.projectSnapshot + const seriesSnapshot = selectedRfq.seriesSnapshot + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col"> + <DialogHeader className="border-b pb-4"> + <DialogTitle className="flex items-center gap-2"> + 프로젝트 상세정보 + <Badge variant="outline">{selectedRfq.pspid}</Badge> + </DialogTitle> + <DialogDescription className="space-y-1"> + <div className="flex items-center gap-2 text-base font-medium"> + <span>RFQ:</span> + <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge> + <span>|</span> + <span>자재:</span> + <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span> + </div> + <div className="text-sm text-muted-foreground"> + {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"}) + </div> + </DialogDescription> + </DialogHeader> + <div className="space-y-6 p-1 overflow-y-auto"> + {/* 기본 프로젝트 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">기본 정보</h3> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div> + <div className="text-sm">{selectedRfq.pspid}</div> + </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">프로젝트명</div> + <div className="text-sm">{selectedRfq.projNm}</div> + </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">선종</div> + <div className="text-sm">{selectedRfq.ptypeNm}</div> + </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">척수</div> + <div className="text-sm">{selectedRfq.projMsrm}</div> + </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">섹터</div> + <div className="text-sm">{selectedRfq.sector}</div> + </div> + </div> + </div> + + <Separator /> + + {/* 프로젝트 스냅샷 정보 */} + {projectSnapshot && ( + <div className="space-y-4"> + <h3 className="text-lg font-semibold">프로젝트 스냅샷</h3> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4"> + {projectSnapshot.scDt && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">S/C</div> + <div className="text-sm">{formatDateToQuarter(projectSnapshot.scDt)}</div> + </div> + )} + {projectSnapshot.klDt && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">K/L</div> + <div className="text-sm">{formatDateToQuarter(projectSnapshot.klDt)}</div> + </div> + )} + {projectSnapshot.lcDt && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">L/C</div> + <div className="text-sm">{formatDateToQuarter(projectSnapshot.lcDt)}</div> + </div> + )} + {projectSnapshot.dlDt && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">D/L</div> + <div className="text-sm">{formatDateToQuarter(projectSnapshot.dlDt)}</div> + </div> + )} + {projectSnapshot.dockNo && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">도크번호</div> + <div className="text-sm">{projectSnapshot.dockNo}</div> + </div> + )} + {projectSnapshot.dockNm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">도크명</div> + <div className="text-sm">{projectSnapshot.dockNm}</div> + </div> + )} + {projectSnapshot.projNo && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">공사번호</div> + <div className="text-sm">{projectSnapshot.projNo}</div> + </div> + )} + {projectSnapshot.projNm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">공사명</div> + <div className="text-sm">{projectSnapshot.projNm}</div> + </div> + )} + {projectSnapshot.ownerNm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">선주</div> + <div className="text-sm">{projectSnapshot.ownerNm}</div> + </div> + )} + {projectSnapshot.kunnrNm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">선주명</div> + <div className="text-sm">{projectSnapshot.kunnrNm}</div> + </div> + )} + {projectSnapshot.cls1Nm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">선급명</div> + <div className="text-sm">{projectSnapshot.cls1Nm}</div> + </div> + )} + {projectSnapshot.projMsrm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">척수</div> + <div className="text-sm">{projectSnapshot.projMsrm}</div> + </div> + )} + {projectSnapshot.ptypeNm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">선종명</div> + <div className="text-sm">{projectSnapshot.ptypeNm}</div> + </div> + )} + {projectSnapshot.sector && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">섹터</div> + <div className="text-sm">{projectSnapshot.sector}</div> + </div> + )} + {projectSnapshot.estmPm && ( + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">견적 PM</div> + <div className="text-sm">{projectSnapshot.estmPm}</div> + </div> + )} + </div> + </div> + )} + + {/* 시리즈 스냅샷 정보 */} + {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && ( + <> + <Separator /> + <div className="space-y-4"> + <h3 className="text-lg font-semibold">시리즈 정보 스냅샷</h3> + <div className="space-y-4"> + {seriesSnapshot.map((series: SeriesSnapshot, index: number) => ( + <div key={index} className="border rounded-lg p-4 space-y-3"> + <div className="flex items-center gap-2"> + <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> + {series.scDt && ( + <div className="space-y-1"> + <div className="text-xs font-medium text-muted-foreground">S/C</div> + <div className="text-sm">{formatDateToQuarter(series.scDt)}</div> + </div> + )} + {series.klDt && ( + <div className="space-y-1"> + <div className="text-xs font-medium text-muted-foreground">K/L</div> + <div className="text-sm">{formatDateToQuarter(series.klDt)}</div> + </div> + )} + {series.lcDt && ( + <div className="space-y-1"> + <div className="text-xs font-medium text-muted-foreground">L/C</div> + <div className="text-sm">{formatDateToQuarter(series.lcDt)}</div> + </div> + )} + {series.dlDt && ( + <div className="space-y-1"> + <div className="text-xs font-medium text-muted-foreground">D/L</div> + <div className="text-sm">{formatDateToQuarter(series.dlDt)}</div> + </div> + )} + {series.dockNo && ( + <div className="space-y-1"> + <div className="text-xs font-medium text-muted-foreground">도크번호</div> + <div className="text-sm">{series.dockNo}</div> + </div> + )} + {series.dockNm && ( + <div className="space-y-1"> + <div className="text-xs font-medium text-muted-foreground">도크명</div> + <div className="text-sm">{series.dockNm}</div> + </div> + )} + </div> + </div> + ))} + </div> + </div> + </> + )} + + {/* 추가 정보가 없는 경우 */} + {!projectSnapshot && !seriesSnapshot && ( + <div className="text-center py-8 text-muted-foreground"> + 추가 프로젝트 상세정보가 없습니다. + </div> + )} + </div> + + {/* 닫기 버튼 */} + <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4"> + <div className="flex justify-end"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx new file mode 100644 index 00000000..6021699f --- /dev/null +++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx @@ -0,0 +1,759 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { DateRangePicker } from "@/components/date-range-picker" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { useTranslation } from '@/i18n/client' +import { getFiltersStateParser } from "@/lib/parsers" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 필터 스키마 정의 (TechSales RFQ에 맞게 수정) +const filterSchema = z.object({ + rfqCode: z.string().optional(), + materialCode: z.string().optional(), + itemName: z.string().optional(), + pspid: z.string().optional(), + projNm: z.string().optional(), + ptypeNm: z.string().optional(), + createdByName: z.string().optional(), + status: z.string().optional(), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정) +const statusOptions = [ + { value: "RFQ Created", label: "RFQ Created" }, + { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, + { value: "RFQ Sent", label: "RFQ Sent" }, + { value: "Quotation Analysis", label: "Quotation Analysis" }, + { value: "Closed", label: "Closed" }, +] + +type FilterFormValues = z.infer<typeof filterSchema> + +interface RFQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +// Updated component for inline use (not a sheet anymore) +export function RFQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: RFQFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + const { t } = useTranslation(lng); + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 + const [isInitializing, setIsInitializing] = useState(false) + // 마지막으로 적용된 필터를 추적하기 위한 ref + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<FilterFormValues>({ + resolver: zodResolver(filterSchema), + defaultValues: { + rfqCode: "", + materialCode: "", + itemName: "", + pspid: "", + projNm: "", + ptypeNm: "", + createdByName: "", + status: "", + dateRange: { + from: undefined, + to: undefined, + }, + }, + }) + + // URL 필터에서 초기 폼 상태 설정 - 개선된 버전 + useEffect(() => { + // 현재 필터를 문자열로 직렬화 + const currentFiltersString = JSON.stringify(filters); + + // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { + formValues.dateRange = { + from: filter.value[0] ? new Date(filter.value[0]) : undefined, + to: filter.value[1] ? new Date(filter.value[1]) : undefined, + }; + formUpdated = true; + } else if (filter.id in formValues) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (formValues as any)[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen, form]) // form 의존성 추가 + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 조회 버튼 클릭 핸들러 + const handleSearch = () => { + // 필터 패널 닫기 로직이 있다면 여기에 추가 + if (onSearch) { + onSearch(); + } + } + + // 폼 제출 핸들러 - 개선된 버전 + async function onSubmit(data: FilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.materialCode?.trim()) { + newFilters.push({ + id: "materialCode", + value: data.materialCode.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.itemName?.trim()) { + newFilters.push({ + id: "itemName", + value: data.itemName.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.pspid?.trim()) { + newFilters.push({ + id: "pspid", + value: data.pspid.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.projNm?.trim()) { + newFilters.push({ + id: "projNm", + value: data.projNm.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.ptypeNm?.trim()) { + newFilters.push({ + id: "ptypeNm", + value: data.ptypeNm.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.createdByName?.trim()) { + newFilters.push({ + id: "createdByName", + value: data.createdByName.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select" as const, + operator: "eq" as const, + rowId: generateId() + }) + } + + // Add date range to params if it exists + if (data.dateRange?.from) { + newFilters.push({ + id: "rfqSendDate", + value: [ + data.dateRange.from.toISOString().split('T')[0], + data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined + ].filter(Boolean) as string[], + type: "date" as const, + operator: "isBetween" as const, + rowId: generateId() + }) + } + + console.log("기본 필터 적용:", newFilters); + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 먼저 필터를 설정 + await setFilters(newFilters.length > 0 ? newFilters : null); + + // 그 다음 페이지를 1로 설정 + await setPage("1"); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + handleSearch(); + + // 페이지 새로고침으로 서버 데이터 다시 가져오기 + setTimeout(() => { + window.location.reload(); + }, 100); + } catch (error) { + console.error("필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 - 개선된 버전 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + rfqCode: "", + materialCode: "", + itemName: "", + pspid: "", + projNm: "", + ptypeNm: "", + createdByName: "", + status: "", + dateRange: { from: undefined, to: undefined }, + }); + + // 필터와 조인 연산자를 초기화 + await setFilters(null); + await setJoinOperator("and"); + await setPage("1"); + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("필터 초기화 완료"); + setIsInitializing(false); + + // 페이지 새로고침으로 서버 데이터 다시 가져오기 + setTimeout(() => { + window.location.reload(); + }, 100); + } catch (error) { + console.error("필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full p-4"> + {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3> + </div> + + {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-6 pt-4"> + {/* RFQ NO. */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ NO.")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("RFQ 번호 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("rfqCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재코드 */} + <FormField + control={form.control} + name="materialCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재코드 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("materialCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재명 */} + <FormField + control={form.control} + name="itemName" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재명")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재명 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("itemName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 ID */} + <FormField + control={form.control} + name="pspid" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("프로젝트 ID")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("프로젝트 ID 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("pspid", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트명 */} + <FormField + control={form.control} + name="projNm" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("프로젝트명")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("프로젝트명 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("projNm", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선종명 */} + <FormField + control={form.control} + name="ptypeNm" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("선종명")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("선종명 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("ptypeNm", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 요청자 */} + <FormField + control={form.control} + name="createdByName" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("요청자")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("요청자 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("createdByName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("Status")}</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder={t("Select status")} /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ 전송일 */} + <FormField + control={form.control} + name="dateRange" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ 전송일")}</FormLabel> + <FormControl> + <div className="relative"> + <DateRangePicker + triggerSize="default" + triggerClassName="w-full bg-white" + align="start" + showClearButton={true} + placeholder={t("RFQ 전송일 범위를 고르세요")} + date={field.value || undefined} + onDateChange={field.onChange} + disabled={isInitializing} + /> + {(field.value?.from || field.value?.to) && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-10 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("dateRange", { from: undefined, to: undefined }); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + {t("초기화")} + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? t("조회 중...") : t("조회")} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx new file mode 100644 index 00000000..caaa1c97 --- /dev/null +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -0,0 +1,409 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { Check, Pencil, X, Info } from "lucide-react" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { Input } from "@/components/ui/input" + +// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함) +type TechSalesRfq = { + id: number + rfqCode: string | null + itemId: number + itemName: string | null + materialCode: string | null + dueDate: Date + rfqSendDate: Date | null + status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" + picCode: string | null + remark: string | null + cancelReason: string | null + createdAt: Date + updatedAt: Date + createdBy: number | null + createdByName: string + updatedBy: number | null + updatedByName: string + sentBy: number | null + sentByName: string | null + projectSnapshot: any + seriesSnapshot: any + pspid: string + projNm: string + sector: string + projMsrm: number + ptypeNm: string + attachmentCount: number + quotationCount: number + // 나머지 필드는 사용할 때마다 추가 + [key: string]: any +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>; + // 상태와 상태 설정 함수를 props로 받음 + editingCell: EditingCellState | null; + setEditingCell: (state: EditingCellState | null) => void; + updateRemark: (rfqId: number, remark: string) => Promise<void>; +} + +export interface EditingCellState { + rowId: string | number; + value: string; +} + + +export function getColumns({ + setRowAction, + editingCell, + setEditingCell, + updateRemark, +}: GetColumnsProps): ColumnDef<TechSalesRfq>[] { + return [ + { + id: "select", + // Remove the "Select all" checkbox in header since we're doing single-select + header: () => <span className="sr-only">Select</span>, + cell: ({ row, table }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + // If selecting this row + if (value) { + // First deselect all rows (to ensure single selection) + table.toggleAllRowsSelected(false) + // Then select just this row + row.toggleSelected(true) + // Trigger the same action that was in the "Select" button + setRowAction({ row, type: "select" }) + } else { + // Just deselect this row + row.toggleSelected(false) + } + }} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 40, + minSize: 40, + maxSize: 40, + }, + + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행상태" /> + ), + cell: ({ row }) => <div>{row.getValue("status")}</div>, + meta: { + excelHeader: "진행상태" + }, + enableResizing: true, + minSize: 80, + size: 100, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ No." /> + ), + cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>, + meta: { + excelHeader: "RFQ No." + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "materialCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재코드" /> + ), + cell: ({ row }) => <div>{row.getValue("materialCode")}</div>, + meta: { + excelHeader: "자재코드" + }, + enableResizing: true, + minSize: 80, + size: 120, + }, + { + accessorKey: "itemName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재명" /> + ), + cell: ({ row }) => <div>{row.getValue("itemName")}</div>, + meta: { + excelHeader: "자재명" + }, + enableResizing: true, + size: 180, + }, + { + accessorKey: "pspid", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 번호" /> + ), + cell: ({ row }) => <div>{row.getValue("pspid")}</div>, + meta: { + excelHeader: "프로젝트 번호" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "projNm", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> + ), + cell: ({ row }) => <div>{row.getValue("projNm")}</div>, + meta: { + excelHeader: "프로젝트명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "projMsrm", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="척수" /> + ), + cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>, + meta: { + excelHeader: "척수" + }, + enableResizing: true, + minSize: 60, + size: 80, + }, + { + accessorKey: "ptypeNm", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="선종" /> + ), + cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>, + meta: { + excelHeader: "선종" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "quotationCount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적수" /> + ), + cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>, + meta: { + excelHeader: "견적수" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "rfqSendDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "RFQ 전송일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "RFQ 마감일" + }, + enableResizing: true, + minSize: 80, + size: 120, + }, + { + accessorKey: "createdByName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청자" /> + ), + cell: ({ row }) => <div>{row.getValue("createdByName")}</div>, + meta: { + excelHeader: "요청자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등록일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDateTime(value as Date) : ""; + }, + meta: { + excelHeader: "등록일" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDateTime(value as Date) : ""; + }, + meta: { + excelHeader: "수정일" + }, + enableResizing: true, + size: 160, + }, + // { + // accessorKey: "remark", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="비고" /> + // ), + // cell: ({ row }) => { + // const id = row.original.id; + // const value = row.getValue("remark") as string; + + // const isEditing = + // editingCell?.rowId === row.id && + // editingCell.value !== undefined; + + // const startEditing = () => { + // setEditingCell({ + // rowId: row.id, + // value: value || "" + // }); + // }; + + // const cancelEditing = () => { + // setEditingCell(null); + // }; + + // const saveChanges = async () => { + // if (!editingCell) return; + + // try { + // await updateRemark(id, editingCell.value); + // setEditingCell(null); + // } catch (error) { + // toast.error("비고 업데이트 중 오류가 발생했습니다."); + // console.error("Error updating remark:", error); + // } + // }; + + // const handleKeyDown = (e: React.KeyboardEvent) => { + // if (e.key === "Enter") { + // saveChanges(); + // } else if (e.key === "Escape") { + // cancelEditing(); + // } + // }; + + // if (isEditing) { + // return ( + // <div className="flex items-center gap-1"> + // <Input + // value={editingCell?.value || ""} + // onChange={(e) => setEditingCell({ + // rowId: row.id, + // value: e.target.value + // })} + // onKeyDown={handleKeyDown} + // autoFocus + // className="h-8 w-full" + // /> + // <Button + // variant="ghost" + // size="icon" + // onClick={saveChanges} + // className="h-8 w-8" + // > + // <Check className="h-4 w-4" /> + // </Button> + // <Button + // variant="ghost" + // size="icon" + // onClick={cancelEditing} + // className="h-8 w-8" + // > + // <X className="h-4 w-4" /> + // </Button> + // </div> + // ); + // } + + // return ( + // <div className="flex items-center gap-1 group"> + // <span className="truncate">{value || ""}</span> + // <Button + // variant="ghost" + // size="icon" + // onClick={startEditing} + // className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity" + // > + // <Pencil className="h-3 w-3" /> + // </Button> + // </div> + // ); + // }, + // meta: { + // excelHeader: "비고" + // }, + // enableResizing: true, + // size: 200, + // }, + { + id: "actions", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="액션" /> + ), + cell: ({ row }) => { + return ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "project-detail" })} + className="h-8 px-2 gap-1" + > + <Info className="h-4 w-4" /> + <span className="hidden sm:inline">프로젝트 상세</span> + </Button> + ); + }, + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 120, + minSize: 120, + maxSize: 120, + }, + ] +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx new file mode 100644 index 00000000..da716eeb --- /dev/null +++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import { Download, RefreshCw } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { type Table } from "@tanstack/react-table" +import { CreateRfqDialog } from "./create-rfq-dialog" + +interface RFQTableToolbarActionsProps<TData> { + selection: Table<TData>; + onRefresh?: () => void; +} + +export function RFQTableToolbarActions<TData>({ + selection, + onRefresh +}: RFQTableToolbarActionsProps<TData>) { + + // 데이터 새로고침 + const handleRefresh = () => { + if (onRefresh) { + onRefresh(); + toast.success("데이터를 새로고침했습니다"); + } + } + + return ( + <div className="flex items-center gap-2"> + {/* RFQ 생성 다이얼로그 */} + <CreateRfqDialog onCreated={onRefresh} /> + + {/* 새로고침 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + className="gap-2" + > + <RefreshCw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">새로고침</span> + </Button> + + {/* 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(selection, { + filename: "tech_sales_rfq", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">내보내기</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx new file mode 100644 index 00000000..3139b1a3 --- /dev/null +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -0,0 +1,524 @@ +"use client" + +import * as React from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { getColumns, EditingCellState } from "./rfq-table-column" +import { useEffect, useCallback, useMemo } from "react" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" +import { getTechSalesRfqsWithJoin } from "@/lib/techsales-rfq/service" +import { toast } from "sonner" +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { RfqDetailTables } from "./detail-table/rfq-detail-table" +import { cn } from "@/lib/utils" +import { ProjectDetailDialog } from "./project-detail-dialog" +import { RFQFilterSheet } from "./rfq-filter-sheet" + +// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤) +interface TechSalesRfq { + id: number + rfqCode: string | null + itemId: number + itemName: string | null + materialCode: string | null + dueDate: Date + rfqSendDate: Date | null + status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" + picCode: string | null + remark: string | null + cancelReason: string | null + createdAt: Date + updatedAt: Date + createdBy: number | null + createdByName: string + updatedBy: number | null + updatedByName: string + sentBy: number | null + sentByName: string | null + projectSnapshot: any + seriesSnapshot: any + pspid: string + projNm: string + sector: string + projMsrm: number + ptypeNm: string + attachmentCount: number + quotationCount: number + // 필요에 따라 다른 필드들 추가 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any +} + +interface RFQListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]> + className?: string; + calculatedHeight?: string; // 계산된 높이 추가 +} + +export function RFQListTable({ + promises, + className, + calculatedHeight +}: RFQListTableProps) { + const searchParams = useSearchParams() + + // 필터 패널 상태 + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + + // 선택된 RFQ 상태 + const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null) + + // 프로젝트 상세정보 다이얼로그 상태 + const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false) + const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null) + + // 패널 collapse 상태 + const [panelHeight, setPanelHeight] = React.useState<number>(55) + + // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) + const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이 + const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값) + const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border) + const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비 + + // 높이 계산 + // 필터 패널 높이 - Layout Header와 Footer 사이 + const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)` + + console.log(calculatedHeight) + + // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외 + const FIXED_TABLE_HEIGHT = calculatedHeight + ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)` + : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback + + // Suspense 방식으로 데이터 처리 + const [promiseData] = React.use(promises) + const tableData = promiseData + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null) + const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) + + // 초기 설정 정의 + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams?.get('page') || '1'), + perPage: parseInt(searchParams?.get('perPage') || '10'), + sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], + filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], + basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams?.get('search') || '', + from: searchParams?.get('from') || undefined, + to: searchParams?.get('to') || undefined, + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: [] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + // DB 기반 프리셋 훅 사용 + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings) + + // 비고 업데이트 함수 + const updateRemark = useCallback(async (rfqId: number, remark: string) => { + try { + // 기술영업 RFQ 비고 업데이트 함수 구현 필요 + // const result = await updateTechSalesRfqRemark(rfqId, remark); + console.log("Update remark for RFQ:", rfqId, "with:", remark); + + toast.success("비고가 업데이트되었습니다"); + } catch (error) { + console.error("비고 업데이트 오류:", error); + toast.error("업데이트 중 오류가 발생했습니다"); + } + }, []) + + // 조회 버튼 클릭 핸들러 + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + // 행 액션 처리 + useEffect(() => { + if (rowAction) { + switch (rowAction.type) { + case "select": + // 객체 참조 안정화를 위해 필요한 필드만 추출 + const rfqData = rowAction.row.original; + setSelectedRfq({ + id: rfqData.id, + rfqCode: rfqData.rfqCode, + itemId: rfqData.itemId, + itemName: rfqData.itemName, + materialCode: rfqData.materialCode, + dueDate: rfqData.dueDate, + rfqSendDate: rfqData.rfqSendDate, + status: rfqData.status, + picCode: rfqData.picCode, + remark: rfqData.remark, + cancelReason: rfqData.cancelReason, + createdAt: rfqData.createdAt, + updatedAt: rfqData.updatedAt, + createdBy: rfqData.createdBy, + createdByName: rfqData.createdByName, + updatedBy: rfqData.updatedBy, + updatedByName: rfqData.updatedByName, + sentBy: rfqData.sentBy, + sentByName: rfqData.sentByName, + projectSnapshot: rfqData.projectSnapshot, + seriesSnapshot: rfqData.seriesSnapshot, + pspid: rfqData.pspid, + projNm: rfqData.projNm, + sector: rfqData.sector, + projMsrm: rfqData.projMsrm, + ptypeNm: rfqData.ptypeNm, + attachmentCount: rfqData.attachmentCount, + quotationCount: rfqData.quotationCount, + }); + break; + case "project-detail": + // 프로젝트 상세정보 다이얼로그 열기 + const projectRfqData = rowAction.row.original; + setProjectDetailRfq({ + id: projectRfqData.id, + rfqCode: projectRfqData.rfqCode, + itemId: projectRfqData.itemId, + itemName: projectRfqData.itemName, + materialCode: projectRfqData.materialCode, + dueDate: projectRfqData.dueDate, + rfqSendDate: projectRfqData.rfqSendDate, + status: projectRfqData.status, + picCode: projectRfqData.picCode, + remark: projectRfqData.remark, + cancelReason: projectRfqData.cancelReason, + createdAt: projectRfqData.createdAt, + updatedAt: projectRfqData.updatedAt, + createdBy: projectRfqData.createdBy, + createdByName: projectRfqData.createdByName, + updatedBy: projectRfqData.updatedBy, + updatedByName: projectRfqData.updatedByName, + sentBy: projectRfqData.sentBy, + sentByName: projectRfqData.sentByName, + projectSnapshot: projectRfqData.projectSnapshot, + seriesSnapshot: projectRfqData.seriesSnapshot, + pspid: projectRfqData.pspid, + projNm: projectRfqData.projNm, + sector: projectRfqData.sector, + projMsrm: projectRfqData.projMsrm, + ptypeNm: projectRfqData.ptypeNm, + attachmentCount: projectRfqData.attachmentCount, + quotationCount: projectRfqData.quotationCount, + }); + setIsProjectDetailOpen(true); + break; + case "update": + console.log("Update rfq:", rowAction.row.original) + break; + case "delete": + console.log("Delete rfq:", rowAction.row.original) + break; + } + setRowAction(null) + } + }, [rowAction]) + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + editingCell, + setEditingCell, + updateRemark + }), + [editingCell, setEditingCell, updateRemark] + ) + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [ + { + id: "rfqCode", + label: "RFQ No.", + type: "text", + }, + { + id: "materialCode", + label: "자재코드", + type: "text", + }, + { + id: "itemName", + label: "자재명", + type: "text", + }, + { + id: "pspid", + label: "프로젝트 ID", + type: "text", + }, + { + id: "projNm", + label: "프로젝트명", + type: "text", + }, + { + id: "ptypeNm", + label: "선종명", + type: "text", + }, + { + id: "rfqSendDate", + label: "RFQ 전송일", + type: "date", + }, + { + id: "dueDate", + label: "RFQ 마감일", + type: "date", + }, + { + id: "createdByName", + label: "요청자", + type: "text", + }, + { + id: "status", + label: "상태", + type: "text", + }, + ] + + // 현재 설정 가져오기 + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + // useDataTable 초기 상태 설정 + const initialState = useMemo(() => { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sorting: initialSettings.sort.filter((sortItem: any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id) + return columnExists + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + // useDataTable 훅 설정 + const { table } = useDataTable({ + data: tableData?.data || [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: columns as any, + pageCount: tableData?.pageCount || 0, + rowCount: tableData?.total || 0, + filterFields: [], + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + // Get active basic filter count + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams?.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch { + return 0 + } + } + + console.log(panelHeight) + + return ( + <div + className={cn("flex flex-col relative", className)} + style={{ height: calculatedHeight }} + > + {/* Filter Panel - 계산된 높이 적용 */} + <div + className={cn( + "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${LAYOUT_HEADER_HEIGHT*2}px`, + height: FIXED_FILTER_HEIGHT + }} + > + {/* Filter Content */} + <div className="h-full"> + <RFQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content */} + <div + className="flex flex-col transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + height: '100%' + }} + > + {/* Header Bar - 고정 높이 */} + <div + className="flex items-center justify-between p-4 bg-background border-b" + style={{ + height: `${LOCAL_HEADER_HEIGHT}px`, + flexShrink: 0 + }} + > + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + {/* Right side info */} + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || 0}건</span> + )} + </div> + </div> + + {/* Table Content Area - 계산된 높이 사용 */} + <div + className="relative bg-background" + style={{ + height: FIXED_TABLE_HEIGHT, + display: 'grid', + gridTemplateRows: '1fr', + gridTemplateColumns: '1fr' + }} + > + <ResizablePanelGroup + direction="vertical" + className="w-full h-full" + > + <ResizablePanel + defaultSize={60} + minSize={25} + maxSize={75} + collapsible={false} + onResize={(size) => { + setPanelHeight(size) + }} + className="flex flex-col overflow-hidden" + > + {/* 상단 테이블 영역 */} + <div className="flex-1 min-h-0 overflow-hidden"> + <DataTable + table={table} + maxHeight={`${panelHeight*0.5}vh`} + > + <DataTableAdvancedToolbar + // eslint-disable-next-line @typescript-eslint/no-explicit-any + table={table as any} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<TechSalesRfq> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <RFQTableToolbarActions + selection={table} + onRefresh={() => {}} + /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + minSize={25} + defaultSize={40} + collapsible={false} + className="flex flex-col overflow-hidden" + > + {/* 하단 상세 테이블 영역 */} + <div className="flex-1 min-h-0 overflow-hidden bg-background"> + <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/> + </div> + </ResizablePanel> + </ResizablePanelGroup> + </div> + </div> + + {/* 프로젝트 상세정보 다이얼로그 */} + <ProjectDetailDialog + open={isProjectDetailOpen} + onOpenChange={setIsProjectDetailOpen} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedRfq={projectDetailRfq as any} + /> + </div> + ) +}
\ No newline at end of file |
