diff options
Diffstat (limited to 'lib/rfq-last/table')
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 779 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-attachments-dialog.tsx | 351 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-items-dialog.tsx | 354 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-columns.tsx | 1206 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 15 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table.tsx | 26 |
6 files changed, 2372 insertions, 359 deletions
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx new file mode 100644 index 00000000..14564686 --- /dev/null +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -0,0 +1,779 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle, Check, ChevronsUpDown } from "lucide-react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } from "../service" + +// 아이템 스키마 +const itemSchema = z.object({ + itemCode: z.string().min(1, "자재코드를 입력해주세요"), + itemName: z.string().min(1, "자재명을 입력해주세요"), + quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), + uom: z.string().min(1, "단위를 입력해주세요"), + remark: z.string().optional(), +}) + +// 일반견적 생성 폼 스키마 +const createGeneralRfqSchema = z.object({ + rfqType: z.string().min(1, "견적 종류를 선택해주세요"), + customRfqType: z.string().optional(), + rfqTitle: z.string().min(1, "견적명을 입력해주세요"), + dueDate: z.date({ + required_error: "마감일을 선택해주세요", + }), + picUserId: z.number().min(1, "구매 담당자를 선택해주세요"), + remark: z.string().optional(), + items: z.array(itemSchema).min(1, "최소 하나의 아이템을 추가해주세요"), +}).refine((data) => { + if (data.rfqType === "기타") { + return data.customRfqType && data.customRfqType.trim().length > 0 + } + return true +}, { + message: "견적 종류를 직접 입력해주세요", + path: ["customRfqType"], +}) + +type CreateGeneralRfqFormValues = z.infer<typeof createGeneralRfqSchema> + +interface CreateGeneralRfqDialogProps { + onSuccess?: () => void; +} + +export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [users, setUsers] = React.useState<Array<{ id: number; name: string }>>([]) + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false) + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false) + const [userSearchTerm, setUserSearchTerm] = React.useState("") + const [previewCode, setPreviewCode] = React.useState("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const userOptionIdsRef = React.useRef<Record<number, string>>({}) + const router = useRouter() + const { data: session } = useSession() + + // 고유 ID 생성 + const buttonId = React.useMemo(() => `user-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + const popoverContentId = React.useMemo(() => `user-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + const commandId = React.useMemo(() => `user-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + const form = useForm<CreateGeneralRfqFormValues>({ + resolver: zodResolver(createGeneralRfqSchema), + defaultValues: { + rfqType: "", + customRfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: userId || undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }) + + // 견적 종류 변경 시 customRfqType 필드 초기화 + const handleRfqTypeChange = (value: string) => { + form.setValue("rfqType", value) + if (value !== "기타") { + form.setValue("customRfqType", "") + } + } + + // RFQ 코드 미리보기 생성 + const generatePreviewCode = React.useCallback(async () => { + const picUserId = form.watch("picUserId") + + if (!picUserId) { + setPreviewCode("") + return + } + + setIsLoadingPreview(true) + try { + const code = await previewGeneralRfqCode(picUserId) + setPreviewCode(code) + } catch (error) { + console.error("코드 미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + }, [form]) + + // 필드 변경 감지해서 미리보기 업데이트 + React.useEffect(() => { + const subscription = form.watch((value, { name }) => { + if (name === "picUserId") { + generatePreviewCode() + } + }) + return () => subscription.unsubscribe() + }, [form, generatePreviewCode]) + + // 사용자 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true) + try { + const userList = await getPUsersForFilter() + setUsers(userList) + } catch (error) { + console.log("사용자 목록 로드 오류:", error) + toast.error("사용자 목록을 불러오는데 실패했습니다") + } finally { + setIsLoadingUsers(false) + } + } + loadUsers() + }, []) + + // 세션 사용자 ID로 기본값 설정 + React.useEffect(() => { + if (userId && !form.getValues("picUserId")) { + form.setValue("picUserId", userId) + } + }, [userId, form]) + + // 사용자 검색 필터링 + const userOptions = React.useMemo(() => { + return users.filter((user) => + user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) + ) + }, [users, userSearchTerm]) + + // 선택된 사용자 찾기 + const selectedUser = React.useMemo(() => { + const picUserId = form.watch("picUserId") + return users.find(user => user.id === picUserId) + }, [users, form.watch("picUserId")]) + + // 사용자 선택 핸들러 + const handleSelectUser = (user: { id: number; name: string }) => { + form.setValue("picUserId", user.id) + setUserPopoverOpen(false) + setUserSearchTerm("") + } + + // 다이얼로그 열림/닫힘 처리 및 폼 리셋 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + + // 다이얼로그가 닫힐 때 폼과 상태 초기화 + if (!newOpen) { + form.reset({ + rfqType: "", + customRfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: userId || undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }) + setUserSearchTerm("") + setUserPopoverOpen(false) + setPreviewCode("") + setIsLoadingPreview(false) + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + const onSubmit = async (data: CreateGeneralRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + setIsLoading(true) + + try { + // 견적 종류가 "기타"인 경우 customRfqType 사용 + const finalRfqType = data.rfqType === "기타" ? data.customRfqType || "기타" : data.rfqType + + // 서버 액션 호출 + const result = await createGeneralRfqAction({ + rfqType: finalRfqType, + rfqTitle: data.rfqTitle, + dueDate: data.dueDate, + picUserId: data.picUserId, + remark: data.remark || "", + items: data.items, + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: `RFQ 코드: ${result.data?.rfqCode}`, + }) + + // 다이얼로그 닫기 + setOpen(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + + // RFQ 상세 페이지로 이동 (선택사항) + // router.push(`/rfq/${result.data?.rfqCode}`) + + } else { + toast.error(result.error || "일반견적 생성에 실패했습니다") + } + + } catch (error) { + console.error('일반견적 생성 오류:', error) + toast.error("일반견적 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + // 아이템 추가 + const handleAddItem = () => { + append({ + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }) + } + + const isCustomRfqType = form.watch("rfqType") === "기타" + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="samsung" size="sm" className="h-8 px-2 lg:px-3"> + <Plus className="mr-2 h-4 w-4" /> + 일반견적 생성 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + {/* 고정된 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>일반견적 생성</DialogTitle> + <DialogDescription> + 새로운 일반견적을 생성합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <ScrollArea className="flex-1 px-1"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> + + {/* 기본 정보 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">기본 정보</h3> + + <div className="grid grid-cols-2 gap-4"> + {/* 견적 종류 */} + <div className="space-y-2"> + <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적 종류 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={handleRfqTypeChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="견적 종류 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="정기견적">정기견적</SelectItem> + <SelectItem value="긴급견적">긴급견적</SelectItem> + <SelectItem value="단가계약">단가계약</SelectItem> + <SelectItem value="기술견적">기술견적</SelectItem> + <SelectItem value="예산견적">예산견적</SelectItem> + <SelectItem value="기타">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 기타 견적 종류 입력 필드 */} + {isCustomRfqType && ( + <FormField + control={form.control} + name="customRfqType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적 종류 직접 입력 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="견적 종류를 입력해주세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 마감일 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 마감일 <span className="text-red-500">*</span> + </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, "yyyy-MM-dd") + ) : ( + <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> + )} + /> + </div> + + {/* 견적명 */} + <FormField + control={form.control} + name="rfqTitle" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="예: 2025년 1분기 사무용품 구매 견적" + {...field} + /> + </FormControl> + <FormDescription> + 견적의 목적이나 내용을 간단명료하게 입력해주세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구매 담당자 - 검색 가능한 셀렉터로 변경 */} + <FormField + control={form.control} + name="picUserId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 구매 담당자 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingUsers} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {selectedUser ? selectedUser.name : "구매 담당자를 선택하세요"} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="담당자 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + key={`${commandId}-list`} + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + <CommandEmpty key={`${commandId}-empty`}>담당자를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {userOptions.map((user, userIndex) => { + if (!userOptionIdsRef.current[user.id]) { + userOptionIdsRef.current[user.id] = + `user-${user.id}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = userOptionIdsRef.current[user.id] + + return ( + <CommandItem + key={`${optionId}-${userIndex}`} + onSelect={() => handleSelectUser(user)} + value={user.name} + className="truncate" + title={user.name} + > + <span className="truncate">{user.name}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + {/* RFQ 코드 미리보기 */} + {previewCode && ( + <div className="flex items-center gap-2 mt-2"> + <Badge variant="secondary" className="font-mono text-sm"> + 예상 RFQ 코드: {previewCode} + </Badge> + {isLoadingPreview && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="추가 비고사항을 입력하세요" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 아이템 정보 섹션 - 컴팩트한 UI */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">아이템 정보</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={handleAddItem} + > + <PlusCircle className="mr-2 h-4 w-4" /> + 아이템 추가 + </Button> + </div> + + <div className="space-y-3"> + {fields.map((field, index) => ( + <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50"> + <div className="flex items-center justify-between mb-3"> + <span className="text-sm font-medium text-gray-700"> + 아이템 #{index + 1} + </span> + {fields.length > 1 && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => remove(index)} + className="h-6 w-6 p-0 text-destructive hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + </Button> + )} + </div> + + <div className="grid grid-cols-4 gap-3"> + {/* 자재코드 */} + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 자재코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="MAT-001" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재명 */} + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 자재명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="A4 용지" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 수량 */} + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 수량 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="number" + min="1" + placeholder="1" + className="h-8 text-sm" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 단위 */} + <FormField + control={form.control} + name={`items.${index}.uom`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 단위 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="EA" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 비고 - 별도 행에 배치 */} + <div className="mt-3"> + <FormField + control={form.control} + name={`items.${index}.remark`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs">비고</FormLabel> + <FormControl> + <Input + placeholder="아이템별 비고사항" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + ))} + </div> + </div> + </form> + </Form> + </ScrollArea> + + {/* 고정된 푸터 */} + <DialogFooter className="flex-shrink-0"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "일반견적 생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-attachments-dialog.tsx b/lib/rfq-last/table/rfq-attachments-dialog.tsx new file mode 100644 index 00000000..253daaa2 --- /dev/null +++ b/lib/rfq-last/table/rfq-attachments-dialog.tsx @@ -0,0 +1,351 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqAttachmentsAction } from "../service" +import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download" + +// 첨부파일 타입 +interface RfqAttachment { + attachmentId: number + attachmentType: string + serialNo: string + description: string | null + currentRevision: string + fileName: string + originalFileName: string + filePath: string + fileSize: number | null + fileType: string | null + createdByName: string | null + createdAt: Date | null + updatedAt: Date | null + revisionComment?: string | null +} + +interface RfqAttachmentsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachmentsDialogProps) { + const [attachments, setAttachments] = React.useState<RfqAttachment[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set()) + + // 첨부파일 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadAttachments = async () => { + setIsLoading(true) + try { + const result = await getRfqAttachmentsAction(rfqData.id) + + if (result.success) { + setAttachments(result.data) + } else { + toast.error(result.error || "첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } + } catch (error) { + console.error("첨부파일 로드 오류:", error) + toast.error("첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } finally { + setIsLoading(false) + } + } + + loadAttachments() + }, [isOpen, rfqData.id]) + + // 파일 다운로드 핸들러 + const handleDownload = async (attachment: RfqAttachment) => { + const attachmentId = attachment.attachmentId + setDownloadingFiles(prev => new Set([...prev, attachmentId])) + + try { + const result = await downloadFile( + attachment.filePath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`) + }, + onError: (error) => { + console.error(`다운로드 실패: ${error}`) + } + } + ) + + if (!result.success) { + console.error("다운로드 결과:", result) + } + } catch (error) { + console.error("파일 다운로드 오류:", error) + toast.error("파일 다운로드에 실패했습니다") + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(attachmentId) + return newSet + }) + } + } + + // 파일 미리보기 핸들러 + const handlePreview = async (attachment: RfqAttachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + + if (!fileInfo.canPreview) { + toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.") + return handleDownload(attachment) + } + + try { + const result = await quickPreview(attachment.filePath, attachment.originalFileName) + + if (!result.success) { + console.error("미리보기 결과:", result) + } + } catch (error) { + console.error("파일 미리보기 오류:", error) + toast.error("파일 미리보기에 실패했습니다") + } + } + + // 스마트 파일 액션 (미리보기 가능하면 미리보기, 아니면 다운로드) + const handleSmartAction = async (attachment: RfqAttachment) => { + const attachmentId = attachment.attachmentId + const fileInfo = getFileInfo(attachment.originalFileName) + + if (fileInfo.canPreview) { + return handlePreview(attachment) + } else { + return handleDownload(attachment) + } + } + + // 첨부파일 타입별 색상 + const getAttachmentTypeBadgeVariant = (type: string) => { + switch (type.toLowerCase()) { + case "견적요청서": return "default" + case "기술사양서": return "secondary" + case "도면": return "outline" + default: return "outline" + } + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-6xl h-[85vh] flex flex-col"> + <DialogHeader> + <DialogTitle>견적 첨부파일</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"} + {attachments.length > 0 && ` (${attachments.length}개 파일)`} + </DialogDescription> + </DialogHeader> + + <ScrollArea className="flex-1"> + {isLoading ? ( + <div className="space-y-3"> + {[...Array(3)].map((_, i) => ( + <div key={i} className="flex items-center space-x-4 p-3 border rounded-lg"> + <Skeleton className="h-8 w-8" /> + <div className="space-y-2 flex-1"> + <Skeleton className="h-4 w-[300px]" /> + <Skeleton className="h-3 w-[200px]" /> + </div> + <div className="flex gap-2"> + <Skeleton className="h-8 w-20" /> + <Skeleton className="h-8 w-20" /> + </div> + </div> + ))} + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">타입</TableHead> + <TableHead>파일명</TableHead> + <TableHead>설명</TableHead> + <TableHead className="w-[90px]">리비전</TableHead> + <TableHead className="w-[100px]">크기</TableHead> + <TableHead className="w-[120px]">생성자</TableHead> + <TableHead className="w-[120px]">생성일</TableHead> + <TableHead className="w-[140px]">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {attachments.length === 0 ? ( + <TableRow> + <TableCell colSpan={8} className="text-center text-muted-foreground py-12"> + <div className="flex flex-col items-center gap-2"> + <FileText className="h-8 w-8 text-muted-foreground" /> + <span>첨부된 파일이 없습니다.</span> + </div> + </TableCell> + </TableRow> + ) : ( + attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + const isDownloading = downloadingFiles.has(attachment.attachmentId) + + return ( + <TableRow key={attachment.attachmentId}> + <TableCell> + <Badge + variant={getAttachmentTypeBadgeVariant(attachment.attachmentType)} + className="text-xs" + > + {attachment.attachmentType} + </Badge> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <span className="text-lg">{fileInfo.icon}</span> + <div className="flex flex-col min-w-0"> + <span className="text-sm font-medium truncate" title={attachment.originalFileName}> + {attachment.originalFileName} + </span> + {attachment.fileName !== attachment.originalFileName && ( + <span className="text-xs text-muted-foreground truncate" title={attachment.fileName}> + {attachment.fileName} + </span> + )} + </div> + </div> + </TableCell> + <TableCell> + <span className="text-sm" title={attachment.description || ""}> + {attachment.description || "-"} + </span> + {attachment.revisionComment && ( + <div className="text-xs text-muted-foreground mt-1"> + {attachment.revisionComment} + </div> + )} + </TableCell> + <TableCell> + <Badge variant="secondary" className="font-mono text-xs"> + {attachment.currentRevision} + </Badge> + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.fileSize ? formatFileSize(attachment.fileSize) : "-"} + </TableCell> + <TableCell className="text-sm"> + {attachment.createdByName || "-"} + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.createdAt ? format(new Date(attachment.createdAt), "MM-dd HH:mm") : "-"} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {/* 미리보기 버튼 (미리보기 가능한 파일만) */} + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="sm" + onClick={() => handlePreview(attachment)} + disabled={isDownloading} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + + {/* 다운로드 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleDownload(attachment)} + disabled={isDownloading} + title="다운로드" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + + {/* 스마트 액션 버튼 (메인 액션) */} + <Button + variant="outline" + size="sm" + onClick={() => handleSmartAction(attachment)} + disabled={isDownloading} + className="ml-1" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin mr-1" /> + ) : fileInfo.canPreview ? ( + <Eye className="h-4 w-4 mr-1" /> + ) : ( + <Download className="h-4 w-4 mr-1" /> + )} + {fileInfo.canPreview ? "보기" : "다운로드"} + </Button> + </div> + </TableCell> + </TableRow> + ) + }) + )} + </TableBody> + </Table> + )} + </ScrollArea> + + {/* 하단 정보 */} + {attachments.length > 0 && !isLoading && ( + <div className="border-t pt-4 text-xs text-muted-foreground"> + <div className="flex justify-between items-center"> + <span> + 총 {attachments.length}개 파일 + {attachments.some(a => a.fileSize) && + ` · 전체 크기: ${formatFileSize( + attachments.reduce((sum, a) => sum + (a.fileSize || 0), 0) + )}` + } + </span> + <span> + {fileInfo.icon} 미리보기 가능 | 📥 다운로드 + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx new file mode 100644 index 00000000..5d7e0747 --- /dev/null +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -0,0 +1,354 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Package, ExternalLink } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqItemsAction } from "../service" + +// 품목 타입 +interface RfqItem { + id: number + rfqsLastId: number | null + rfqItem: string | null + prItem: string | null + prNo: string | null + materialCode: string | null + materialCategory: string | null + acc: string | null + materialDescription: string | null + size: string | null + deliveryDate: Date | null + quantity: number | null + uom: string | null + grossWeight: number | null + gwUom: string | null + specNo: string | null + specUrl: string | null + trackingNo: string | null + majorYn: boolean | null + remark: string | null + projectDef: string | null + projectSc: string | null + projectKl: string | null + projectLc: string | null + projectDl: string | null + // RFQ 관련 정보 + rfqCode: string | null + rfqType: string | null + rfqTitle: string | null + itemCode: string | null + itemName: string | null + projectCode: string | null + projectName: string | null +} + +interface ItemStatistics { + total: number + major: number + regular: number + totalQuantity: number + totalWeight: number +} + +interface RfqItemsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { + const [items, setItems] = React.useState<RfqItem[]>([]) + const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null) + const [isLoading, setIsLoading] = React.useState(false) + + // 품목 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadItems = async () => { + setIsLoading(true) + try { + const result = await getRfqItemsAction(rfqData.id) + + if (result.success) { + setItems(result.data) + setStatistics(result.statistics) + } else { + toast.error(result.error || "품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } + } catch (error) { + console.error("품목 로드 오류:", error) + toast.error("품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [isOpen, rfqData.id]) + + // 사양서 링크 열기 + const handleOpenSpec = (specUrl: string) => { + window.open(specUrl, '_blank', 'noopener,noreferrer') + } + + // 수량 포맷팅 + const formatQuantity = (quantity: number | null, uom: string | null) => { + if (!quantity) return "-" + return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}` + } + + // 중량 포맷팅 + const formatWeight = (weight: number | null, uom: string | null) => { + if (!weight) return "-" + return `${weight.toLocaleString()} ${uom || "KG"}` + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>견적 품목 목록</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} + </DialogDescription> + </DialogHeader> + + {/* 통계 정보 */} + {statistics && !isLoading && ( + <> + <div className="grid grid-cols-5 gap-3 py-3"> + <div className="text-center"> + <div className="text-2xl font-bold text-primary">{statistics.total}</div> + <div className="text-xs text-muted-foreground">전체 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-blue-600">{statistics.major}</div> + <div className="text-xs text-muted-foreground">주요 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> + <div className="text-xs text-muted-foreground">일반 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 수량</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 중량 (KG)</div> + </div> + </div> + <Separator /> + </> + )} + + <ScrollArea className="flex-1"> + {isLoading ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">구분</TableHead> + <TableHead className="w-[120px]">자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="w-[100px]">수량</TableHead> + <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[80px]">사양</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {[...Array(3)].map((_, i) => ( + <TableRow key={i}> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) : items.length === 0 ? ( + <div className="text-center text-muted-foreground py-12"> + <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> + <p>품목이 없습니다.</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">구분</TableHead> + <TableHead className="w-[120px]">자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="w-[100px]">수량</TableHead> + <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[100px]">사양</TableHead> + <TableHead className="w-[100px]">프로젝트</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item, index) => ( + <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}> + <TableCell> + <div className="flex flex-col items-center gap-1"> + <span className="text-xs font-mono">#{index + 1}</span> + {item.majorYn && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span> + {item.acc && ( + <span className="text-xs text-muted-foreground font-mono"> + ACC: {item.acc} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-sm font-medium" title={item.materialDescription || ""}> + {item.materialDescription || "-"} + </span> + {item.materialCategory && ( + <span className="text-xs text-muted-foreground"> + {item.materialCategory} + </span> + )} + {item.size && ( + <span className="text-xs text-muted-foreground"> + 크기: {item.size} + </span> + )} + </div> + </TableCell> + <TableCell> + <span className="text-sm font-medium"> + {formatQuantity(item.quantity, item.uom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {formatWeight(item.grossWeight, item.gwUom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} + </span> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-xs font-mono">{item.prNo || "-"}</span> + {item.prItem && item.prItem !== item.prNo && ( + <span className="text-xs text-muted-foreground font-mono"> + {item.prItem} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {item.specNo && ( + <span className="text-xs font-mono">{item.specNo}</span> + )} + {item.specUrl && ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0" + onClick={() => handleOpenSpec(item.specUrl!)} + title="사양서 열기" + > + <ExternalLink className="h-3 w-3" /> + </Button> + )} + {item.trackingNo && ( + <div className="text-xs text-muted-foreground"> + TRK: {item.trackingNo} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="text-xs"> + {[ + item.projectDef && `DEF: ${item.projectDef}`, + item.projectSc && `SC: ${item.projectSc}`, + item.projectKl && `KL: ${item.projectKl}`, + item.projectLc && `LC: ${item.projectLc}`, + item.projectDl && `DL: ${item.projectDl}` + ].filter(Boolean).join(" | ") || "-"} + </div> + </TableCell> + <TableCell> + <span className="text-xs" title={item.remark || ""}> + {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} + </span> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </ScrollArea> + + {/* 하단 통계 정보 */} + {statistics && !isLoading && ( + <div className="border-t pt-3 text-xs text-muted-foreground"> + <div className="flex justify-between items-center"> + <span> + 총 {statistics.total}개 품목 + (주요: {statistics.major}개, 일반: {statistics.regular}개) + </span> + <span> + 전체 수량: {statistics.totalQuantity.toLocaleString()} | + 전체 중량: {statistics.totalWeight.toLocaleString()} KG + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx index 3fac8881..1b523adc 100644 --- a/lib/rfq-last/table/rfq-table-columns.tsx +++ b/lib/rfq-last/table/rfq-table-columns.tsx @@ -5,7 +5,7 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Eye, FileText, Send, Package, Users, ChevronRight } from "lucide-react"; +import { Eye, FileText, Send, Lock, LockOpen } from "lucide-react"; import { Tooltip, TooltipContent, @@ -17,10 +17,14 @@ import { RfqsLastView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; +import { useRouter } from "next/navigation"; + +type NextRouter = ReturnType<typeof useRouter>; interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqsLastView> | null>>; - rfqCategory?: "all" | "general" | "itb" | "rfq"; + rfqCategory?: "general" | "itb" | "rfq"; + router: NextRouter; } // RFQ 상태별 색상 @@ -38,408 +42,914 @@ const getStatusBadgeVariant = (status: string) => { } }; -// 시리즈 배지 -const getSeriesBadge = (series: string | null) => { - if (!series) return null; - - const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; - const variant = series === "SS" ? "default" : series === "II" ? "secondary" : "outline"; - - return <Badge variant={variant} className="text-xs">{label}</Badge>; -}; - export function getRfqColumns({ setRowAction, - rfqCategory = "all" + rfqCategory = "itb", + router }: GetColumnsProps): ColumnDef<RfqsLastView>[] { - - const baseColumns: ColumnDef<RfqsLastView>[] = [ - // ═══════════════════════════════════════════════════════════════ - // 선택 및 기본 정보 - // ═══════════════════════════════════════════════════════════════ - - // Checkbox - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} - onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} - aria-label="select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(v) => row.toggleSelected(!!v)} - aria-label="select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - }, - - // RFQ 코드 - { - accessorKey: "rfqCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />, - cell: ({ row }) => { - const rfqSealed = row.original.rfqSealedYn; - return ( - <div className="flex items-center gap-1"> - <span className="font-mono font-medium">{row.original.rfqCode}</span> - {rfqSealed && ( - <Badge variant="destructive" className="text-xs">봉인</Badge> - )} - </div> - ); - }, - size: 140, - }, - - // 상태 - { - accessorKey: "status", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, - cell: ({ row }) => ( - <Badge variant={getStatusBadgeVariant(row.original.status)}> - {row.original.status} - </Badge> - ), - size: 120, - }, - - // ═══════════════════════════════════════════════════════════════ - // 일반견적 필드 (rfqCategory가 'general' 또는 'all'일 때만) - // ═══════════════════════════════════════════════════════════════ - ...(rfqCategory === "general" || rfqCategory === "all" ? [ - { - id: "rfqType", - accessorKey: "rfqType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 유형" />, - cell: ({ row }) => row.original.rfqType || "-", + + // ═══════════════════════════════════════════════════════════════ + // ITB 컬럼 정의 + // ═══════════════════════════════════════════════════════════════ + if (rfqCategory === "itb") { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // ITB No. + { + accessorKey: "rfqCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="ITB No." />, + cell: ({ row }) => ( + <span className="font-mono font-medium">{row.original.rfqCode}</span> + ), + size: 120, + }, + + // 상세 (액션 버튼) - 수정됨 + { + id: "detail", + header: "상세", + cell: ({ row }) => ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)} + > + <Eye className="h-4 w-4" /> + </Button> + ), + size: 60, + }, + + // 견적상태 + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {row.original.status} + </Badge> + ), + size: 120, + }, + + // 견적 밀봉 + { + accessorKey: "rfqSealedYn", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />, + cell: ({ row }) => { + const isSealed = row.original.rfqSealedYn; + return ( + <div className="flex justify-center"> + {isSealed ? ( + <Lock className="h-4 w-4 text-red-500" /> + ) : ( + <LockOpen className="h-4 w-4 text-gray-400" /> + )} + </div> + ); + }, + size: 80, + }, + + // 구매담당자 + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", size: 100, }, + + // 프로젝트 (프로젝트명) { - id: "rfqTitle", - accessorKey: "rfqTitle", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 제목" />, + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />, cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}> - {row.original.rfqTitle || "-"} + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> </div> ), - size: 200, + size: 220, + }, + + // 선급 + { + accessorKey: "classNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />, + cell: ({ row }) => row.original.classNo || "-", + size: 80, + }, + + // PKG No. (PKG명) + { + accessorKey: "packageName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PKG No. (PKG명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + <span className="max-w-[150px] truncate" title={row.original.packageName || ""}> + {row.original.packageName || "-"} + </span> + </div> + ), + size: 180, + }, + + // 자재그룹 (자재그룹명) + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.itemCode} + </span> + <span className="max-w-[150px] truncate" title={row.original.itemName || ""}> + {row.original.itemName || "-"} + </span> + </div> + ), + size: 180, + }, + + // SM Code + { + accessorKey: "smCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM Code" />, + cell: ({ row }) => row.original.smCode || "-", + size: 80, + }, + + // 견적문서 - 수정됨 + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적문서" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + + // 견적생성일 + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적발송일 + { + accessorKey: "rfqSendDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />, + cell: ({ row }) => { + const date = row.original.rfqSendDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적마감일 + { + accessorKey: "dueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, + cell: ({ row }) => { + const date = row.original.dueDate; + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const isOverdue = now > dueDate; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {format(dueDate, "yyyy-MM-dd")} + </span> + ); + }, + size: 100, + }, + + // 설계담당자 + { + accessorKey: "engPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />, + cell: ({ row }) => row.original.engPicName || "-", + size: 100, }, - ] as ColumnDef<RfqsLastView>[] : []), - // ═══════════════════════════════════════════════════════════════ - // ITB 필드 (rfqCategory가 'itb' 또는 'all'일 때만) - // ═══════════════════════════════════════════════════════════════ - ...(rfqCategory === "itb" || rfqCategory === "all" ? [ + // 프로젝트 Company { - id: "projectCompany", accessorKey: "projectCompany", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 회사" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" />, cell: ({ row }) => row.original.projectCompany || "-", size: 120, }, + + // TBE 결과접수 + { + accessorKey: "tbeResultReceived", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="TBE 결과접수" />, + cell: ({ row }) => { + const received = row.original.quotationReceivedCount || 0; + const total = row.original.vendorCount || 0; + return `${received}/${total}`; + }, + size: 100, + }, + + // 프로젝트 Flag { - id: "projectFlag", accessorKey: "projectFlag", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 플래그" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" />, cell: ({ row }) => row.original.projectFlag || "-", size: 100, }, + + // 프로젝트 Site { - id: "projectSite", accessorKey: "projectSite", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 사이트" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" />, cell: ({ row }) => row.original.projectSite || "-", + size: 100, + }, + + // 최종수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-"; + }, size: 120, }, + + // 최종수정자 { - id: "smCode", - accessorKey: "smCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM 코드" />, - cell: ({ row }) => row.original.smCode || "-", - size: 80, + accessorKey: "updatedByUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => row.original.updatedByUserName || "-", + size: 100, }, - ] as ColumnDef<RfqsLastView>[] : []), - // ═══════════════════════════════════════════════════════════════ - // RFQ(PR) 필드 (rfqCategory가 'rfq' 또는 'all'일 때만) - // ═══════════════════════════════════════════════════════════════ - ...(rfqCategory === "rfq" || rfqCategory === "all" ? [ + // 비고 { - id: "prNumber", - accessorKey: "prNumber", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 번호" />, + accessorKey: "remark", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.original.remark || "-", + size: 150, + }, + ]; + } + + // ═══════════════════════════════════════════════════════════════ + // RFQ 컬럼 정의 + // ═══════════════════════════════════════════════════════════════ + if (rfqCategory === "rfq") { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), cell: ({ row }) => ( - <span className="font-mono text-sm">{row.original.prNumber || "-"}</span> + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // RFQ No. + { + accessorKey: "rfqCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ No." />, + cell: ({ row }) => ( + <span className="font-mono font-medium">{row.original.rfqCode}</span> ), size: 120, }, + + // 상세 - 수정됨 { - id: "prIssueDate", - accessorKey: "prIssueDate", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 발행일" />, + id: "detail", + header: "상세", + cell: ({ row }) => ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)} + > + <Eye className="h-4 w-4" /> + </Button> + ), + size: 60, + }, + + // 견적상태 + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {row.original.status} + </Badge> + ), + size: 120, + }, + + // 견적 밀봉 + { + accessorKey: "rfqSealedYn", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />, cell: ({ row }) => { - const date = row.original.prIssueDate; - return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + const isSealed = row.original.rfqSealedYn; + return ( + <div className="flex justify-center"> + {isSealed ? ( + <Lock className="h-4 w-4 text-red-500" /> + ) : ( + <LockOpen className="h-4 w-4 text-gray-400" /> + )} + </div> + ); }, + size: 80, + }, + + // 구매담당자 + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", size: 100, }, + + // 프로젝트 (프로젝트명) + { + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> + </div> + ), + size: 220, + }, + + // 시리즈 { - id: "series", accessorKey: "series", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />, - cell: ({ row }) => getSeriesBadge(row.original.series), + cell: ({ row }) => { + const series = row.original.series; + if (!series) return "-"; + const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; + return <Badge variant="outline">{label}</Badge>; + }, size: 100, }, - ] as ColumnDef<RfqsLastView>[] : []), - - // ═══════════════════════════════════════════════════════════════ - // 공통 프로젝트 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "프로젝트 정보", - columns: [ - { - accessorKey: "projectCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />, - cell: ({ row }) => ( - <span className="font-mono text-sm">{row.original.projectCode || "-"}</span> - ), - size: 120, - }, - { - accessorKey: "projectName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />, - cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.projectName || ""}> - {row.original.projectName || "-"} - </div> - ), - size: 200, - }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 품목 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "품목 정보", - columns: [ - { - accessorKey: "itemCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />, - cell: ({ row }) => ( - <span className="font-mono text-sm">{row.original.itemCode || "-"}</span> - ), - size: 100, - }, - { - accessorKey: "itemName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재명" />, - cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.itemName || ""}> + + // 선급 + { + accessorKey: "classNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />, + cell: ({ row }) => row.original.classNo || "-", + size: 80, + }, + + // PKG No. (PKG명) + { + accessorKey: "packageName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PKG No. (PKG명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + <span className="max-w-[150px] truncate" title={row.original.packageName || ""}> + {row.original.packageName || "-"} + </span> + </div> + ), + size: 180, + }, + + // 자재그룹 (자재그룹명) + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.itemCode} + </span> + <span className="max-w-[150px] truncate" title={row.original.itemName || ""}> {row.original.itemName || "-"} - </div> - ), - size: 200, - }, - { - accessorKey: "packageNo", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지 번호" />, - cell: ({ row }) => row.original.packageNo || "-", - size: 100, + </span> + </div> + ), + size: 180, + }, + + // 자재코드 + { + accessorKey: "itemCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.itemCode || "-"}</span> + ), + size: 100, + }, + + // 견적문서 - 수정됨 + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적문서" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + + // PR건수 - 수정됨 + { + accessorKey: "prItemsCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR건수" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="font-mono text-sm p-1 h-auto" + onClick={() => setRowAction({ row, type: "items" })} + > + {row.original.prItemsCount || 0} + </Button> + ), + size: 80, + }, + + // 견적생성일 + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; }, - { - accessorKey: "packageName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지명" />, - cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.packageName || ""}> - {row.original.packageName || "-"} - </div> - ), - size: 200, + size: 100, + }, + + // 견적발송일 + { + accessorKey: "rfqSendDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />, + cell: ({ row }) => { + const date = row.original.rfqSendDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 담당자 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "담당자", - columns: [ - { - accessorKey: "picUserName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", - size: 100, + size: 100, + }, + + // 견적마감일 + { + accessorKey: "dueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, + cell: ({ row }) => { + const date = row.original.dueDate; + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const isOverdue = now > dueDate; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {format(dueDate, "yyyy-MM-dd")} + </span> + ); }, - { - accessorKey: "engPicName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="엔지니어링 담당" />, - cell: ({ row }) => row.original.engPicName || "-", - size: 120, + size: 100, + }, + + // 설계담당자 + { + accessorKey: "engPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />, + cell: ({ row }) => row.original.engPicName || "-", + size: 100, + }, + + // TBE 결과접수 + { + accessorKey: "tbeResultReceived", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="TBE 결과접수" />, + cell: ({ row }) => { + const received = row.original.quotationReceivedCount || 0; + const total = row.original.vendorCount || 0; + return `${received}/${total}`; }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 일정 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "일정", - columns: [ - { - accessorKey: "rfqSendDate", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발송일" />, - cell: ({ row }) => { - const date = row.original.rfqSendDate; - return date ? ( - <div className="flex items-center gap-1"> - <Send className="h-3 w-3 text-muted-foreground" /> - <span className="text-sm"> - {format(new Date(date), "MM-dd", { locale: ko })} - </span> - </div> - ) : "-"; - }, - size: 90, + size: 100, + }, + + // 대표 PR No. + { + accessorKey: "prNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="대표 PR No." />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.prNumber || "-"}</span> + ), + size: 120, + }, + + // PR발행일 + { + accessorKey: "prIssueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR발행일" />, + cell: ({ row }) => { + const date = row.original.prIssueDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; }, - { - accessorKey: "dueDate", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="마감일" />, - cell: ({ row }) => { - const date = row.original.dueDate; - if (!date) return "-"; - - const now = new Date(); - const dueDate = new Date(date); - const isOverdue = now > dueDate; - - return ( - <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> - {format(dueDate, "MM-dd", { locale: ko })} - </span> - ); - }, - size: 90, + size: 100, + }, + + // 최종수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-"; }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 벤더 및 견적 현황 - // ═══════════════════════════════════════════════════════════════ - { - header: "견적 현황", - columns: [ - { - accessorKey: "vendorCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업체수" />, - cell: ({ row }) => ( - <div className="flex items-center gap-1"> - <Users className="h-3 w-3 text-muted-foreground" /> - <span className="font-medium">{row.original.vendorCount || 0}</span> + size: 120, + }, + + // 최종수정자 + { + accessorKey: "updatedByUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => row.original.updatedByUserName || "-", + size: 100, + }, + + // 비고 + { + accessorKey: "remark", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.original.remark || "-", + size: 150, + }, + ]; + } + + // ═══════════════════════════════════════════════════════════════ + // 일반견적 컬럼 정의 + // ═══════════════════════════════════════════════════════════════ + if (rfqCategory === "general") { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // 견적 No. + { + accessorKey: "rfqCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 No." />, + cell: ({ row }) => ( + <span className="font-mono font-medium">{row.original.rfqCode}</span> + ), + size: 120, + }, + + // 상세 - 수정됨 + { + id: "detail", + header: "상세", + cell: ({ row }) => ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)} + > + <Eye className="h-4 w-4" /> + </Button> + ), + size: 60, + }, + + // 견적상태 + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {row.original.status} + </Badge> + ), + size: 120, + }, + + // 견적 밀봉 + { + accessorKey: "rfqSealedYn", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />, + cell: ({ row }) => { + const isSealed = row.original.rfqSealedYn; + return ( + <div className="flex justify-center"> + {isSealed ? ( + <Lock className="h-4 w-4 text-red-500" /> + ) : ( + <LockOpen className="h-4 w-4 text-gray-400" /> + )} </div> - ), - size: 80, - }, - { - accessorKey: "shortListedVendorCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Short List" />, - cell: ({ row }) => { - const count = row.original.shortListedVendorCount || 0; - return count > 0 ? ( - <Badge variant="default" className="font-mono"> - {count} - </Badge> - ) : "-"; - }, - size: 90, + ); }, - { - accessorKey: "quotationReceivedCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적접수" />, - cell: ({ row }) => { - const received = row.original.quotationReceivedCount || 0; - const total = row.original.vendorCount || 0; - - return ( - <div className="flex items-center gap-1"> - <FileText className="h-3 w-3 text-muted-foreground" /> - <span className={`text-sm ${received === total && total > 0 ? "text-green-600 font-medium" : ""}`}> - {received}/{total} - </span> - </div> - ); - }, - size: 90, + size: 80, + }, + + // 견적종류 + { + accessorKey: "rfqType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적종류" />, + cell: ({ row }) => row.original.rfqType || "-", + size: 100, + }, + + // 프로젝트 (프로젝트명) + { + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> + </div> + ), + size: 220, + }, + + // 시리즈 + { + accessorKey: "series", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />, + cell: ({ row }) => { + const series = row.original.series; + if (!series) return "-"; + const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; + return <Badge variant="outline">{label}</Badge>; }, - ] - }, - - // PR Items 정보 - { - accessorKey: "prItemsCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR Items" />, - cell: ({ row }) => { - const prItems = row.original.prItemsCount || 0; - const majorItems = row.original.majorItemsCount || 0; - - return ( - <div className="flex flex-col gap-0.5"> - <span className="text-sm font-medium">{prItems}개</span> - {majorItems > 0 && ( - <Badge variant="secondary" className="text-xs"> - 주요 {majorItems} - </Badge> - )} + size: 100, + }, + + // 선급 + { + accessorKey: "classNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />, + cell: ({ row }) => row.original.classNo || "-", + size: 80, + }, + + // 견적명 + { + accessorKey: "rfqTitle", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적명" />, + cell: ({ row }) => ( + <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}> + {row.original.rfqTitle || "-"} </div> - ); - }, - size: 90, - }, - - // 액션 - { - id: "actions", - header: "액션", - enableHiding: false, - size: 80, - minSize: 80, - cell: ({ row }) => { - return ( - <div className="flex items-center gap-1"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-8 w-8" - onClick={() => setRowAction({ row, type: "view" })} - > - <Eye className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>상세보기</TooltipContent> - </Tooltip> - </TooltipProvider> + ), + size: 200, + }, + + // 자재그룹 (자재그룹명) + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.itemCode} + </span> + <span className="max-w-[150px] truncate" title={row.original.itemName || ""}> + {row.original.itemName || "-"} + </span> </div> - ); + ), + size: 180, + }, + + // 자재코드 + { + accessorKey: "itemCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.itemCode || "-"}</span> + ), + size: 100, }, - }, - ]; - return baseColumns; + // 견적 자료 - 수정됨 + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + + // 견적품목수 - 수정됨 + { + accessorKey: "prItemsCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="font-mono text-sm p-1 h-auto" + onClick={() => setRowAction({ row, type: "items" })} + > + {row.original.prItemsCount || 0} + </Button> + ), + size: 90, + }, + + // 견적생성일 + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적발송일 + { + accessorKey: "rfqSendDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />, + cell: ({ row }) => { + const date = row.original.rfqSendDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적마감일 + { + accessorKey: "dueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, + cell: ({ row }) => { + const date = row.original.dueDate; + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const isOverdue = now > dueDate; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {format(dueDate, "yyyy-MM-dd")} + </span> + ); + }, + size: 100, + }, + + // 구매담당자 + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + size: 100, + }, + + // 최종수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-"; + }, + size: 120, + }, + + // 최종수정자 + { + accessorKey: "updatedByUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => row.original.updatedByUserName || "-", + size: 100, + }, + + // 비고 + { + accessorKey: "remark", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.original.remark || "-", + size: 150, + }, + ]; + } + + // 기본값 (fallback) + return []; }
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index 1f60da36..9b696cbd 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -13,15 +13,18 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { RfqsLastView } from "@/db/schema"; +import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; interface RfqTableToolbarActionsProps { table: Table<RfqsLastView>; onRefresh?: () => void; + rfqCategory?: "general" | "itb" | "rfq"; } export function RfqTableToolbarActions({ table, onRefresh, + rfqCategory = "itb", }: RfqTableToolbarActionsProps) { const [isExporting, setIsExporting] = React.useState(false); @@ -150,14 +153,10 @@ export function RfqTableToolbarActions({ </DropdownMenuContent> </DropdownMenu> - <Button - variant="samsung" - size="sm" - className="h-8 px-2 lg:px-3" - > - <Plus className="mr-2 h-4 w-4" /> - RFQ 생성 - </Button> + {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */} + {rfqCategory === "general" && ( + <CreateGeneralRfqDialog onSuccess={onRefresh} /> + ) } </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx index 199695a0..e8db116b 100644 --- a/lib/rfq-last/table/rfq-table.tsx +++ b/lib/rfq-last/table/rfq-table.tsx @@ -24,6 +24,8 @@ import { getRfqColumns } from "./rfq-table-columns"; import { RfqsLastView } from "@/db/schema"; import { getRfqs } from "../service"; import { RfqTableToolbarActions } from "./rfq-table-toolbar-actions"; +import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; +import { RfqItemsDialog } from "./rfq-items-dialog"; interface RfqTableProps { data: Awaited<ReturnType<typeof getRfqs>>; @@ -66,7 +68,6 @@ export function RfqTable({ ); // 초기 데이터 설정 -// const [initialPromiseData] = React.use(promises); const [tableData, setTableData] = React.useState(data); const [isDataLoading, setIsDataLoading] = React.useState(false); @@ -232,9 +233,10 @@ export function RfqTable({ const columns = React.useMemo(() => { return getRfqColumns({ setRowAction, - rfqCategory + rfqCategory , + router }); - }, [rfqCategory, setRowAction]); + }, [rfqCategory, setRowAction, router]); const filterFields: DataTableFilterField<RfqsLastView>[] = [ { id: "rfqCode", label: "RFQ 코드" }, @@ -442,6 +444,7 @@ export function RfqTable({ <RfqTableToolbarActions table={table} + rfqCategory={rfqCategory} onRefresh={refreshData} /> </div> @@ -452,6 +455,23 @@ export function RfqTable({ </div> </div> </div> + + {/* 다이얼로그들 */} + {rowAction?.type === "attachment" && ( + <RfqAttachmentsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} + + {rowAction?.type === "items" && ( + <RfqItemsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} </> ); }
\ No newline at end of file |
