diff options
Diffstat (limited to 'lib/techsales-rfq/table')
| -rw-r--r-- | lib/techsales-rfq/table/create-rfq-dialog.tsx | 749 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | 2 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-column.tsx | 41 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table.tsx | 52 |
4 files changed, 562 insertions, 282 deletions
diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx index 5faa3a0b..bacec02e 100644 --- a/lib/techsales-rfq/table/create-rfq-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx @@ -44,12 +44,20 @@ import { } from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" // 실제 데이터 서비스 import import { - getShipbuildingItemsByWorkType, - searchShipbuildingItems, getWorkTypes, + getAllShipbuildingItemsForCache, + getShipTypes, type ShipbuildingItem, type WorkType } from "@/lib/items-tech/service" @@ -81,7 +89,7 @@ interface CreateRfqDialogProps { onCreated?: () => void; } -export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { +export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { const { data: session } = useSession() const [isProcessing, setIsProcessing] = React.useState(false) const [isDialogOpen, setIsDialogOpen] = React.useState(false) @@ -90,13 +98,115 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { // 검색 및 필터링 상태 const [itemSearchQuery, setItemSearchQuery] = React.useState("") const [selectedWorkType, setSelectedWorkType] = React.useState<WorkType | null>(null) + const [selectedShipType, setSelectedShipType] = React.useState<string | 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 [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([]) + const [shipTypes, setShipTypes] = React.useState<string[]>([]) const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState<string | null>(null) + const [retryCount, setRetryCount] = React.useState(0) + + // 데이터 로딩 함수를 useCallback으로 메모이제이션 + const loadData = React.useCallback(async (isRetry = false) => { + try { + if (!isRetry) { + setIsLoadingItems(true) + setDataLoadError(null) + } + + console.log(`데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) + + const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ + getWorkTypes(), + getAllShipbuildingItemsForCache(), + getShipTypes() + ]) + + console.log("WorkTypes 결과:", workTypesResult) + console.log("Items 결과:", itemsResult) + console.log("ShipTypes 결과:", shipTypesResult) + + // WorkTypes 설정 + if (Array.isArray(workTypesResult)) { + setWorkTypes(workTypesResult) + } else { + console.error("WorkTypes 데이터 형식 오류:", workTypesResult) + throw new Error("공종 데이터를 불러올 수 없습니다.") + } + + // Items 설정 + if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) { + setAllItems(itemsResult.data) + console.log("아이템 설정 완료:", itemsResult.data.length, "개") + } else { + console.error("아이템 로딩 실패:", itemsResult.error) + throw new Error(itemsResult.error || "아이템 데이터를 불러올 수 없습니다.") + } + + // ShipTypes 설정 + if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) { + setShipTypes(shipTypesResult.data) + console.log("선종 설정 완료:", shipTypesResult.data) + } else { + console.error("선종 로딩 실패:", shipTypesResult.error) + throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") + } + + // 성공 시 재시도 카운터 리셋 + setRetryCount(0) + setDataLoadError(null) + console.log("데이터 로딩 완료") + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + console.error("데이터 로딩 오류:", errorMessage) + + setDataLoadError(errorMessage) + + // 3회까지 자동 재시도 (500ms 간격) + if (retryCount < 2) { + console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) + setTimeout(() => { + setRetryCount(prev => prev + 1) + loadData(true) + }, 500 * (retryCount + 1)) + } else { + // 재시도 실패 시 사용자에게 알림 + toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) + } + } finally { + if (!isRetry) { + setIsLoadingItems(false) + } + } + }, [retryCount]) + + // 다이얼로그가 열릴 때마다 데이터 로딩 + React.useEffect(() => { + if (isDialogOpen) { + // 다이얼로그가 열릴 때마다 데이터 상태 초기화 및 로딩 + setDataLoadError(null) + setRetryCount(0) + + // 이미 데이터가 있고 에러가 없다면 로딩하지 않음 (성능 최적화) + if (allItems.length > 0 && workTypes.length > 0 && shipTypes.length > 0 && !dataLoadError) { + console.log("기존 데이터 사용 (캐시)") + return + } + + loadData() + } + }, [isDialogOpen, loadData, allItems.length, workTypes.length, shipTypes.length, dataLoadError]) + + // 수동 새로고침 함수 + const handleRefreshData = React.useCallback(() => { + setDataLoadError(null) + setRetryCount(0) + loadData() + }, [loadData]) // RFQ 생성 폼 const form = useForm<CreateRfqFormValues>({ @@ -104,55 +214,42 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { defaultValues: { biddingProjectId: undefined, materialCodes: [], - dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후 + dueDate: undefined, // 기본값 제거 } }) - // 공종 목록 로드 - React.useEffect(() => { - const loadWorkTypes = async () => { - const types = await getWorkTypes() - setWorkTypes(types) + // 필터링된 아이템 목록 가져오기 + const availableItems = React.useMemo(() => { + let filtered = [...allItems] + + // 선종 필터 + if (selectedShipType) { + filtered = filtered.filter(item => item.shipTypes === selectedShipType) } - 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) + // 공종 필터 + if (selectedWorkType) { + filtered = filtered.filter(item => item.workType === selectedWorkType) } - }, [itemSearchQuery, selectedWorkType]) - // 아이템 검색 디바운스 - React.useEffect(() => { - setIsSearchingItems(true) - const timer = setTimeout(() => { - loadItems() - setIsSearchingItems(false) - }, 300) + // 검색어 필터 + if (itemSearchQuery && itemSearchQuery.trim()) { + const query = itemSearchQuery.toLowerCase().trim() + filtered = filtered.filter(item => + item.itemCode.toLowerCase().includes(query) || + item.itemName.toLowerCase().includes(query) || + (item.description && item.description.toLowerCase().includes(query)) || + (item.itemList && item.itemList.toLowerCase().includes(query)) + ) + } + + return filtered + }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) - return () => clearTimeout(timer) - }, [loadItems]) + // 사용 가능한 선종 목록 가져오기 + const availableShipTypes = React.useMemo(() => { + return shipTypes + }, [shipTypes]) // 프로젝트 선택 처리 const handleProjectSelect = (project: Project) => { @@ -160,6 +257,9 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { form.setValue("biddingProjectId", project.id) // 선택 초기화 setSelectedItems([]) + setSelectedShipType(null) + setSelectedWorkType(null) + setItemSearchQuery("") form.setValue("materialCodes", []) } @@ -255,13 +355,16 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { form.reset({ biddingProjectId: undefined, materialCodes: [], - dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + dueDate: undefined, // 기본값 제거 }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) + setSelectedShipType(null) setSelectedItems([]) - setAvailableItems([]) + // 에러 상태 및 재시도 카운터 초기화 + setDataLoadError(null) + setRetryCount(0) // 생성 후 콜백 실행 if (onCreated) { @@ -277,7 +380,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { } return ( - <Dialog + <Dialog open={isDialogOpen} onOpenChange={(open) => { setIsDialogOpen(open) @@ -285,13 +388,16 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { form.reset({ biddingProjectId: undefined, materialCodes: [], - dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + dueDate: undefined, // 기본값 제거 }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) + setSelectedShipType(null) setSelectedItems([]) - setAvailableItems([]) + // 에러 상태 및 재시도 카운터 초기화 + setDataLoadError(null) + setRetryCount(0) } }} > @@ -306,95 +412,177 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { <span className="hidden sm:inline">RFQ 생성</span> </Button> </DialogTrigger> - <DialogContent className="max-w-4xl w-[90vw] h-[90vh] overflow-hidden flex flex-col"> - <DialogHeader> + <DialogContent + className="max-w-none h-[90vh] overflow-y-auto flex flex-col" + style={{ width: '1200px' }} + > + <DialogHeader className="border-b pb-4"> <DialogTitle>RFQ 생성</DialogTitle> </DialogHeader> - <div className="flex-1 overflow-y-auto"> + <div className="space-y-6 p-1 overflow-y-auto"> <Form {...form}> - <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-4"> + <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6"> {/* 프로젝트 선택 */} - <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 + <div className="space-y-4"> + <FormField + control={form.control} + name="biddingProjectId" + render={({ field }) => ( + <FormItem> + <FormLabel>입찰 프로젝트</FormLabel> + <FormControl> + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="입찰 프로젝트를 선택하세요" /> - </PopoverContent> - </Popover> - <FormDescription> - 벤더가 견적을 제출해야 하는 마감일입니다. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - <Separator className="my-4" /> - - {!selectedProject ? ( - <div className="text-sm text-muted-foreground italic text-center py-8"> - 먼저 프로젝트를 선택해주세요 + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + {/* 선종 선택 */} + <div className="space-y-4"> + <div> + <FormLabel>선종 선택</FormLabel> + </div> + + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( + <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <X className="h-4 w-4 text-destructive" /> + <span className="text-sm text-destructive">{dataLoadError}</span> + </div> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isLoadingItems} + className="h-8 text-xs" + > + {isLoadingItems ? ( + <> + <Loader2 className="h-3 w-3 animate-spin mr-1" /> + 재시도 중... + </> + ) : ( + "다시 시도" + )} + </Button> + </div> + </div> + )} + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + className="w-full justify-between" + disabled={!selectedProject || isLoadingItems || dataLoadError !== null} + > + {isLoadingItems ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + 데이터 로딩 중... + </> + ) : dataLoadError ? ( + "데이터 로딩 실패" + ) : selectedShipType ? ( + selectedShipType + ) : ( + "미선택 (전체)" + )} + <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-full max-h-60 overflow-y-auto"> + <DropdownMenuCheckboxItem + checked={selectedShipType === null} + onCheckedChange={() => { + setSelectedShipType(null) + setSelectedItems([]) + form.setValue("materialCodes", []) + }} + > + 전체 선종 + </DropdownMenuCheckboxItem> + {availableShipTypes.map(shipType => ( + <DropdownMenuCheckboxItem + key={shipType} + checked={selectedShipType === shipType} + onCheckedChange={() => { + setSelectedShipType(shipType) + setSelectedItems([]) + form.setValue("materialCodes", []) + }} + > + {shipType} + </DropdownMenuCheckboxItem> + ))} + </DropdownMenuContent> + </DropdownMenu> </div> - ) : ( + + <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> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + <div className="space-y-6"> {/* 아이템 선택 영역 */} <div className="space-y-4"> <div> <FormLabel>조선 아이템 선택</FormLabel> <FormDescription> - 공종별 아이템을 선택하세요 + {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"} </FormDescription> </div> @@ -408,6 +596,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { value={itemSearchQuery} onChange={(e) => setItemSearchQuery(e.target.value)} className="pl-8 pr-8" + disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} /> {itemSearchQuery && ( <Button @@ -415,19 +604,21 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { size="sm" className="absolute right-0 top-0 h-full px-3" onClick={() => setItemSearchQuery("")} + disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} > <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"> + <Button + variant="outline" + className="gap-1" + disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} + > {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"} <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> </Button> @@ -457,10 +648,41 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { <div className="border rounded-md"> <ScrollArea className="h-[300px]"> <div className="p-2 space-y-1"> - {isLoadingItems ? ( + {dataLoadError ? ( + <div className="text-center py-8"> + <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4"> + <div className="flex flex-col items-center gap-3"> + <X className="h-8 w-8 text-destructive" /> + <div className="text-center"> + <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p> + <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p> + </div> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isLoadingItems} + className="h-8" + > + {isLoadingItems ? ( + <> + <Loader2 className="h-3 w-3 animate-spin mr-1" /> + 재시도 중... + </> + ) : ( + "다시 시도" + )} + </Button> + </div> + </div> + </div> + ) : isLoadingItems ? ( <div className="text-center py-8 text-muted-foreground"> <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" /> 아이템을 불러오는 중... + {retryCount > 0 && ( + <p className="text-xs mt-1">재시도 {retryCount}회</p> + )} </div> ) : availableItems.length > 0 ? ( [...availableItems] @@ -547,146 +769,149 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { /> {/* RFQ 그룹핑 미리보기 */} - {selectedItems.length > 0 && ( - <div className="space-y-3"> - <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel> - <div className="border rounded-md p-3 bg-background"> - {(() => { - // 아이템명(itemList)으로 그룹핑 - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item) - return groups - }, {} as Record<string, typeof selectedItems>) + <div className="space-y-3"> + <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel> + <div className="border rounded-md bg-background"> + {(() => { + // 아이템명(itemList)으로 그룹핑 + const groupedItems = selectedItems.reduce((groups, item) => { + const actualItemName = item.itemList // 실제 조선 아이템명 + if (!actualItemName) { + return groups // itemList가 없는 경우 제외 + } + if (!groups[actualItemName]) { + groups[actualItemName] = [] + } + groups[actualItemName].push(item) + return groups + }, {} as Record<string, typeof selectedItems>) - const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { - const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 - const joinedItemCodes = itemCodes.join(',') - return { - actualItemName, - items, - itemCodes, - joinedItemCodes, - codeLength: joinedItemCodes.length, - isOverLimit: joinedItemCodes.length > 255 - } - }) + const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { + const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 + const joinedItemCodes = itemCodes.join(',') + return { + actualItemName, + items, + itemCodes, + joinedItemCodes, + codeLength: joinedItemCodes.length, + isOverLimit: joinedItemCodes.length > 255 + } + }) - return ( - <div className="space-y-3"> - <div className="text-sm text-muted-foreground"> - 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑) - </div> - {rfqGroups.map((group, index) => ( - <div - key={group.actualItemName} - className={cn( - "p-3 border rounded-md space-y-2", - group.isOverLimit && "border-destructive bg-destructive/5" - )} - > - <div className="flex items-center justify-between"> - <div className="font-medium"> - RFQ #{index + 1}: {group.actualItemName} - </div> - <Badge variant={group.isOverLimit ? "destructive" : "secondary"}> - {group.itemCodes.length}개 자재코드 ({group.codeLength}/255자) - </Badge> - </div> - <div className="text-sm text-muted-foreground"> - 자재코드: {group.joinedItemCodes} - </div> - {group.isOverLimit && ( - <div className="text-sm text-destructive"> - ⚠️ 자재코드 길이가 255자를 초과합니다. 일부 아이템을 제거해주세요. - </div> - )} - <div className="text-xs text-muted-foreground"> - 포함된 아이템: {group.items.map(item => `${item.itemCode}`).join(', ')} - </div> - </div> - ))} + return ( + <div className="space-y-3"> + <div className="text-sm text-muted-foreground p-3 border-b"> + 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑) </div> - ) - })()} - </div> + <ScrollArea className="h-[200px]"> + <Table> + <TableHeader className="sticky top-0 bg-background"> + <TableRow> + <TableHead className="w-[80px]">RFQ #</TableHead> + <TableHead>아이템명</TableHead> + <TableHead className="w-[120px]">자재그룹코드 개수</TableHead> + <TableHead className="w-[100px]">길이</TableHead> + <TableHead className="w-[80px]">상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {rfqGroups.map((group, index) => ( + <TableRow + key={group.actualItemName} + className={group.isOverLimit ? "bg-destructive/5" : ""} + > + <TableCell className="font-medium">#{index + 1}</TableCell> + <TableCell> + <div className="max-w-[200px] truncate" title={group.actualItemName}> + {group.actualItemName} + </div> + </TableCell> + <TableCell>{group.itemCodes.length}개</TableCell> + <TableCell> + <span className={group.isOverLimit ? "text-destructive font-medium" : ""}> + {group.codeLength}/255자 + </span> + </TableCell> + <TableCell> + {group.isOverLimit ? ( + <Badge variant="destructive" className="text-xs">초과</Badge> + ) : ( + <Badge variant="secondary" className="text-xs">정상</Badge> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + </div> + ) + })()} </div> - )} + </div> </div> </div> - )} - - {/* 안내 메시지 */} - {/* {selectedProject && ( - <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> - <p>• 공종별 조선 아이템을 선택하세요.</p> - <p>• <strong>같은 아이템명의 다른 자재코드들은 하나의 RFQ로 그룹핑됩니다.</strong></p> - <p>• 그룹핑된 자재코드들은 comma로 구분되어 저장됩니다.</p> - <p>• 자재코드 길이는 최대 255자까지 가능합니다.</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 || - // 255자 초과 그룹이 있는지 확인 - (() => { - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item.itemCode) - return groups - }, {} as Record<string, string[]>) - - return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255) - })() - } - > - {isProcessing ? "처리 중..." : (() => { - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item.itemCode) - return groups - }, {} as Record<string, string[]>) - - const groupCount = Object.keys(groupedItems).length - return `${groupCount}개 아이템 그룹으로 생성하기` - })()} - </Button> </div> </form> </Form> </div> + + {/* Footer - Sticky 버튼 영역 */} + <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4"> + <div className="flex justify-end space-x-2"> + <Button + type="button" + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isProcessing} + > + 취소 + </Button> + <Button + type="button" + onClick={form.handleSubmit(handleCreateRfq)} + disabled={ + isProcessing || + !selectedProject || + selectedItems.length === 0 || + // 255자 초과 그룹이 있는지 확인 + (() => { + const groupedItems = selectedItems.reduce((groups, item) => { + const actualItemName = item.itemList // 실제 조선 아이템명 + if (!actualItemName) { + return groups // itemList가 없는 경우 제외 + } + if (!groups[actualItemName]) { + groups[actualItemName] = [] + } + groups[actualItemName].push(item.itemCode) + return groups + }, {} as Record<string, string[]>) + + return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255) + })() + } + > + {isProcessing ? "처리 중..." : (() => { + const groupedItems = selectedItems.reduce((groups, item) => { + const actualItemName = item.itemList // 실제 조선 아이템명 + if (!actualItemName) { + return groups // itemList가 없는 경우 제외 + } + if (!groups[actualItemName]) { + groups[actualItemName] = [] + } + groups[actualItemName].push(item.itemCode) + return groups + }, {} as Record<string, string[]>) + + const groupCount = Object.keys(groupedItems).length + return `${groupCount}개 아이템 그룹으로 생성하기` + })()} + </Button> + </div> + </div> </DialogContent> </Dialog> ) diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index dbaeae0c..ba530fe3 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -15,9 +15,9 @@ import { Button } from "@/components/ui/button" import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react" import { ClientDataTable } from "@/components/client-data-table/data-table" import { AddVendorDialog } from "./add-vendor-dialog" -import { DeleteVendorsDialog } from "./delete-vendors-dialog" import { VendorCommunicationDrawer } from "./vendor-communication-drawer" import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" +import { DeleteVendorsDialog } from "../delete-vendors-dialog" // 기본적인 RFQ 타입 정의 interface TechSalesRfq { diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index 4f7bd499..dfb85420 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -30,8 +30,40 @@ type TechSalesRfq = { updatedByName: string sentBy: number | null sentByName: string | null - projectSnapshot: Record<string, unknown> - seriesSnapshot: Record<string, unknown> + // 스키마와 일치하도록 타입 수정 + projectSnapshot: { + pspid: string; + projNm?: string; + sector?: string; + projMsrm?: number; + kunnr?: string; + kunnrNm?: string; + cls1?: string; + cls1Nm?: string; + ptype?: string; + ptypeNm?: string; + pmodelCd?: string; + pmodelNm?: string; + pmodelSz?: string; + pmodelUom?: string; + txt04?: string; + txt30?: string; + estmPm?: string; + pspCreatedAt?: Date | string; + pspUpdatedAt?: Date | string; + } | Record<string, unknown> // legacy 호환성을 위해 유지 + seriesSnapshot: Array<{ + pspid: string; + sersNo: string; + scDt?: string; + klDt?: string; + lcDt?: string; + dlDt?: string; + dockNo?: string; + dockNm?: string; + projNo?: string; + post1?: string; + }> | Record<string, unknown> // legacy 호환성을 위해 유지 pspid: string projNm: string sector: string @@ -43,11 +75,6 @@ type TechSalesRfq = { [key: string]: unknown } -// 프로젝트 상세정보 타입 추가를 위한 확장 -// interface ExtendedDataTableRowAction<TData> extends DataTableRowAction<TData> { -// type: DataTableRowAction<TData>["type"] | "project-detail" -// } - interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>; openAttachmentsSheet: (rfqId: number) => void; diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx index c79e2ecf..f1570577 100644 --- a/lib/techsales-rfq/table/rfq-table.tsx +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -51,8 +51,40 @@ interface TechSalesRfq { updatedByName: string sentBy: number | null sentByName: string | null - projectSnapshot: Record<string, unknown> - seriesSnapshot: Record<string, unknown> + // 스키마와 일치하도록 타입 수정 + projectSnapshot: { + pspid: string; + projNm?: string; + sector?: string; + projMsrm?: number; + kunnr?: string; + kunnrNm?: string; + cls1?: string; + cls1Nm?: string; + ptype?: string; + ptypeNm?: string; + pmodelCd?: string; + pmodelNm?: string; + pmodelSz?: string; + pmodelUom?: string; + txt04?: string; + txt30?: string; + estmPm?: string; + pspCreatedAt?: Date | string; + pspUpdatedAt?: Date | string; + } | Record<string, unknown> // legacy 호환성을 위해 유지 + seriesSnapshot: Array<{ + pspid: string; + sersNo: string; + scDt?: string; + klDt?: string; + lcDt?: string; + dlDt?: string; + dockNo?: string; + dockNm?: string; + projNo?: string; + post1?: string; + }> | Record<string, unknown> // legacy 호환성을 위해 유지 pspid: string projNm: string sector: string @@ -61,7 +93,6 @@ interface TechSalesRfq { attachmentCount: number quotationCount: number // 필요에 따라 다른 필드들 추가 - // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: unknown } @@ -279,7 +310,7 @@ export function RFQListTable({ setSelectedRfqForAttachments({ ...rfq, projectSnapshot: rfq.projectSnapshot || {}, - seriesSnapshot: rfq.seriesSnapshot || {}, + seriesSnapshot: Array.isArray(rfq.seriesSnapshot) ? rfq.seriesSnapshot : {}, }) setAttachmentsOpen(true) } catch (error) { @@ -290,19 +321,15 @@ export function RFQListTable({ // 첨부파일 업데이트 콜백 const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => { - // TODO: 실제로는 테이블 데이터를 다시 조회하거나 상태를 업데이트해야 함 - // 현재는 로그만 출력하고 토스트 메시지로 피드백 제공 + // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨 console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`) - // 성공 피드백 (중복되지 않도록 짧은 지연 후 표시) + // 성공 피드백 setTimeout(() => { toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, { duration: 3000 }) }, 500) - - // TODO: 나중에 실제 테이블 데이터 업데이트 로직 구현 - // 예: setTableData() 또는 데이터 재조회 }, []) const columns = React.useMemo( @@ -518,7 +545,8 @@ export function RFQListTable({ shallow={false} > <div className="flex items-center gap-2"> - <TablePresetManager<TechSalesRfq> + {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */} + {/* <TablePresetManager<TechSalesRfq> presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} @@ -530,7 +558,7 @@ export function RFQListTable({ onApplyPreset={applyPreset} onSetDefaultPreset={setDefaultPreset} onRenamePreset={renamePreset} - /> + /> */} <RFQTableToolbarActions selection={table} |
