diff options
Diffstat (limited to 'lib/rfq-last/table')
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-assign-pic-dialog.tsx | 311 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 402 |
3 files changed, 434 insertions, 283 deletions
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 14564686..7abf06a3 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -60,7 +60,7 @@ import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } fro // 아이템 스키마 const itemSchema = z.object({ - itemCode: z.string().min(1, "자재코드를 입력해주세요"), + itemCode: z.string().optional(), itemName: z.string().min(1, "자재명을 입력해주세요"), quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), uom: z.string().min(1, "단위를 입력해주세요"), @@ -645,7 +645,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp render={({ field }) => ( <FormItem> <FormLabel className="text-xs"> - 자재코드 <span className="text-red-500">*</span> + 자재코드 </FormLabel> <FormControl> <Input diff --git a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx new file mode 100644 index 00000000..89dda979 --- /dev/null +++ b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx @@ -0,0 +1,311 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, User, Users } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { getPUsersForFilter } from "@/lib/rfq-last/service"; +import { assignPicToRfqs } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface User { + id: number; + name: string; + userCode?: string; + email?: string; +} + +interface RfqAssignPicDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfqIds: number[]; + selectedRfqCodes: string[]; + onSuccess?: () => void; +} + +export function RfqAssignPicDialog({ + open, + onOpenChange, + selectedRfqIds, + selectedRfqCodes, + onSuccess, +}: RfqAssignPicDialogProps) { + const [users, setUsers] = React.useState<User[]>([]); + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); + const [isAssigning, setIsAssigning] = React.useState(false); + const [selectedUser, setSelectedUser] = React.useState<User | null>(null); + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false); + const [userSearchTerm, setUserSearchTerm] = React.useState(""); + + // ITB만 필터링 (rfqCode가 "I"로 시작하는 것) + const itbCodes = React.useMemo(() => { + return selectedRfqCodes.filter(code => code.startsWith("I")); + }, [selectedRfqCodes]); + + const itbIds = React.useMemo(() => { + return selectedRfqIds.filter((id, index) => selectedRfqCodes[index]?.startsWith("I")); + }, [selectedRfqIds, selectedRfqCodes]); + + // 유저 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true); + try { + const userList = await getPUsersForFilter(); + setUsers(userList); + } catch (error) { + console.log("사용자 목록 로드 오류:", error); + toast.error("사용자 목록을 불러오는데 실패했습니다"); + } finally { + setIsLoadingUsers(false); + } + }; + + if (open) { + loadUsers(); + // 다이얼로그 열릴 때 초기화 + setSelectedUser(null); + setUserSearchTerm(""); + } + }, [open]); + + // 유저 검색 + const filteredUsers = React.useMemo(() => { + if (!userSearchTerm) return users; + + const lowerSearchTerm = userSearchTerm.toLowerCase(); + return users.filter( + (user) => + user.name.toLowerCase().includes(lowerSearchTerm) || + user.userCode?.toLowerCase().includes(lowerSearchTerm) + ); + }, [users, userSearchTerm]); + + const handleSelectUser = (user: User) => { + setSelectedUser(user); + setUserPopoverOpen(false); + }; + + const handleAssign = async () => { + if (!selectedUser) { + toast.error("담당자를 선택해주세요"); + return; + } + + if (itbIds.length === 0) { + toast.error("선택한 항목 중 ITB가 없습니다"); + return; + } + + setIsAssigning(true); + try { + const result = await assignPicToRfqs({ + rfqIds: itbIds, + picUserId: selectedUser.id, + }); + + if (result.success) { + toast.success(result.message); + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(result.message); + } + } catch (error) { + console.error("담당자 지정 오류:", error); + toast.error("담당자 지정 중 오류가 발생했습니다"); + } finally { + setIsAssigning(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="h-5 w-5" /> + 담당자 지정 + </DialogTitle> + <DialogDescription> + 선택한 ITB에 구매 담당자를 지정합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 선택된 ITB 정보 */} + <div className="space-y-2"> + <label className="text-sm font-medium">선택된 ITB</label> + <div className="p-3 bg-muted rounded-md"> + <div className="flex items-center gap-2 mb-2"> + <Badge variant="secondary">{itbCodes.length}건</Badge> + {itbCodes.length !== selectedRfqCodes.length && ( + <span className="text-xs text-muted-foreground"> + (전체 {selectedRfqCodes.length}건 중) + </span> + )} + </div> + <div className="max-h-[100px] overflow-y-auto"> + <div className="flex flex-wrap gap-1"> + {itbCodes.slice(0, 10).map((code, index) => ( + <Badge key={index} variant="outline" className="text-xs"> + {code} + </Badge> + ))} + {itbCodes.length > 10 && ( + <Badge variant="outline" className="text-xs"> + +{itbCodes.length - 10}개 + </Badge> + )} + </div> + </div> + </div> + {itbCodes.length === 0 && ( + <Alert className="border-orange-200 bg-orange-50"> + <AlertDescription className="text-orange-800"> + 선택한 항목 중 ITB (I로 시작하는 코드)가 없습니다. + </AlertDescription> + </Alert> + )} + </div> + + {/* 담당자 선택 */} + <div className="space-y-2"> + <label className="text-sm font-medium">구매 담당자</label> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="w-full justify-between h-10" + disabled={isLoadingUsers || itbCodes.length === 0} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {selectedUser ? ( + <> + {selectedUser.name} + {selectedUser.userCode && ( + <span className="text-muted-foreground"> + ({selectedUser.userCode}) + </span> + )} + </> + ) : ( + <span className="text-muted-foreground"> + 구매 담당자를 선택하세요 + </span> + )} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 코드로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandGroup> + {filteredUsers.map((user) => ( + <CommandItem + key={user.id} + onSelect={() => handleSelectUser(user)} + className="flex items-center justify-between" + > + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {user.name} + {user.userCode && ( + <span className="text-muted-foreground text-sm"> + ({user.userCode}) + </span> + )} + </span> + <Check + className={cn( + "h-4 w-4", + selectedUser?.id === user.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + {selectedUser && ( + <p className="text-xs text-muted-foreground"> + 선택한 담당자: {selectedUser.name} + {selectedUser.userCode && ` (${selectedUser.userCode})`} + </p> + )} + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isAssigning} + > + 취소 + </Button> + <Button + type="submit" + onClick={handleAssign} + disabled={!selectedUser || itbCodes.length === 0 || isAssigning} + > + {isAssigning ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 지정 중... + </> + ) : ( + "담당자 지정" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ 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 91b2798f..d933fa95 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -1,308 +1,148 @@ "use client"; import * as React from "react"; -import { type Table } from "@tanstack/react-table"; -import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react"; - +import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { toast } from "sonner"; +import { Users, RefreshCw, FileDown, Plus } from "lucide-react"; import { RfqsLastView } from "@/db/schema"; -import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; -import { sealMultipleRfqs, unsealMultipleRfqs } from "../service"; - -interface RfqTableToolbarActionsProps { - table: Table<RfqsLastView>; - onRefresh?: () => void; +import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface RfqTableToolbarActionsProps<TData> { + table: Table<TData>; rfqCategory?: "general" | "itb" | "rfq"; + onRefresh?: () => void; } -export function RfqTableToolbarActions({ - table, - onRefresh, +export function RfqTableToolbarActions<TData>({ + table, rfqCategory = "itb", -}: RfqTableToolbarActionsProps) { - const [isExporting, setIsExporting] = React.useState(false); - const [isSealing, setIsSealing] = React.useState(false); - const [sealDialogOpen, setSealDialogOpen] = React.useState(false); - const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal"); - + onRefresh +}: RfqTableToolbarActionsProps<TData>) { + const [showAssignDialog, setShowAssignDialog] = React.useState(false); + + // 선택된 행 가져오기 const selectedRows = table.getFilteredSelectedRowModel().rows; - const selectedRfqIds = selectedRows.map(row => row.original.id); - // 선택된 항목들의 밀봉 상태 확인 - const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length; - const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length; - - const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => { - setSealAction(action); - setSealDialogOpen(true); - }, []); - - const confirmSealAction = React.useCallback(async () => { - setIsSealing(true); - try { - const result = sealAction === "seal" - ? await sealMultipleRfqs(selectedRfqIds) - : await unsealMultipleRfqs(selectedRfqIds); - - if (result.success) { - toast.success(result.message); - table.toggleAllRowsSelected(false); // 선택 해제 - onRefresh?.(); // 데이터 새로고침 - } else { - toast.error(result.error); - } - } catch (error) { - toast.error("작업 중 오류가 발생했습니다."); - } finally { - setIsSealing(false); - setSealDialogOpen(false); - } - }, [sealAction, selectedRfqIds, table, onRefresh]); - - const handleExportCSV = React.useCallback(async () => { - setIsExporting(true); - try { - const data = table.getFilteredRowModel().rows.map((row) => { - const original = row.original; - return { - "RFQ 코드": original.rfqCode || "", - "상태": original.status || "", - "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉", - "프로젝트 코드": original.projectCode || "", - "프로젝트명": original.projectName || "", - "자재코드": original.itemCode || "", - "자재명": original.itemName || "", - "패키지 번호": original.packageNo || "", - "패키지명": original.packageName || "", - "구매담당자": original.picUserName || original.picName || "", - "엔지니어링 담당": original.engPicName || "", - "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "", - "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "", - "업체수": original.vendorCount || 0, - "Short List": original.shortListedVendorCount || 0, - "견적접수": original.quotationReceivedCount || 0, - "PR Items": original.prItemsCount || 0, - "주요 Items": original.majorItemsCount || 0, - "시리즈": original.series || "", - "견적 유형": original.rfqType || "", - "견적 제목": original.rfqTitle || "", - "프로젝트 회사": original.projectCompany || "", - "프로젝트 사이트": original.projectSite || "", - "SM 코드": original.smCode || "", - "PR 번호": original.prNumber || "", - "PR 발행일": original.prIssueDate ? new Date(original.prIssueDate).toLocaleDateString("ko-KR") : "", - "생성자": original.createdByUserName || "", - "생성일": original.createdAt ? new Date(original.createdAt).toLocaleDateString("ko-KR") : "", - "수정자": original.updatedByUserName || "", - "수정일": original.updatedAt ? new Date(original.updatedAt).toLocaleDateString("ko-KR") : "", - }; - }); - - const fileName = `RFQ_목록_${new Date().toISOString().split("T")[0]}.csv`; - exportTableToCSV({ data, filename: fileName }); - } catch (error) { - console.error("Export failed:", error); - } finally { - setIsExporting(false); - } - }, [table]); - - const handleExportSelected = React.useCallback(async () => { - setIsExporting(true); - try { - const selectedRows = table.getFilteredSelectedRowModel().rows; - if (selectedRows.length === 0) { - alert("선택된 항목이 없습니다."); - return; - } - - const data = selectedRows.map((row) => { - const original = row.original; - return { - "RFQ 코드": original.rfqCode || "", - "상태": original.status || "", - "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉", - "프로젝트 코드": original.projectCode || "", - "프로젝트명": original.projectName || "", - "자재코드": original.itemCode || "", - "자재명": original.itemName || "", - "패키지 번호": original.packageNo || "", - "패키지명": original.packageName || "", - "구매담당자": original.picUserName || original.picName || "", - "엔지니어링 담당": original.engPicName || "", - "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "", - "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "", - "업체수": original.vendorCount || 0, - "Short List": original.shortListedVendorCount || 0, - "견적접수": original.quotationReceivedCount || 0, - }; - }); - - const fileName = `RFQ_선택항목_${new Date().toISOString().split("T")[0]}.csv`; - exportTableToCSV({ data, filename: fileName }); - } catch (error) { - console.error("Export failed:", error); - } finally { - setIsExporting(false); - } - }, [table]); + // 선택된 RFQ의 ID와 코드 추출 + const selectedRfqData = React.useMemo(() => { + const rows = selectedRows.map(row => row.original as RfqsLastView); + return { + ids: rows.map(row => row.id), + codes: rows.map(row => row.rfqCode || ""), + // "I"로 시작하는 ITB만 필터링 + itbCount: rows.filter(row => row.rfqCode?.startsWith("I")).length, + totalCount: rows.length + }; + }, [selectedRows]); + + // 담당자 지정 가능 여부 체크 ("I"로 시작하는 항목이 있는지) + const canAssignPic = selectedRfqData.itbCount > 0; + + const handleAssignSuccess = () => { + // 테이블 선택 초기화 + table.toggleAllPageRowsSelected(false); + // 데이터 새로고침 + onRefresh?.(); + }; return ( <> <div className="flex items-center gap-2"> - {onRefresh && ( - <Button - variant="outline" - size="sm" - onClick={onRefresh} - className="h-8 px-2 lg:px-3" - > - <RefreshCw className="mr-2 h-4 w-4" /> - 새로고침 - </Button> + {/* 담당자 지정 버튼 - 선택된 항목 중 ITB가 있을 때만 표시 */} + {selectedRfqData.totalCount > 0 && canAssignPic && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="default" + size="sm" + onClick={() => setShowAssignDialog(true)} + className="flex items-center gap-2" + > + <Users className="h-4 w-4" /> + 담당자 지정 + <Badge variant="secondary" className="ml-1"> + {selectedRfqData.itbCount}건 + </Badge> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 ITB에 구매 담당자를 지정합니다</p> + {selectedRfqData.itbCount !== selectedRfqData.totalCount && ( + <p className="text-xs text-muted-foreground mt-1"> + 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다 + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> )} - {/* 견적 밀봉/해제 버튼 */} - {selectedRfqIds.length > 0 && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="h-8 px-2 lg:px-3" - disabled={isSealing} - > - <Lock className="mr-2 h-4 w-4" /> - 견적 밀봉 - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => handleSealAction("seal")} - disabled={unsealedCount === 0} - > - <Lock className="mr-2 h-4 w-4" /> - 선택 항목 밀봉 ({unsealedCount}개) - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => handleSealAction("unseal")} - disabled={sealedCount === 0} - > - <LockOpen className="mr-2 h-4 w-4" /> - 선택 항목 밀봉 해제 ({sealedCount}개) - </DropdownMenuItem> - <DropdownMenuSeparator /> - <div className="px-2 py-1.5 text-xs text-muted-foreground"> - 전체 {selectedRfqIds.length}개 선택됨 - </div> - </DropdownMenuContent> - </DropdownMenu> + {/* 선택된 항목 표시 */} + {selectedRfqData.totalCount > 0 && ( + <div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md"> + <span className="text-sm text-muted-foreground"> + 선택된 항목: + </span> + <Badge variant="secondary"> + {selectedRfqData.totalCount}건 + </Badge> + {selectedRfqData.totalCount !== selectedRfqData.itbCount && ( + <Badge variant="outline" className="text-xs"> + ITB {selectedRfqData.itbCount}건 + </Badge> + )} + </div> )} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="h-8 px-2 lg:px-3" - disabled={isExporting} - > - <Download className="mr-2 h-4 w-4" /> - {isExporting ? "내보내는 중..." : "내보내기"} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={handleExportCSV}> - 전체 데이터 내보내기 - </DropdownMenuItem> - <DropdownMenuItem - onClick={handleExportSelected} - disabled={table.getFilteredSelectedRowModel().rows.length === 0} - > - 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개) - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + {/* 기존 버튼들 */} + <Button + variant="outline" + size="sm" + onClick={onRefresh} + className="flex items-center gap-2" + > + <RefreshCw className="h-4 w-4" /> + 새로고침 + </Button> - {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */} {rfqCategory === "general" && ( - <CreateGeneralRfqDialog onSuccess={onRefresh} /> + <Button + variant="outline" + size="sm" + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 일반견적 생성 + </Button> )} + + <Button + variant="outline" + size="sm" + className="flex items-center gap-2" + disabled={selectedRfqData.totalCount === 0} + > + <FileDown className="h-4 w-4" /> + 엑셀 다운로드 + </Button> </div> - {/* 밀봉 확인 다이얼로그 */} - <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle> - {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"} - </AlertDialogTitle> - <AlertDialogDescription> - {sealAction === "seal" - ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.` - : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`} - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel> - <AlertDialogAction - onClick={confirmSealAction} - disabled={isSealing} - className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""} - > - {isSealing ? "처리 중..." : "확인"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + {/* 담당자 지정 다이얼로그 */} + <RfqAssignPicDialog + open={showAssignDialog} + onOpenChange={setShowAssignDialog} + selectedRfqIds={selectedRfqData.ids} + selectedRfqCodes={selectedRfqData.codes} + onSuccess={handleAssignSuccess} + /> </> ); -} - -// CSV 내보내기 유틸리티 함수 -function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) { - if (!data || data.length === 0) { - console.warn("No data to export"); - return; - } - - const headers = Object.keys(data[0]); - const csvContent = [ - headers.join(","), - ...data.map(row => - headers.map(header => { - const value = row[header]; - // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기 - if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; - }).join(",") - ) - ].join("\n"); - - const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = filename; - link.click(); - URL.revokeObjectURL(link.href); }
\ No newline at end of file |
