diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-02 09:56:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-02 09:56:20 +0000 |
| commit | 230ce796836c25df26c130dbcd616ef97d12b2ec (patch) | |
| tree | 033d982409c89b5f90db0b3772827139c7a8bf80 | |
| parent | dfdfae3018f8499240f48d28ce634f4a5c56e006 (diff) | |
| parent | 9b6269c856516f3987ee249c6131a44728e276e2 (diff) | |
Merge branch 'dev' of https://github.com/DTS-Development/SHI_EVCP into dev
| -rw-r--r-- | components/documents/StageList.tsx | 7 | ||||
| -rw-r--r-- | components/documents/view-document-dialog.tsx | 2 | ||||
| -rw-r--r-- | components/form-data/form-data-report-batch-dialog.tsx | 33 | ||||
| -rw-r--r-- | components/form-data/form-data-report-dialog.tsx | 10 | ||||
| -rw-r--r-- | components/form-data/form-data-report-temp-upload-dialog.tsx | 587 | ||||
| -rw-r--r-- | components/form-data/form-data-report-temp-upload-tab.tsx | 228 | ||||
| -rw-r--r-- | components/form-data/form-data-report-temp-uploaded-list-tab.tsx | 211 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 2 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 6 | ||||
| -rw-r--r-- | components/form-data/temp-download-btn.tsx | 45 | ||||
| -rw-r--r-- | components/form-data/var-list-download-btn.tsx | 110 | ||||
| -rw-r--r-- | lib/vendor-document-list/table/update-doc-sheet.tsx (renamed from lib/vendor-document-list/table/update-doc-sheet.tsx) | 0 | ||||
| -rw-r--r-- | public/icons/temp_sample_icon.svg | 4 | ||||
| -rw-r--r-- | public/icons/var_list_icon.svg | 4 |
14 files changed, 700 insertions, 549 deletions
diff --git a/components/documents/StageList.tsx b/components/documents/StageList.tsx index 81f8a5ca..8d82b741 100644 --- a/components/documents/StageList.tsx +++ b/components/documents/StageList.tsx @@ -61,8 +61,6 @@ interface Version { export default function StageList({ document }: StageListProps) { const [versions, setVersions] = useState<Version[]>([]) -console.log(versions) - const [stageOptions, setStageOptions] = useState<string[]>([]) const [isLoading, setIsLoading] = useState<boolean>(false) @@ -130,7 +128,7 @@ console.log(versions) } } - const selectItems = useMemo(() => { + const selectItems = useMemo(() => { return versions.filter(c => c.selected && c.attachments && c.attachments.length > 0) }, [versions]) @@ -142,9 +140,8 @@ console.log(versions) Document: {document.docNumber} {document.title} </h2> - <div className="flex flex-row gap-2"> - {selectItems.length > 0 && <ViewDocumentDialog versions={versions}/>} + {selectItems.length > 0 && <ViewDocumentDialog versions={selectItems}/>} <AddDocumentDialog diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx index e08bc4e2..162079ad 100644 --- a/components/documents/view-document-dialog.tsx +++ b/components/documents/view-document-dialog.tsx @@ -185,7 +185,7 @@ const DocumentViewer: React.FC<{ if (tabIds.length > 0) { await UI.TabManager.setActiveTab(tabIds[0]); - } + } setFileSetLoading(false); } diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index f4efde64..6a76784c 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -8,10 +8,10 @@ import React, { useEffect, } from "react"; import { useToast } from "@/hooks/use-toast"; -import { toast as toastMessage} from "sonner"; +import { toast as toastMessage } from "sonner"; import prettyBytes from "pretty-bytes"; import { X, Loader2 } from "lucide-react"; -import { saveAs } from 'file-saver'; +import { saveAs } from "file-saver"; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -129,7 +129,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ setIsUploading(true); try { - const origin = await getOrigin() + const origin = await getOrigin(); const targetFiles = selectedFiles[0]; @@ -162,13 +162,13 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ if (reqeustCreateReport.ok) { const blob = await reqeustCreateReport.blob(); - saveAs(blob, `${formCode}.pdf`); + saveAs(blob, `${formCode}.pdf`); - toastMessage.success("Report 다운로드 완료!") + toastMessage.success("Report 다운로드 완료!"); } else { const err = await reqeustCreateReport.json(); console.error("에러:", err); - throw new Error(err.message) + throw new Error(err.message); } } catch (err) { console.error(err); @@ -179,8 +179,8 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ }); } finally { setIsUploading(false); - setSelectedFiles([]) - setOpen(false) + setSelectedFiles([]); + setOpen(false); } }; @@ -188,13 +188,14 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <Dialog open={open} onOpenChange={onClose}> <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> <DialogHeader> - <DialogTitle>Batch Report Download</DialogTitle> + <DialogTitle>Vendor Document Create</DialogTitle> <DialogDescription> - Report Template을 선택하신 후 갑지를 업로드하여 주시기 바랍니다. + Vendor Document Template을 선택하신 후 갑지를 업로드하여 주시기 + 바랍니다. </DialogDescription> </DialogHeader> <div className="h-[60px]"> - <Label>Report Template Select</Label> + <Label>Vendor Document Template Select</Label> <Select value={selectTemp} onValueChange={setSelectTemp}> <SelectTrigger className="w-[100%]"> <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> @@ -213,7 +214,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ </Select> </div> <div> - <Label>Report Cover Page Upload(.docx)</Label> + <Label>Vendor Document Cover Page Upload(.docx)</Label> <Dropzone maxSize={MAX_FILE_SIZE} multiple={false} @@ -265,10 +266,14 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <DialogFooter> <Button - disabled={selectedFiles.length === 0 || selectTemp.length === 0 || isUploading} + disabled={ + selectedFiles.length === 0 || + selectTemp.length === 0 || + isUploading + } onClick={submitData} > - {isUploading && <Loader2 />}다운로드 + {isUploading && <Loader2 />}Vendor Document Create </Button> </DialogFooter> </DialogContent> diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index e28b4345..52262bf5 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -103,20 +103,20 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ <Dialog open={reportData.length > 0} onOpenChange={onClose}> <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> <DialogHeader> - <DialogTitle>Report</DialogTitle> + <DialogTitle>Create Vendor Document</DialogTitle> <DialogDescription> - 사용하시고자 하는 Report Template를 선택하여 주시기 바랍니다. + 사용하시고자 하는 Vendor Document Template를 선택하여 주시기 바랍니다. </DialogDescription> </DialogHeader> <div className="h-[60px]"> - <Label>Report Template Select</Label> + <Label>Vendor Document Template Select</Label> <Select value={selectTemp} onValueChange={setSelectTemp} disabled={instance === null} > <SelectTrigger className="w-[100%]"> - <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> + <SelectValue placeholder="사용하시고자하는 Vendor Document Template을 선택하여 주시기 바랍니다." /> </SelectTrigger> <SelectContent> {tempList.map((c) => { @@ -145,7 +145,7 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ <DialogFooter> <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> - 다운로드 + Create Vendor Document </Button> </DialogFooter> </DialogContent> diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index 69df704e..74cfe7c3 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -1,66 +1,25 @@ "use client"; -import React, { - FC, - Dispatch, - SetStateAction, - useState, - useEffect, -} from "react"; -import { useToast } from "@/hooks/use-toast"; -import { toast as toastMessage } from "sonner"; -import prettyBytes from "pretty-bytes"; -import { X, Loader2, Download, Delete, Trash2 } from "lucide-react"; -import ExcelJS from "exceljs"; -import { saveAs } from "file-saver"; -import { Badge } from "@/components/ui/badge"; +import React, { FC, Dispatch, SetStateAction, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, - DialogFooter, } from "@/components/ui/dialog"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone"; -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, -} from "@/components/ui/file-list"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { - getReportTempList, - uploadReportTemp, - getReportTempFileData, - deleteReportTempFile, -} from "@/lib/forms/services"; -import { VendorDataReportTemps } from "@/db/schema/vendorData"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TempDownloadBtn } from "./temp-download-btn"; +import { VarListDownloadBtn } from "./var-list-download-btn"; +import { FormDataReportTempUploadTab } from "./form-data-report-temp-upload-tab"; +import { FormDataReportTempUploadedListTab } from "./form-data-report-temp-uploaded-list-tab"; import { DataTableColumnJSON } from "./form-data-table-columns"; interface FormDataReportTempUploadDialogProps { @@ -73,9 +32,6 @@ interface FormDataReportTempUploadDialogProps { uploaderType: string; } -// 최대 파일 크기 설정 (3000MB) -const MAX_FILE_SIZE = 3000000; - export const FormDataReportTempUploadDialog: FC< FormDataReportTempUploadDialogProps > = ({ @@ -87,483 +43,74 @@ export const FormDataReportTempUploadDialog: FC< formCode, uploaderType, }) => { - const { toast } = useToast(); - const [selectedFiles, setSelectedFiles] = useState<File[]>([]); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - const [prevReportTemp, setPrevReportTemp] = useState<VendorDataReportTemps[]>( - [] - ); - - useEffect(() => { - updateReportTempList(packageId, formId, setPrevReportTemp); - }, [packageId, formId]); - - // 드롭존 - 파일 드랍 처리 - const handleDropAccepted = (acceptedFiles: File[]) => { - const newFiles = [...selectedFiles, ...acceptedFiles]; - setSelectedFiles(newFiles); - }; - - // 드롭존 - 파일 거부(에러) 처리 - const handleDropRejected = (fileRejections: any[]) => { - fileRejections.forEach((rejection) => { - toast({ - variant: "destructive", - title: "File Error", - description: `${rejection.file.name}: ${ - rejection.errors[0]?.message || "Upload failed" - }`, - }); - }); - }; - - // 파일 제거 핸들러 - const removeFile = (index: number) => { - const updatedFiles = [...selectedFiles]; - updatedFiles.splice(index, 1); - setSelectedFiles(updatedFiles); - }; - - const submitData = async () => { - setIsUploading(true); - setUploadProgress(0); - try { - const totalFiles = selectedFiles.length; - let successCount = 0; - - for (let i = 0; i < totalFiles; i++) { - const file = selectedFiles[i]; - - const formData = new FormData(); - formData.append("file", file); - formData.append("customFileName", file.name); - formData.append("uploaderType", uploaderType); - - await uploadReportTemp(packageId, formId, formData); - - successCount++; - setUploadProgress(Math.round((successCount / totalFiles) * 100)); - } - } catch (err) { - console.error(err); - toast({ - title: "Error", - description: "파일 업로드 중 오류가 발생했습니다.", - variant: "destructive", - }); - } finally { - setIsUploading(false); - setUploadProgress(0); - updateReportTempList(packageId, formId, setPrevReportTemp); - setOpen(false); - } - }; - - const downloadTempFile = async () => { - try { - const { fileName, fileType, base64 } = await getReportTempFileData(); - - saveAs(`data:${fileType};base64,${base64}`, fileName); - - toastMessage.success("Report Sample File 다운로드 완료!"); - } catch (err) { - console.log(err); - toast({ - title: "Error", - description: "Sample File을 찾을 수가 없습니다.", - variant: "destructive", - }); - } - }; - - const downloadReportVarList = async () => { - try { - // Create a new workbook - const workbook = new ExcelJS.Workbook(); - - // 데이터 시트 생성 - const worksheet = workbook.addWorksheet("Data"); - - // 유효성 검사용 숨김 시트 생성 - const validationSheet = workbook.addWorksheet("ValidationData"); - validationSheet.state = "hidden"; // 시트 숨김 처리 - - // 1. 데이터 시트에 헤더 추가 - const headers = ["Table Column Label", "Report Variable"]; - worksheet.addRow(headers); - - // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.alignment = { horizontal: "center" }; - headerRow.eachCell((cell) => { - cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFCCCCCC" }, - }; - }); - - // 2. 데이터 행 추가 - columnsJSON.forEach((row) => { - const { displayLabel, label } = row; - - const labelConvert = label.replaceAll(" ", "_"); - - worksheet.addRow([displayLabel, labelConvert]); - }); - - // 3. 컬럼 너비 자동 조정 - headers.forEach((col, idx) => { - const column = worksheet.getColumn(idx + 1); - - // 최적 너비 계산 - let maxLength = col.length; - columnsJSON.forEach((row) => { - const valueKey = idx === 0 ? "displayLabel" : "label"; - - const value = row[valueKey]; - if (value !== undefined && value !== null) { - const valueLength = String(value).length; - if (valueLength > maxLength) { - maxLength = valueLength; - } - } - }); - - // 너비 설정 (최소 10, 최대 50) - column.width = Math.min(Math.max(maxLength + 2, 10), 50); - }); - - const buffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([buffer]), `${formCode}_report_varible_list.xlsx`); - toastMessage.success("Report Varible List File 다운로드 완료!"); - } catch (err) { - console.log(err); - toast({ - title: "Error", - description: "Variable List 파일을 찾을 수가 없습니다.", - variant: "destructive", - }); - } - }; + const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> <DialogHeader> - <DialogTitle>Report Template Upload</DialogTitle> + <DialogTitle>Vendor Document Template</DialogTitle> <DialogDescription> - 사용하시고자 하는 Report Template(.docx)를 업로드 하여주시기 - 바랍니다. + {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 + 하여주시기 바랍니다. */} </DialogDescription> </DialogHeader> - <div> - <Label>Sample Template Download</Label> - - <FileList className="max-h-[200px] gap-3"> - <FileListItem className="p-3"> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>sample_template_file.docx</FileListName> - </FileListInfo> - <FileListAction onClick={downloadTempFile}> - <Download className="h-4 w-4" /> - <span className="sr-only">Download</span> - </FileListAction> - </FileListHeader> - </FileListItem> - <FileListItem className="p-3"> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>report_variable_list.xlsx</FileListName> - </FileListInfo> - <FileListAction onClick={downloadReportVarList}> - <Download className="h-4 w-4" /> - <span className="sr-only">Download</span> - </FileListAction> - </FileListHeader> - </FileListItem> - </FileList> - </div> - <div> - <Label>Uploaded Template Files</Label> - <UploadedTempFiles - prevReportTemp={prevReportTemp} - updateReportTempList={() => - updateReportTempList(packageId, formId, setPrevReportTemp) - } - /> - </div> - - <div> - <Label>Report Template File Upload(.docx)</Label> - <Dropzone - maxSize={MAX_FILE_SIZE} - multiple={true} - accept={{ accept: [".docx"] }} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - disabled={isUploading} - > - {({ maxSize }) => ( - <> - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> - <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} - </DropzoneDescription> + <Tabs value={tabValue}> + <div className="flex justify-between items-center"> + <TabsList> + <TabsTrigger value="upload" onClick={() => setTabValue("upload")}> + Upload Template File + </TabsTrigger> + <TabsTrigger + value="uploaded" + onClick={() => setTabValue("uploaded")} + > + Uploaded Template File List + </TabsTrigger> + </TabsList> + <div className="flex flex-row gap-2"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div> + <TempDownloadBtn /> </div> - </div> - </DropzoneZone> - <Label className="text-xs text-muted-foreground"> - 여러 파일을 선택할 수 있습니다. - </Label> - </> - )} - </Dropzone> - </div> - - {selectedFiles.length > 0 && ( - <div className="grid gap-2"> - <div className="flex items-center justify-between"> - <h6 className="text-sm font-semibold"> - 선택된 파일 ({selectedFiles.length}) - </h6> - <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + </TooltipTrigger> + <TooltipContent> + <Label>Template Sample File Download</Label> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <div> + <VarListDownloadBtn + columnsJSON={columnsJSON} + formCode={formCode} + /> + </div> + </TooltipTrigger> + <TooltipContent> + <Label>Variable List File Download</Label> + </TooltipContent> + </Tooltip> + </TooltipProvider> </div> - <ScrollArea> - <UploadFileItem - selectedFiles={selectedFiles} - removeFile={removeFile} - isUploading={isUploading} - /> - </ScrollArea> </div> - )} - - {isUploading && <UploadProgressBox uploadProgress={uploadProgress} />} - <DialogFooter> - <Button onClick={() => setOpen(false)}>취소</Button> - <Button disabled={selectedFiles.length === 0} onClick={submitData}> - 업로드 - </Button> - </DialogFooter> + <TabsContent value="upload"> + <FormDataReportTempUploadTab + packageId={packageId} + formId={formId} + uploaderType={uploaderType} + /> + </TabsContent> + <TabsContent value="uploaded"> + <FormDataReportTempUploadedListTab + packageId={packageId} + formId={formId} + /> + </TabsContent> + </Tabs> </DialogContent> </Dialog> ); }; - -interface UploadFileItemProps { - selectedFiles: File[]; - removeFile: (index: number) => void; - isUploading: boolean; -} - -const UploadFileItem: FC<UploadFileItemProps> = ({ - selectedFiles, - removeFile, - isUploading, -}) => { - return ( - <FileList className="max-h-[100px] gap-3"> - {selectedFiles.map((file, index) => ( - <FileListItem key={index} className="p-3"> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListDescription> - {prettyBytes(file.size)} - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={() => removeFile(index)} - disabled={isUploading} - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </FileList> - ); -}; - -const UploadProgressBox: FC<{ uploadProgress: number }> = ({ - uploadProgress, -}) => { - return ( - <div className="flex flex-col gap-1 mt-2"> - <div className="flex items-center gap-2"> - <Loader2 className="h-4 w-4 animate-spin" /> - <span className="text-sm">{uploadProgress}% 업로드 중...</span> - </div> - <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> - <div - className="h-full bg-primary rounded-full transition-all" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - ); -}; - -const generateFileName = ( - packageId: number, - formId: number, - originalFileName: string, - index: number, - totalFiles: number -) => { - // Get the file extension - const extension = originalFileName.split(".").pop() || ""; - - // Base name without extension - const baseName = `${packageId}_${formId}`; - - // For multiple files, add a suffix - if (totalFiles > 1) { - return `${baseName}_${index + 1}.${extension}`; - } - - // For a single file, no suffix needed - return `${baseName}.${extension}`; -}; - -type UpdateReportTempList = ( - packageId: number, - formId: number, - setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>> -) => void; - -const updateReportTempList: UpdateReportTempList = async ( - packageId, - formId, - setPrevReportTemp -) => { - const tempList = await getReportTempList(packageId, formId); - setPrevReportTemp(tempList); -}; - -interface UploadedTempFiles { - prevReportTemp: VendorDataReportTemps[]; - updateReportTempList: () => void; -} - -const UploadedTempFiles: FC<UploadedTempFiles> = ({ - prevReportTemp, - updateReportTempList, -}) => { - const { toast } = useToast(); - - const downloadTempFile = async (fileName: string, filePath: string) => { - try { - const getTempFile = await fetch(filePath); - - if (getTempFile.ok) { - const blob = await getTempFile.blob(); - - saveAs(blob, fileName); - - toastMessage.success("Report 다운로드 완료!"); - } else { - const err = await getTempFile.json(); - console.error("에러:", err); - throw new Error(err.message); - } - - toastMessage.success("Template File 다운로드 완료!"); - } catch (err) { - console.error(err) - toast({ - title: "Error", - description: "Template File 다운로드 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - }; - - const deleteTempFile = async (id: number) => { - try { - const { result, error } = await deleteReportTempFile(id); - - if (result) { - updateReportTempList(); - toastMessage.success("Template File 삭제 완료!"); - } else { - throw new Error(error); - } - } catch (err) { - toast({ - title: "Error", - description: "Template File 삭제 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - }; - - return ( - <ScrollArea> - <FileList className="max-h-[100px] gap-3"> - {prevReportTemp.map((c) => { - const { fileName, filePath, id } = c; - - return ( - <AlertDialog key={id}> - <FileListItem className="p-3"> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileName}</FileListName> - </FileListInfo> - <FileListAction - onClick={() => { - downloadTempFile(fileName, filePath); - }} - > - <Download className="h-4 w-4" /> - <span className="sr-only">Download</span> - </FileListAction> - <AlertDialogTrigger asChild> - <FileListAction> - <Trash2 className="h-4 w-4" /> - <span className="sr-only">Delete</span> - </FileListAction> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle> - Report Templete File({fileName})을 삭제하시겠습니까? - </AlertDialogTitle> - <AlertDialogDescription /> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>취소</AlertDialogCancel> - <AlertDialogAction - onClick={() => { - deleteTempFile(id); - }} - > - 삭제 - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </FileListHeader> - </FileListItem> - </AlertDialog> - ); - })} - </FileList> - </ScrollArea> - ); -}; diff --git a/components/form-data/form-data-report-temp-upload-tab.tsx b/components/form-data/form-data-report-temp-upload-tab.tsx new file mode 100644 index 00000000..5e6179a8 --- /dev/null +++ b/components/form-data/form-data-report-temp-upload-tab.tsx @@ -0,0 +1,228 @@ +"use client"; + +import React, { FC, useState } from "react"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { uploadReportTemp } from "@/lib/forms/services"; + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3000000; + +interface FormDataReportTempUploadTabProps { + packageId: number; + formId: number; + uploaderType: string; +} + +export const FormDataReportTempUploadTab: FC< + FormDataReportTempUploadTabProps +> = ({ packageId, formId, uploaderType }) => { + const { toast } = useToast(); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || "Upload failed" + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + const submitData = async () => { + setIsUploading(true); + setUploadProgress(0); + try { + const totalFiles = selectedFiles.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = selectedFiles[i]; + + const formData = new FormData(); + formData.append("file", file); + formData.append("customFileName", file.name); + formData.append("uploaderType", uploaderType); + + await uploadReportTemp(packageId, formId, formData); + + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + toastMessage.success("Template File 업로드 완료!"); + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + setSelectedFiles([]) + } + }; + + return ( + <div className='flex flex-col gap-4'> + <div> + <Label>Vendor Document Template File Upload(.docx)</Label> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </Label> + </> + )} + </Dropzone> + </div> + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + </div> + <ScrollArea> + <UploadFileItem + selectedFiles={selectedFiles} + removeFile={removeFile} + isUploading={isUploading} + /> + </ScrollArea> + </div> + )} + + {isUploading && <UploadProgressBox uploadProgress={uploadProgress} />} + <DialogFooter> + <Button disabled={selectedFiles.length === 0} onClick={submitData}> + 업로드 + </Button> + </DialogFooter> + </div> + ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; +} + +const UploadFileItem: FC<UploadFileItemProps> = ({ + selectedFiles, + removeFile, + isUploading, +}) => { + return ( + <FileList className="max-h-[150px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + ); +}; + +const UploadProgressBox: FC<{ uploadProgress: number }> = ({ + uploadProgress, +}) => { + return ( + <div className="flex flex-col gap-1 mt-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + ); +}; diff --git a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx new file mode 100644 index 00000000..7379a312 --- /dev/null +++ b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx @@ -0,0 +1,211 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import { Download, Trash2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + FileList, + FileListAction, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { getReportTempList, deleteReportTempFile } from "@/lib/forms/services"; +import { VendorDataReportTemps } from "@/db/schema/vendorData"; + +interface FormDataReportTempUploadedListTabProps { + packageId: number; + formId: number; +} + +export const FormDataReportTempUploadedListTab: FC< + FormDataReportTempUploadedListTabProps +> = ({ packageId, formId }) => { + const [prevReportTemp, setPrevReportTemp] = useState<VendorDataReportTemps[]>( + [] + ); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const getTempFiles = async () => { + await updateReportTempList(packageId, formId, setPrevReportTemp); + setIsLoading(false); + }; + + getTempFiles(); + }, [packageId, formId]); + + return ( + <div> + <Label>Uploaded Template File List</Label> + <UploadedTempFiles + prevReportTemp={prevReportTemp} + updateReportTempList={() => + updateReportTempList(packageId, formId, setPrevReportTemp) + } + isLoading={isLoading} + /> + </div> + ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>> +) => Promise<void>; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setPrevReportTemp +) => { + const tempList = await getReportTempList(packageId, formId); + setPrevReportTemp(tempList); +}; + +interface UploadedTempFiles { + prevReportTemp: VendorDataReportTemps[]; + updateReportTempList: () => void; + isLoading: boolean; +} + +const UploadedTempFiles: FC<UploadedTempFiles> = ({ + prevReportTemp, + updateReportTempList, + isLoading, +}) => { + const { toast } = useToast(); + + const downloadTempFile = async (fileName: string, filePath: string) => { + try { + const getTempFile = await fetch(filePath); + + if (getTempFile.ok) { + const blob = await getTempFile.blob(); + + saveAs(blob, fileName); + + toastMessage.success("Report 다운로드 완료!"); + } else { + const err = await getTempFile.json(); + console.error("에러:", err); + throw new Error(err.message); + } + + toastMessage.success("Template File 다운로드 완료!"); + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "Template File 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + const deleteTempFile = async (id: number) => { + try { + const { result, error } = await deleteReportTempFile(id); + + if (result) { + updateReportTempList(); + toastMessage.success("Template File 삭제 완료!"); + } else { + throw new Error(error); + } + } catch (err) { + toast({ + title: "Error", + description: "Template File 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + if (isLoading) { + return ( + <div className="min-h-[157px]"> + <Label>로딩 중...</Label> + </div> + ); + } + + return ( + <ScrollArea className="min-h-[157px] max-h-[337px] overflow-auto"> + <FileList className="gap-3"> + {prevReportTemp.map((c) => { + const { fileName, filePath, id } = c; + + return ( + <AlertDialog key={id}> + <FileListItem className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileName}</FileListName> + </FileListInfo> + <FileListAction + onClick={() => { + downloadTempFile(fileName, filePath); + }} + > + <Download className="h-4 w-4" /> + <span className="sr-only">Download</span> + </FileListAction> + <AlertDialogTrigger asChild> + <FileListAction> + <Trash2 className="h-4 w-4" /> + <span className="sr-only">Delete</span> + </FileListAction> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + Report Templete File({fileName})을 삭제하시겠습니까? + </AlertDialogTitle> + <AlertDialogDescription /> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + deleteTempFile(id); + }} + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </FileListHeader> + </FileListItem> + </AlertDialog> + ); + })} + </FileList> + </ScrollArea> + ); +}; diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index b23b2e70..a136b5d3 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -133,7 +133,7 @@ export function getColumns<TData extends object>({ setReportData([original]); }} > - Create Report + Create Vendor Document </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index b696f45b..4caee44f 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -531,10 +531,10 @@ export default function DynamicTable({ <Popover> <PopoverTrigger asChild> <Button variant="default" size="sm"> - Report + Vendor Document </Button> </PopoverTrigger> - <PopoverContent className="flex flex-row gap-2"> + <PopoverContent className="flex flex-row gap-2 w-auto"> <Button variant="outline" size="sm" @@ -547,7 +547,7 @@ export default function DynamicTable({ size="sm" onClick={() => setBatchDownDialog(true)} > - Report Download + Vendor Document Create </Button> </PopoverContent> </Popover> diff --git a/components/form-data/temp-download-btn.tsx b/components/form-data/temp-download-btn.tsx new file mode 100644 index 00000000..a5f963e4 --- /dev/null +++ b/components/form-data/temp-download-btn.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import { saveAs } from "file-saver"; +import { Button } from "@/components/ui/button"; +import { getReportTempFileData } from "@/lib/forms/services"; + +export const TempDownloadBtn = () => { + const { toast } = useToast(); + + const downloadTempFile = async () => { + try { + const { fileName, fileType, base64 } = await getReportTempFileData(); + + saveAs(`data:${fileType};base64,${base64}`, fileName); + + toastMessage.success("Report Sample File 다운로드 완료!"); + } catch (err) { + console.log(err); + toast({ + title: "Error", + description: "Sample File을 찾을 수가 없습니다.", + variant: "destructive", + }); + } + }; + return ( + <Button + variant="ghost" + className="relative p-2" + aria-label="Template Sample Download" + onClick={downloadTempFile} + > + <Image + src="/icons/temp_sample_icon.svg" + alt="Template Sample Download Icon" + width={20} + height={20} + /> + </Button> + ); +}; diff --git a/components/form-data/var-list-download-btn.tsx b/components/form-data/var-list-download-btn.tsx new file mode 100644 index 00000000..19bb26f9 --- /dev/null +++ b/components/form-data/var-list-download-btn.tsx @@ -0,0 +1,110 @@ +"use client"; + +import React, { FC } from "react"; +import Image from "next/image"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { Button } from "@/components/ui/button"; +import { DataTableColumnJSON } from "./form-data-table-columns"; + +interface VarListDownloadBtnProps { + columnsJSON: DataTableColumnJSON[]; + formCode: string; +} + +export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ + columnsJSON, + formCode, +}) => { + const { toast } = useToast(); + + const downloadReportVarList = async () => { + try { + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 데이터 시트에 헤더 추가 + const headers = ["Table Column Label", "Report Variable"]; + worksheet.addRow(headers); + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 2. 데이터 행 추가 + columnsJSON.forEach((row) => { + const { displayLabel, label } = row; + + const labelConvert = label.replaceAll(" ", "_"); + + worksheet.addRow([displayLabel, labelConvert]); + }); + + // 3. 컬럼 너비 자동 조정 + headers.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.length; + columnsJSON.forEach((row) => { + const valueKey = idx === 0 ? "displayLabel" : "label"; + + const value = row[valueKey]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([buffer]), `${formCode}_report_varible_list.xlsx`); + toastMessage.success("Report Varible List File 다운로드 완료!"); + } catch (err) { + console.log(err); + toast({ + title: "Error", + description: "Variable List 파일을 찾을 수가 없습니다.", + variant: "destructive", + }); + } + }; + + return ( + <Button + variant="ghost" + className="relative p-2" + aria-label="Variable List Download" + onClick={downloadReportVarList} + > + <Image + src="/icons/var_list_icon.svg" + alt="Template Sample Download Icon" + width={20} + height={20} + /> + </Button> + ); +}; diff --git a/lib/vendor-document-list/table/update-doc-sheet.tsx b/lib/vendor-document-list/table/update-doc-sheet.tsx index 3e0ca225..3e0ca225 100644 --- a/lib/vendor-document-list/table/update-doc-sheet.tsx +++ b/lib/vendor-document-list/table/update-doc-sheet.tsx diff --git a/public/icons/temp_sample_icon.svg b/public/icons/temp_sample_icon.svg new file mode 100644 index 00000000..4bf9aa6f --- /dev/null +++ b/public/icons/temp_sample_icon.svg @@ -0,0 +1,4 @@ +<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2 7.84496C2 5.33105 4.18739 3.5 6.62349 3.5H32.214C34.6501 3.5 36.8375 5.33105 36.8375 7.84496V24.2639C36.284 23.6142 35.6306 23.0581 34.9021 22.6214V16.8693H14.9028V33.5805H24.3253C24.7467 34.3354 25.2834 35.0124 25.9105 35.5859H6.62349C4.18739 35.5859 2 33.7548 2 31.2409V7.84496ZM3.93542 26.0942V31.2409C3.93542 32.4188 5.02153 33.5805 6.62349 33.5805H12.9674V26.0942H3.93542ZM12.9674 24.0888H3.93542V16.8693H12.9674V24.0888ZM3.93542 7.84496C3.93542 6.6671 5.02153 5.50537 6.62349 5.50537H32.214C33.816 5.50537 34.9021 6.6671 34.9021 7.84496V14.8639H3.93542V7.84496Z" fill="#020817"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M31.1936 36.4999C34.9526 36.4999 37.9999 33.4526 37.9999 29.6936C37.9999 25.9345 34.9526 22.8872 31.1936 22.8872C27.4345 22.8872 24.3872 25.9345 24.3872 29.6936C24.3872 33.4526 27.4345 36.4999 31.1936 36.4999ZM31.5577 34.632C31.3656 34.8538 31.0217 34.8538 30.8296 34.632L28.0307 31.4005C27.7603 31.0883 27.9819 30.6027 28.3947 30.6027H29.8689L29.8689 24.9645C29.8689 24.6983 30.0845 24.5889 30.3506 24.5888L32.0367 24.589C32.3028 24.589 32.5185 24.6983 32.5185 24.9645V30.6027H33.9926C34.4054 30.6027 34.627 31.0883 34.3567 31.4005L31.5577 34.632Z" fill="#020817"/> +</svg> diff --git a/public/icons/var_list_icon.svg b/public/icons/var_list_icon.svg new file mode 100644 index 00000000..792b67be --- /dev/null +++ b/public/icons/var_list_icon.svg @@ -0,0 +1,4 @@ +<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2 7.5C2 5.29086 3.79086 3.5 6 3.5H32.8374C35.0465 3.5 36.8374 5.29086 36.8374 7.5V24.0237C35.391 22.5854 33.3975 21.6965 31.1965 21.6965C27.2791 21.6965 24.0194 24.5121 23.3306 28.23H12.6416C11.9512 28.23 11.3916 28.7896 11.3916 29.48C11.3916 30.1703 11.9512 30.73 12.6416 30.73H23.2626C23.5093 32.6426 24.4315 34.3436 25.7821 35.5859H6C3.79086 35.5859 2 33.7951 2 31.5859V7.5ZM8.8916 9.41016C8.8916 8.7198 8.33196 8.16016 7.6416 8.16016C6.95125 8.16016 6.3916 8.7198 6.3916 9.41016V9.42266C6.3916 10.113 6.95125 10.6727 7.6416 10.6727C8.33196 10.6727 8.8916 10.113 8.8916 9.42266V9.41016ZM12.6416 8.16016C11.9512 8.16016 11.3916 8.7198 11.3916 9.41016C11.3916 10.1005 11.9512 10.6602 12.6416 10.6602H31.196C31.8864 10.6602 32.446 10.1005 32.446 9.41016C32.446 8.7198 31.8864 8.16016 31.196 8.16016H12.6416ZM7.6416 18.1948C8.33196 18.1948 8.8916 18.7545 8.8916 19.4448V19.4573C8.8916 20.1477 8.33196 20.7073 7.6416 20.7073C6.95125 20.7073 6.3916 20.1477 6.3916 19.4573V19.4448C6.3916 18.7545 6.95125 18.1948 7.6416 18.1948ZM11.3916 19.4448C11.3916 18.7545 11.9512 18.1948 12.6416 18.1948H31.196C31.8864 18.1948 32.446 18.7545 32.446 19.4448C32.446 20.1352 31.8864 20.6948 31.196 20.6948H12.6416C11.9512 20.6948 11.3916 20.1352 11.3916 19.4448ZM8.8916 29.48C8.8916 28.7896 8.33196 28.23 7.6416 28.23C6.95125 28.23 6.3916 28.7896 6.3916 29.48V29.4925C6.3916 30.1828 6.95125 30.7425 7.6416 30.7425C8.33196 30.7425 8.8916 30.1828 8.8916 29.4925V29.48Z" fill="#020817"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M31.1965 36.5028C34.9555 36.5028 38.0028 33.4555 38.0028 29.6965C38.0028 25.9374 34.9555 22.8901 31.1965 22.8901C27.4374 22.8901 24.3901 25.9374 24.3901 29.6965C24.3901 33.4555 27.4374 36.5028 31.1965 36.5028ZM31.5606 34.6349C31.3686 34.8567 31.0246 34.8567 30.8325 34.6349L28.0336 31.4034C27.7632 31.0912 27.9848 30.6057 28.3976 30.6057H29.8718L29.8718 24.9674C29.8718 24.7012 30.0875 24.5918 30.3535 24.5918L32.0396 24.5919C32.3057 24.5919 32.5214 24.7012 32.5214 24.9674V30.6057H33.9956C34.4084 30.6057 34.63 31.0912 34.3596 31.4034L31.5606 34.6349Z" fill="#020817"/> +</svg> |
