diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-30 03:36:16 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-30 03:36:16 +0000 |
| commit | 2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (patch) | |
| tree | 32383a85f691c04619abafc4bd9dedf269e78128 /lib/techsales-rfq/table/create-rfq-dialog.tsx | |
| parent | 6ce9a6e26fea92f82ab26cf4bb0837f170162dd0 (diff) | |
(김준회) 기술영업 조선 RFQ 오류 수정 및 선종 필터 기능 추가
Diffstat (limited to 'lib/techsales-rfq/table/create-rfq-dialog.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/create-rfq-dialog.tsx | 749 |
1 files changed, 487 insertions, 262 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> ) |
