diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-01 10:06:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-01 10:06:20 +0000 |
| commit | 99ca52106b9a7beeb8d31aeb333b4084648007c7 (patch) | |
| tree | 08ec593f8775237aab88c8edd6305bcc7a6ab398 /components/additional-info/join-form.tsx | |
| parent | 369220fb1f7e48bcaaa86653cbc3aea3277b5e7d (diff) | |
(최겸) 서명 미리보기 추가, 기술영업 아이템코드 수정 기능 추가
Diffstat (limited to 'components/additional-info/join-form.tsx')
| -rw-r--r-- | components/additional-info/join-form.tsx | 594 |
1 files changed, 348 insertions, 246 deletions
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index 220547df..8dca4b61 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -126,6 +126,7 @@ const cashFlowRatingScaleMap: Record<string, string[]> = { } const MAX_FILE_SIZE = 3e9 +const IMAGE_FILE_SIZE = 5e6 // 5MB // 첨부파일 타입 정의 const ATTACHMENT_TYPES = [ @@ -183,6 +184,7 @@ export function InfoForm() { // 서명/직인 업로드 관련 상태 const [signatureFiles, setSignatureFiles] = React.useState<File[]>([]) const [hasSignature, setHasSignature] = React.useState(false) + const [signaturePreviewUrl, setSignaturePreviewUrl] = React.useState<string | null>(null) // React Hook Form const form = useForm<UpdateVendorInfoSchema>({ @@ -280,6 +282,21 @@ export function InfoForm() { setExistingCreditFiles(creditFiles) setExistingCashFlowFiles(cashFlowFiles) setExistingSignatureFiles(signatureFiles) // 서명/직인 파일들 + + // 기존 서명/직인 파일이 이미지인 경우 미리보기 URL 생성 + if (signatureFiles.length > 0) { + const signatureFile = signatureFiles[0] + const fileName = signatureFile.fileName.toLowerCase() + const isImage = fileName.includes('.jpg') || fileName.includes('.jpeg') || + fileName.includes('.png') || fileName.includes('.gif') || + fileName.includes('.webp') + + if (isImage) { + // 실제 파일 경로 사용 (DB에 저장된 filePath) + // filePath는 이미 /vendors/{vendorId}/{hashedFileName} 형태 + setSignaturePreviewUrl(signatureFile.filePath) + } + } } // 폼 기본값 설정 (연락처 포함) @@ -352,6 +369,15 @@ export function InfoForm() { fetchVendorData() }, [companyId, form, replaceContacts]) + // 컴포넌트 언마운트 시 미리보기 URL 정리 (blob URL만) + React.useEffect(() => { + return () => { + if (signaturePreviewUrl && signaturePreviewUrl.startsWith('blob:')) { + URL.revokeObjectURL(signaturePreviewUrl) + } + } + }, [signaturePreviewUrl]) + // 보안 다운로드 유틸리티를 사용한 개별 파일 다운로드 const handleDownloadFile = async (file: AttachmentFile) => { try { @@ -529,6 +555,13 @@ export function InfoForm() { // 삭제할 ID 목록에 추가 setFilesToDelete([...filesToDelete, fileId]) + // 서명/직인 파일을 삭제하는 경우 미리보기도 정리 + const isSignatureFile = existingSignatureFiles.some(file => file.id === fileId) + if (isSignatureFile && signaturePreviewUrl) { + // 서버 URL인 경우 revokeObjectURL을 호출하지 않음 (blob URL이 아니므로) + setSignaturePreviewUrl(null) + } + // UI에서 제거 setExistingFiles(existingFiles.filter(file => file.id !== fileId)) setExistingCreditFiles(existingCreditFiles.filter(file => file.id !== fileId)) @@ -543,6 +576,12 @@ export function InfoForm() { + // 이미지 파일 검증 함수 (서명용) + const isImageFile = (file: File): boolean => { + const imageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] + return imageTypes.includes(file.type) + } + // 서명/직인 업로드 핸들러들 (한 개만 허용) const handleSignatureDropAccepted = (acceptedFiles: File[]) => { // 첫 번째 파일만 사용 (한 개만 허용) @@ -560,9 +599,22 @@ export function InfoForm() { }) } + // 기존 미리보기 URL 정리 (blob URL만) + if (signaturePreviewUrl && signaturePreviewUrl.startsWith('blob:')) { + URL.revokeObjectURL(signaturePreviewUrl) + } + setSignatureFiles([newFile]) // 새 파일 설정 setHasSignature(true) + // 이미지 파일인 경우 미리보기 생성 + if (isImageFile(newFile)) { + const previewUrl = URL.createObjectURL(newFile) + setSignaturePreviewUrl(previewUrl) + } else { + setSignaturePreviewUrl(null) + } + if (acceptedFiles.length > 1) { toast({ title: "파일 제한", @@ -585,6 +637,10 @@ export function InfoForm() { const removeSignatureFile = () => { setSignatureFiles([]) setHasSignature(false) + if (signaturePreviewUrl && signaturePreviewUrl.startsWith('blob:')) { + URL.revokeObjectURL(signaturePreviewUrl) + } + setSignaturePreviewUrl(null) } // 파일 타입 라벨 가져오기 @@ -755,112 +811,306 @@ export function InfoForm() { <Separator /> - {/* 서명/직인 등록 섹션 - 독립적으로 사용 가능 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <CheckCircle className="w-5 h-5" /> - 회사 서명/직인 등록 (선택사항) - </CardTitle> - <CardDescription> - 회사의 공식 서명이나 직인을 등록하여 계약서 및 공식 문서에 사용할 수 있습니다. - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - {/* 현재 등록된 서명/직인 파일 표시 (한 개만) */} - {(existingSignatureFiles.length > 0 || signatureFiles.length > 0) && ( - <div className="space-y-2"> - <div className="flex items-center gap-2 p-2 border rounded-lg bg-green-50"> - <CheckCircle className="w-4 h-4 text-green-600" /> - <span className="text-sm text-green-800">서명/직인 등록됨</span> - </div> - - {/* 기존 등록된 서명/직인 (첫 번째만 표시) */} - {existingSignatureFiles.length > 0 && signatureFiles.length === 0 && ( - <div className="p-2 border rounded-lg"> - {(() => { - const file = existingSignatureFiles[0]; - const fileInfo = getFileInfo(file.fileName); - return ( - <div className="flex items-center gap-2"> - <FileListIcon /> - <div className="flex-1"> - <div className="text-xs font-medium">{fileInfo.icon} {file.fileName}</div> - <div className="text-xs text-muted-foreground"> - {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + {/* 서명/직인 등록과 첨부파일 요약을 하나의 행으로 배치 */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + {/* 서명/직인 등록 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <CheckCircle className="w-5 h-5" /> + 회사 서명/직인 등록 (선택사항) + </CardTitle> + <CardDescription> + 회사의 공식 서명이나 직인을 등록하여 계약서 및 공식 문서에 사용할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 현재 등록된 서명/직인 파일 표시 (한 개만) */} + {(existingSignatureFiles.length > 0 || signatureFiles.length > 0) && ( + <div className="space-y-2"> + {/* 기존 등록된 서명/직인 (첫 번째만 표시) */} + {existingSignatureFiles.length > 0 && signatureFiles.length === 0 && ( + <div className="space-y-2"> + <div className="p-2 border rounded-lg"> + {(() => { + const file = existingSignatureFiles[0]; + const fileInfo = getFileInfo(file.fileName); + return ( + <div className="flex items-center gap-2"> + <FileListIcon /> + <div className="flex-1"> + <div className="text-xs font-medium">{fileInfo.icon} {file.fileName}</div> + <div className="text-xs text-muted-foreground"> + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + </div> + </div> + <div className="flex items-center space-x-1"> + <FileListAction + onClick={() => handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />} + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-3 w-3" /> + </FileListAction> + </div> </div> - </div> - <div className="flex items-center space-x-1"> - <FileListAction - onClick={() => handleDownloadFile(file)} - disabled={isDownloading} - > - {isDownloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />} - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-3 w-3" /> - </FileListAction> + ); + })()} + </div> + + {/* 기존 서명 미리보기 (이미지인 경우만) */} + {signaturePreviewUrl && !signatureFiles.length && ( + <div className="p-2 border rounded-lg bg-gray-50"> + <div className="text-xs font-medium mb-2">등록된 서명/직인</div> + <div className="flex justify-center"> + <img + src={signaturePreviewUrl} + alt="등록된 서명/직인" + className="max-w-full max-h-32 object-contain rounded border" + onError={() => { + console.error("기존 서명 이미지 로드 실패") + setSignaturePreviewUrl(null) + }} + // 추가 보안: referrer policy 설정 + referrerPolicy="no-referrer" + /> </div> </div> - ); - })()} - </div> - )} - - {/* 새로 업로드된 서명/직인 */} - {signatureFiles.length > 0 && ( - <div className="p-2 border rounded-lg bg-blue-50"> - {(() => { - const file = signatureFiles[0]; - return ( - <div className="flex items-center gap-2"> - <FileListIcon /> - <div className="flex-1"> - <div className="text-xs font-medium">{file.name}</div> - <div className="text-xs text-muted-foreground"> - 서명/직인 (새 파일) | {prettyBytes(file.size)} + )} + </div> + )} + + {/* 새로 업로드된 서명/직인 */} + {signatureFiles.length > 0 && ( + <div className="space-y-2"> + <div className="p-2 border rounded-lg bg-blue-50"> + {(() => { + const file = signatureFiles[0]; + return ( + <div className="flex items-center gap-2"> + <FileListIcon /> + <div className="flex-1"> + <div className="text-xs font-medium">{file.name}</div> + <div className="text-xs text-muted-foreground"> + 서명/직인 (새 파일) | {prettyBytes(file.size)} + </div> + </div> + <FileListAction onClick={removeSignatureFile}> + <X className="h-3 w-3" /> + </FileListAction> </div> + ); + })()} + </div> + + {/* 서명 미리보기 (이미지인 경우만) */} + {signaturePreviewUrl && ( + <div className="p-2 border rounded-lg bg-gray-50"> + <div className="text-xs font-medium mb-2">미리보기</div> + <div className="flex justify-center"> + <img + src={signaturePreviewUrl} + alt="서명/직인 미리보기" + className="max-w-full max-h-32 object-contain rounded border" + onError={() => { + toast({ + variant: "destructive", + title: "미리보기 오류", + description: "이미지를 불러올 수 없습니다.", + }) + setSignaturePreviewUrl(null) + }} + // 추가 보안: referrer policy 설정 + referrerPolicy="no-referrer" + /> </div> - <FileListAction onClick={removeSignatureFile}> - <X className="h-3 w-3" /> - </FileListAction> </div> - ); - })()} - </div> - )} - </div> - )} - - {/* 서명/직인 업로드 드롭존 */} - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleSignatureDropAccepted} - onDropRejected={handleSignatureDropRejected} - disabled={isSubmitting} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center min-h-[50px]"> - <DropzoneInput /> - <div className="flex items-center gap-2"> - <Upload className="w-4 h-4" /> - <div className="text-sm"> - <DropzoneTitle> - {existingSignatureFiles.length > 0 || signatureFiles.length > 0 - ? "서명/직인 교체" - : "서명/직인 업로드" - } - </DropzoneTitle> - <DropzoneDescription> - 한 개 파일만 업로드 가능 {maxSize ? ` | 최대: ${prettyBytes(maxSize)}` : ""} - </DropzoneDescription> + )} </div> - </div> - </DropzoneZone> + )} + </div> )} - </Dropzone> - </CardContent> - </Card> + + {/* 서명/직인 업로드 드롭존 */} + <Dropzone + maxSize={IMAGE_FILE_SIZE} + onDropAccepted={handleSignatureDropAccepted} + onDropRejected={handleSignatureDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center min-h-[50px]"> + <DropzoneInput /> + <div className="flex items-center gap-2"> + <Upload className="w-4 h-4" /> + <div className="text-sm"> + <DropzoneTitle> + {existingSignatureFiles.length > 0 || signatureFiles.length > 0 + ? "서명/직인 교체" + : "서명/직인 업로드" + } + </DropzoneTitle> + <DropzoneDescription> + 한 개 파일만 업로드 가능 {maxSize ? ` | 최대: ${prettyBytes(maxSize)}` : ""} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + </CardContent> + </Card> + + {/* 첨부파일 요약 카드 - 기존 파일 있는 경우만 표시 */} + {(existingFiles.length > 0 || existingCreditFiles.length > 0 || existingCashFlowFiles.length > 0) && ( + <Card> + <CardHeader> + <CardTitle>첨부파일 요약</CardTitle> + <CardDescription> + 현재 등록된 파일 목록입니다. 전체 다운로드하거나 개별 파일을 다운로드할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="grid gap-4"> + {existingFiles.length > 0 && ( + <div> + <h4 className="font-medium mb-2">첨부파일</h4> + <ScrollArea className="h-48"> + <FileList className="gap-2"> + {existingFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName> + {fileInfo.icon} {file.fileName} + </FileListName> + <FileListDescription> + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + </FileListDescription> + </FileListInfo> + <div className="flex items-center space-x-2"> + <FileListAction + onClick={() => handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ); + })} + </FileList> + </ScrollArea> + </div> + )} + + {existingCreditFiles.length > 0 && ( + <div> + <h4 className="font-medium mb-2">신용평가 첨부파일</h4> + <ScrollArea className="h-24"> + <FileList className="gap-2"> + {existingCreditFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName> + {fileInfo.icon} {file.fileName} + </FileListName> + <FileListDescription> + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + </FileListDescription> + </FileListInfo> + <div className="flex items-center space-x-2"> + <FileListAction + onClick={() => handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ); + })} + </FileList> + </ScrollArea> + </div> + )} + + {existingCashFlowFiles.length > 0 && ( + <div> + <h4 className="font-medium mb-2">현금흐름 첨부파일</h4> + <ScrollArea className="h-24"> + <FileList className="gap-2"> + {existingCashFlowFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName> + {fileInfo.icon} {file.fileName} + </FileListName> + <FileListDescription> + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + </FileListDescription> + </FileListInfo> + <div className="flex items-center space-x-2"> + <FileListAction + onClick={() => handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ); + })} + </FileList> + </ScrollArea> + </div> + )} + </div> + </CardContent> + <CardFooter> + {(existingFiles.length + existingCreditFiles.length + existingCashFlowFiles.length) > 1 && ( + <Button + variant="outline" + onClick={handleDownloadAllFiles} + disabled={isDownloading} + > + {isDownloading ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Download className="mr-2 h-4 w-4" /> + )} + 전체 다운로드 + </Button> + )} + </CardFooter> + </Card> + )} + </div> {/* 정규업체 등록 현황 섹션 */} {registrationData ? ( @@ -930,154 +1180,6 @@ export function InfoForm() { </Card> )} - {/* 첨부파일 요약 카드 - 기존 파일 있는 경우만 표시 */} - {(existingFiles.length > 0 || existingCreditFiles.length > 0 || existingCashFlowFiles.length > 0) && ( - <Card> - <CardHeader> - <CardTitle>첨부파일 요약</CardTitle> - <CardDescription> - 현재 등록된 파일 목록입니다. 전체 다운로드하거나 개별 파일을 다운로드할 수 있습니다. - </CardDescription> - </CardHeader> - <CardContent> - <div className="grid gap-4"> - {existingFiles.length > 0 && ( - <div> - <h4 className="font-medium mb-2">첨부파일</h4> - <ScrollArea className="h-48"> - <FileList className="gap-2"> - {existingFiles.map((file) => { - const fileInfo = getFileInfo(file.fileName); - return ( - <FileListItem key={file.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName> - {fileInfo.icon} {file.fileName} - </FileListName> - <FileListDescription> - {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} - </FileListDescription> - </FileListInfo> - <div className="flex items-center space-x-2"> - <FileListAction - onClick={() => handleDownloadFile(file)} - disabled={isDownloading} - > - {isDownloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-4 w-4" /> - </FileListAction> - </div> - </FileListHeader> - </FileListItem> - ); - })} - </FileList> - </ScrollArea> - </div> - )} - - {existingCreditFiles.length > 0 && ( - <div> - <h4 className="font-medium mb-2">신용평가 첨부파일</h4> - <ScrollArea className="h-24"> - <FileList className="gap-2"> - {existingCreditFiles.map((file) => { - const fileInfo = getFileInfo(file.fileName); - return ( - <FileListItem key={file.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName> - {fileInfo.icon} {file.fileName} - </FileListName> - <FileListDescription> - {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} - </FileListDescription> - </FileListInfo> - <div className="flex items-center space-x-2"> - <FileListAction - onClick={() => handleDownloadFile(file)} - disabled={isDownloading} - > - {isDownloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-4 w-4" /> - </FileListAction> - </div> - </FileListHeader> - </FileListItem> - ); - })} - </FileList> - </ScrollArea> - </div> - )} - - {existingCashFlowFiles.length > 0 && ( - <div> - <h4 className="font-medium mb-2">현금흐름 첨부파일</h4> - <ScrollArea className="h-24"> - <FileList className="gap-2"> - {existingCashFlowFiles.map((file) => { - const fileInfo = getFileInfo(file.fileName); - return ( - <FileListItem key={file.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName> - {fileInfo.icon} {file.fileName} - </FileListName> - <FileListDescription> - {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} - </FileListDescription> - </FileListInfo> - <div className="flex items-center space-x-2"> - <FileListAction - onClick={() => handleDownloadFile(file)} - disabled={isDownloading} - > - {isDownloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-4 w-4" /> - </FileListAction> - </div> - </FileListHeader> - </FileListItem> - ); - })} - </FileList> - </ScrollArea> - </div> - )} - </div> - </CardContent> - <CardFooter> - {(existingFiles.length + existingCreditFiles.length + existingCashFlowFiles.length) > 1 && ( - <Button - variant="outline" - onClick={handleDownloadAllFiles} - disabled={isDownloading} - > - {isDownloading ? ( - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - ) : ( - <Download className="mr-2 h-4 w-4" /> - )} - 전체 다운로드 - </Button> - )} - </CardFooter> - </Card> - )} - <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> |
