diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/additional-info/join-form.tsx | 311 | ||||
| -rw-r--r-- | components/additional-info/tech-vendor-info-form.tsx | 188 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 4 | ||||
| -rw-r--r-- | components/settings/account-form.tsx | 283 | ||||
| -rw-r--r-- | components/ship-vendor-document/add-attachment-dialog.tsx | 40 | ||||
| -rw-r--r-- | components/ship-vendor-document/new-revision-dialog.tsx | 62 | ||||
| -rw-r--r-- | components/ship-vendor-document/user-vendor-document-table-container.tsx | 76 | ||||
| -rw-r--r-- | components/signup/tech-vendor-join-form.tsx | 6 | ||||
| -rw-r--r-- | components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx | 4 | ||||
| -rw-r--r-- | components/tech-vendors/tech-vendor-container.tsx | 2 |
10 files changed, 611 insertions, 365 deletions
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index b6cb0d9c..da2ddac7 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -80,6 +80,17 @@ import { CardTitle, } from "@/components/ui/card" import { InformationButton } from "@/components/information/information-button" + +// 보안 파일 다운로드 유틸리티 import +import { + downloadFile, + quickDownload, + smartFileAction, + getFileInfo, + formatFileSize, + getSecurityInfo +} from "@/lib/file-download" + i18nIsoCountries.registerLocale(enLocale) i18nIsoCountries.registerLocale(koLocale) @@ -111,7 +122,6 @@ const cashFlowRatingScaleMap: Record<string, string[]> = { SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], } - const MAX_FILE_SIZE = 3e9 // 파일 타입 정의 @@ -277,6 +287,7 @@ export function InfoForm() { fetchVendorData() }, [companyId, form, replaceContacts]) + // 보안 다운로드 유틸리티를 사용한 개별 파일 다운로드 const handleDownloadFile = async (file: AttachmentFile) => { try { setIsDownloading(true); @@ -285,74 +296,94 @@ export function InfoForm() { const fileId = typeof file === 'object' ? file.id : file; const fileName = typeof file === 'object' ? file.fileName : `file-${fileId}`; - // 다운로드 링크 생성 (URL 인코딩 적용) + // API 엔드포인트 URL 구성 const downloadUrl = `/api/vendors/attachments/download?id=${fileId}&vendorId=${Number(companyId)}`; - // a 태그를 사용한 다운로드 - const downloadLink = document.createElement('a'); - downloadLink.href = downloadUrl; - downloadLink.download = fileName; - downloadLink.target = '_blank'; // 추가: 새 탭에서 열도록 설정 (일부 브라우저에서 더 안정적) - document.body.appendChild(downloadLink); - downloadLink.click(); - - // 정리 (메모리 누수 방지) - setTimeout(() => { - document.body.removeChild(downloadLink); - }, 100); - - toast({ - title: "다운로드 시작", - description: "파일 다운로드가 시작되었습니다.", + // 보안 다운로드 유틸리티 사용 + const result = await downloadFile(downloadUrl, fileName, { + action: 'download', + showToast: false, // 우리가 직접 토스트 관리 + onSuccess: (fileName, fileSize) => { + const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : ''; + toast({ + title: "다운로드 완료", + description: `파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`, + }); + }, + onError: (error) => { + console.error("Download error:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: error || "파일 다운로드 중 오류가 발생했습니다.", + }); + } }); + + if (!result.success && result.error) { + // 오류 처리는 onError 콜백에서 이미 처리됨 + console.error("Download failed:", result.error); + } + } catch (error) { console.error("Error downloading file:", error); toast({ variant: "destructive", title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", + description: "파일 다운로드 중 예상치 못한 오류가 발생했습니다.", }); } finally { setIsDownloading(false); } }; - // 전체 파일 다운로드 함수 -const handleDownloadAllFiles = async () => { - try { - setIsDownloading(true); - - // 다운로드 URL 생성 - const downloadUrl = `/api/vendors/attachments/download-all?vendorId=${Number(companyId)}`; - - // a 태그를 사용한 다운로드 - const downloadLink = document.createElement('a'); - downloadLink.href = downloadUrl; - downloadLink.download = `vendor-${companyId}-files.zip`; - downloadLink.target = '_blank'; - document.body.appendChild(downloadLink); - downloadLink.click(); - - // 정리 - setTimeout(() => { - document.body.removeChild(downloadLink); - }, 100); - - toast({ - title: "다운로드 시작", - description: "전체 파일 다운로드가 시작되었습니다.", - }); - } catch (error) { - console.error("Error downloading files:", error); - toast({ - variant: "destructive", - title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", - }); - } finally { - setIsDownloading(false); - } -}; + // 보안 다운로드 유틸리티를 사용한 전체 파일 다운로드 + const handleDownloadAllFiles = async () => { + try { + setIsDownloading(true); + + // 전체 파일 다운로드 API 엔드포인트 + const downloadUrl = `/api/vendors/attachments/download-all?vendorId=${Number(companyId)}`; + const fileName = `vendor-${companyId}-files.zip`; + + // 보안 다운로드 유틸리티 사용 + const result = await downloadFile(downloadUrl, fileName, { + action: 'download', + showToast: false, // 우리가 직접 토스트 관리 + onSuccess: (fileName, fileSize) => { + const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : ''; + toast({ + title: "전체 다운로드 완료", + description: `전체 파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`, + }); + }, + onError: (error) => { + console.error("Download all error:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: error || "전체 파일 다운로드 중 오류가 발생했습니다.", + }); + } + }); + + if (!result.success && result.error) { + // 오류 처리는 onError 콜백에서 이미 처리됨 + console.error("Download all failed:", result.error); + } + + } catch (error) { + console.error("Error downloading files:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: "전체 파일 다운로드 중 예상치 못한 오류가 발생했습니다.", + }); + } finally { + setIsDownloading(false); + } + }; + // Dropzone handlers const handleDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] @@ -529,6 +560,9 @@ const handleDownloadAllFiles = async () => { ) } + // 보안 정보 가져오기 (선택적으로 사용자에게 표시) + const securityInfo = getSecurityInfo(); + // Render return ( <div className="container py-6"> @@ -563,6 +597,11 @@ const handleDownloadAllFiles = async () => { </Badge> </div> )} + + {/* 보안 정보 표시 (선택적) */} + <div className="text-xs text-muted-foreground"> + <p>📁 허용 파일 크기: {securityInfo.maxFileSizeFormatted} | 남은 다운로드: {securityInfo.remainingDownloads}/분</p> + </div> </div> <Separator /> @@ -583,27 +622,35 @@ const handleDownloadAllFiles = async () => { <h4 className="font-medium mb-2">일반 첨부파일</h4> <ScrollArea className="h-32"> <FileList className="gap-2"> - {existingFiles.map((file) => ( - <FileListItem key={file.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - <FileListDescription> - {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} - </FileListDescription> - </FileListInfo> - <div className="flex items-center space-x-2"> - <FileListAction onClick={() => handleDownloadFile(file)}> - <Download className="h-4 w-4" /> - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-4 w-4" /> - </FileListAction> - </div> - </FileListHeader> - </FileListItem> - ))} + {existingFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName> + {fileInfo.icon} {file.fileName} + </FileListName> + <FileListDescription> + {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> @@ -614,27 +661,35 @@ const handleDownloadAllFiles = async () => { <h4 className="font-medium mb-2">신용평가 첨부파일</h4> <ScrollArea className="h-32"> <FileList className="gap-2"> - {existingCreditFiles.map((file) => ( - <FileListItem key={file.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - <FileListDescription> - {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} - </FileListDescription> - </FileListInfo> - <div className="flex items-center space-x-2"> - <FileListAction onClick={() => handleDownloadFile(file)}> - <Download className="h-4 w-4" /> - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-4 w-4" /> - </FileListAction> - </div> - </FileListHeader> - </FileListItem> - ))} + {existingCreditFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName> + {fileInfo.icon} {file.fileName} + </FileListName> + <FileListDescription> + {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> @@ -645,27 +700,35 @@ const handleDownloadAllFiles = async () => { <h4 className="font-medium mb-2">현금흐름 첨부파일</h4> <ScrollArea className="h-32"> <FileList className="gap-2"> - {existingCashFlowFiles.map((file) => ( - <FileListItem key={file.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - <FileListDescription> - {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} - </FileListDescription> - </FileListInfo> - <div className="flex items-center space-x-2"> - <FileListAction onClick={() => handleDownloadFile(file)}> - <Download className="h-4 w-4" /> - </FileListAction> - <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> - <X className="h-4 w-4" /> - </FileListAction> - </div> - </FileListHeader> - </FileListItem> - ))} + {existingCashFlowFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName> + {fileInfo.icon} {file.fileName} + </FileListName> + <FileListDescription> + {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> @@ -674,8 +737,16 @@ const handleDownloadAllFiles = async () => { </CardContent> <CardFooter> {(existingFiles.length + existingCreditFiles.length + existingCashFlowFiles.length) > 1 && ( - <Button variant="outline" onClick={handleDownloadAllFiles}> - <Download className="mr-2 h-4 w-4" /> + <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> )} diff --git a/components/additional-info/tech-vendor-info-form.tsx b/components/additional-info/tech-vendor-info-form.tsx index 55d01d21..02358a45 100644 --- a/components/additional-info/tech-vendor-info-form.tsx +++ b/components/additional-info/tech-vendor-info-form.tsx @@ -31,6 +31,17 @@ import { CardTitle,
} from "@/components/ui/card"
import { InformationButton } from "@/components/information/information-button"
+
+// 보안 파일 다운로드 유틸리티 import
+import {
+ downloadFile,
+ quickDownload,
+ smartFileAction,
+ getFileInfo,
+ formatFileSize,
+ getSecurityInfo
+} from "@/lib/file-download"
+
// 타입 정의
interface TechVendorContact {
id: number
@@ -46,6 +57,7 @@ interface TechVendorAttachment { fileName: string
filePath: string
attachmentType: string
+ fileSize?: number
createdAt: Date
updatedAt: Date
}
@@ -133,69 +145,96 @@ export function TechVendorInfoForm() { fetchTechVendorData()
}, [techCompanyId, form])
+ // 보안 다운로드 유틸리티를 사용한 개별 파일 다운로드
const handleDownloadFile = async (file: TechVendorAttachment) => {
try {
setIsDownloading(true)
+ // API 엔드포인트 URL 구성
const downloadUrl = `/api/tech-vendors/attachments/download?id=${file.id}&vendorId=${Number(techCompanyId)}`
- const downloadLink = document.createElement('a')
- downloadLink.href = downloadUrl
- downloadLink.download = file.fileName
- downloadLink.target = '_blank'
- document.body.appendChild(downloadLink)
- downloadLink.click()
-
- setTimeout(() => {
- document.body.removeChild(downloadLink)
- }, 100)
+ // 보안 다운로드 유틸리티 사용
+ const result = await downloadFile(downloadUrl, file.fileName, {
+ action: 'download',
+ showToast: false, // 우리가 직접 토스트 관리
+ onSuccess: (fileName, fileSize) => {
+ const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : '';
+ toast({
+ title: "다운로드 완료",
+ description: `파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`,
+ });
+ },
+ onError: (error) => {
+ console.error("Download error:", error);
+ toast({
+ variant: "destructive",
+ title: "다운로드 오류",
+ description: error || "파일 다운로드 중 오류가 발생했습니다.",
+ });
+ }
+ });
+
+ if (!result.success && result.error) {
+ // 오류 처리는 onError 콜백에서 이미 처리됨
+ console.error("Download failed:", result.error);
+ }
- toast({
- title: "다운로드 시작",
- description: "파일 다운로드가 시작되었습니다.",
- })
} catch (error) {
- console.error("Error downloading file:", error)
+ console.error("Error downloading file:", error);
toast({
variant: "destructive",
title: "다운로드 오류",
- description: "파일 다운로드 중 오류가 발생했습니다.",
- })
+ description: "파일 다운로드 중 예상치 못한 오류가 발생했습니다.",
+ });
} finally {
- setIsDownloading(false)
+ setIsDownloading(false);
}
}
+ // 보안 다운로드 유틸리티를 사용한 전체 파일 다운로드
const handleDownloadAllFiles = async () => {
try {
setIsDownloading(true)
+ // 전체 파일 다운로드 API 엔드포인트
const downloadUrl = `/api/tech-vendors/attachments/download-all?vendorId=${Number(techCompanyId)}`
+ const fileName = `tech-vendor-${techCompanyId}-files.zip`
- const downloadLink = document.createElement('a')
- downloadLink.href = downloadUrl
- downloadLink.download = `tech-vendor-${techCompanyId}-files.zip`
- downloadLink.target = '_blank'
- document.body.appendChild(downloadLink)
- downloadLink.click()
-
- setTimeout(() => {
- document.body.removeChild(downloadLink)
- }, 100)
+ // 보안 다운로드 유틸리티 사용
+ const result = await downloadFile(downloadUrl, fileName, {
+ action: 'download',
+ showToast: false, // 우리가 직접 토스트 관리
+ onSuccess: (fileName, fileSize) => {
+ const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : '';
+ toast({
+ title: "전체 다운로드 완료",
+ description: `전체 파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`,
+ });
+ },
+ onError: (error) => {
+ console.error("Download all error:", error);
+ toast({
+ variant: "destructive",
+ title: "다운로드 오류",
+ description: error || "전체 파일 다운로드 중 오류가 발생했습니다.",
+ });
+ }
+ });
+
+ if (!result.success && result.error) {
+ // 오류 처리는 onError 콜백에서 이미 처리됨
+ console.error("Download all failed:", result.error);
+ }
- toast({
- title: "다운로드 시작",
- description: "전체 파일 다운로드가 시작되었습니다.",
- })
} catch (error) {
- console.error("Error downloading files:", error)
+ console.error("Error downloading files:", error);
toast({
variant: "destructive",
title: "다운로드 오류",
- description: "파일 다운로드 중 오류가 발생했습니다.",
- })
+ description: "전체 파일 다운로드 중 예상치 못한 오류가 발생했습니다.",
+ });
} finally {
- setIsDownloading(false)
+ setIsDownloading(false);
}
}
@@ -247,6 +286,9 @@ export function TechVendorInfoForm() { )
}
+ // 보안 정보 가져오기
+ const securityInfo = getSecurityInfo();
+
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
@@ -256,6 +298,11 @@ export function TechVendorInfoForm() { <InformationButton pagePath="partners/info" />
</div>
<p className="text-gray-600">기술영업 벤더 정보를 확인하고 업데이트할 수 있습니다.</p>
+
+ {/* 보안 정보 표시 */}
+ <div className="text-xs text-muted-foreground mt-1">
+ <p>📁 허용 파일 크기: {securityInfo.maxFileSizeFormatted} | 남은 다운로드: {securityInfo.remainingDownloads}/분</p>
+ </div>
</div>
{attachments.length > 0 && (
<Button
@@ -263,7 +310,11 @@ export function TechVendorInfoForm() { onClick={handleDownloadAllFiles}
disabled={isDownloading}
>
- <Download className="h-4 w-4 mr-2" />
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4 mr-2" />
+ )}
{isDownloading ? "다운로드 중..." : "전체 파일 다운로드"}
</Button>
)}
@@ -395,7 +446,6 @@ export function TechVendorInfoForm() { </CardContent>
</Card>
-
{/* 연락처 정보 */}
{contacts.length > 0 && (
<Card>
@@ -455,7 +505,7 @@ export function TechVendorInfoForm() { </Card>
)}
- {/* 첨부파일 정보 */}
+ {/* 첨부파일 정보 - 보안 강화된 버전 */}
{attachments.length > 0 && (
<Card>
<CardHeader>
@@ -466,29 +516,45 @@ export function TechVendorInfoForm() { </CardHeader>
<CardContent>
<div className="space-y-2">
- {attachments.map((file) => (
- <div
- key={file.id}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
- >
- <div className="flex-1">
- <h4 className="font-medium">{file.fileName}</h4>
- <div className="flex items-center space-x-2 text-sm text-gray-600">
- <span>{file.attachmentType}</span>
- <span>•</span>
- <span>{new Date(file.createdAt).toLocaleDateString()}</span>
+ {attachments.map((file) => {
+ const fileInfo = getFileInfo(file.fileName);
+ return (
+ <div
+ key={file.id}
+ className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
+ >
+ <div className="flex-1">
+ <div className="flex items-center space-x-2">
+ <span className="text-lg">{fileInfo.icon}</span>
+ <h4 className="font-medium">{file.fileName}</h4>
+ </div>
+ <div className="flex items-center space-x-2 text-sm text-gray-600 mt-1">
+ <span>{file.attachmentType}</span>
+ <span>•</span>
+ <span>{new Date(file.createdAt).toLocaleDateString()}</span>
+ {file.fileSize && (
+ <>
+ <span>•</span>
+ <span>{formatFileSize(file.fileSize)}</span>
+ </>
+ )}
+ </div>
</div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadFile(file)}
+ disabled={isDownloading}
+ >
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4" />
+ )}
+ </Button>
</div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleDownloadFile(file)}
- disabled={isDownloading}
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- ))}
+ );
+ })}
</div>
</CardContent>
</Card>
@@ -513,4 +579,4 @@ export function TechVendorInfoForm() { </Form>
</div>
)
-}
\ No newline at end of file +}
\ No newline at end of file diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index a686da7a..0b04c0c3 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -52,6 +52,8 @@ export function Header() { const { data: session } = useSession(); const { activeMenus, isLoading } = useActiveMenus(); + console.log(session) + const userName = session?.user?.name || ""; const domain = session?.user?.domain || ""; const initials = userName @@ -242,7 +244,7 @@ export function Header() { <DropdownMenu> <DropdownMenuTrigger asChild> <Avatar className="cursor-pointer h-8 w-8"> - <AvatarImage src={`/profiles/${session?.user?.image}`||"/user-avatar.jpg"} alt="User Avatar" /> + <AvatarImage src={`${session?.user?.image}`||"/user-avatar.jpg"} alt="User Avatar" /> <AvatarFallback> {initials || "?"} </AvatarFallback> diff --git a/components/settings/account-form.tsx b/components/settings/account-form.tsx index 97cad9e5..e2435a2b 100644 --- a/components/settings/account-form.tsx +++ b/components/settings/account-form.tsx @@ -24,8 +24,6 @@ import { useSession } from "next-auth/react"; import { updateUserProfileImage } from "@/lib/users/service" - - const accountFormSchema = z.object({ name: z .string() @@ -36,56 +34,95 @@ const accountFormSchema = z.object({ message: "Name must not be longer than 30 characters.", }), email: z.string().email(), - company: z - .string() - .min(2, { - message: "Name must be at least 2 characters.", - }) - .max(30, { - message: "Name must not be longer than 30 characters.", - }), - imageFile: z.any().optional(), - }) type AccountFormValues = z.infer<typeof accountFormSchema> - - export function AccountForm() { - const { data: session } = useSession(); const userId = session?.user.id || "" - + const [currentImageUrl, setCurrentImageUrl] = React.useState<string | null>(null) const [previewUrl, setPreviewUrl] = React.useState<string | null>(null) + const [imageError, setImageError] = React.useState<boolean>(false) const form = useForm<AccountFormValues>({ resolver: zodResolver(accountFormSchema), defaultValues: { name: "", - company: "", email: "", imageFile: null, }, }) - // Fetch data in useEffect - React.useEffect(() => { - console.log("Form state changed: ", form.getValues()); + // 안전한 이미지 URL 검증 함수 + const isValidImageUrl = (url: string): boolean => { + try { + // 1. 빈 문자열 체크 + if (!url || typeof url !== 'string') return false + + // 2. 위험한 프로토콜 차단 + const dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:', 'ftp:'] + const lowerUrl = url.toLowerCase() + if (dangerousProtocols.some(protocol => lowerUrl.startsWith(protocol))) { + return false + } + + // 3. 상대 경로 공격 방지 + if (url.includes('../') || url.includes('..\\')) { + return false + } + + // 4. 허용된 경로만 통과 (프로젝트 구조에 맞게 조정) + const allowedPaths = ['/profiles/', '/uploads/', '/images/'] + const hasAllowedPath = allowedPaths.some(path => url.startsWith(path)) + + // 5. 또는 허용된 도메인만 통과 (필요한 경우) + // const allowedDomains = ['yourdomain.com', 'cdn.yourdomain.com'] + // if (url.startsWith('http')) { + // const urlObj = new URL(url) + // return allowedDomains.includes(urlObj.hostname) + // } + + return hasAllowedPath + + } catch (error) { + console.error('URL validation error:', error) + return false + } + } + + // 안전한 이미지 URL 생성 함수 + const getSafeImageUrl = (imagePath: string | null): string | null => { + if (!imagePath) return null + + // 이미 전체 경로인 경우 + if (imagePath.startsWith('/profiles/') || imagePath.startsWith('/uploads/')) { + return isValidImageUrl(imagePath) ? imagePath : null + } + + // 파일명만 있는 경우 안전한 경로로 조합 + const safePath = `/profiles/${encodeURIComponent(imagePath)}` + return isValidImageUrl(safePath) ? safePath : null + } + React.useEffect(() => { async function fetchUser() { try { const data = await findUserById(Number(userId)) if (data) { - // Also reset the form's default values form.reset({ name: data.user_name || "", - company: data.company_name || "", email: data.user_email || "", - imageFile: data.user_image, // no file to begin with + imageFile: null, }) + + // 안전한 이미지 URL 설정 + const safeImageUrl = getSafeImageUrl(data.user_image) + setCurrentImageUrl(safeImageUrl) + setImageError(false) + setPreviewUrl(null) } } catch (error) { console.error("Failed to fetch user data:", error) @@ -97,12 +134,9 @@ export function AccountForm() { } }, [userId, form]) - async function onSubmit(data: AccountFormValues) { - // RHF가 추적한 dirtyFields를 가져옵니다. const { dirtyFields } = form.formState - // 변경된 필드가 전혀 없다면 => 업데이트 스킵 if (Object.keys(dirtyFields).length === 0) { toast({ title: "No changes", @@ -111,18 +145,26 @@ export function AccountForm() { return } - // 바뀐 파일만 업로드 let imageFile: File | null = null if (dirtyFields.imageFile && data.imageFile && data.imageFile.length > 0) { - // 새로 업로드한 파일 - imageFile = data.imageFile[0] + const file = data.imageFile[0] + + // 클라이언트 측 파일 검증 + if (!isValidImageFile(file)) { + toast({ + title: "Invalid file", + description: "Please select a valid image file (PNG, JPG, JPEG, WebP, max 5MB)", + variant: "destructive", + }) + return + } + + imageFile = file } - // FormData 생성 const formData = new FormData() formData.append("userId", userId) formData.append("name", data.name) - formData.append("company", data.company) formData.append("email", data.email) if (imageFile) { @@ -130,7 +172,6 @@ export function AccountForm() { } try { - // 서버 액션(또는 API) 호출 await updateUserProfileImage(formData) toast({ @@ -147,6 +188,72 @@ export function AccountForm() { } } + // 파일 유효성 검증 함수 + const isValidImageFile = (file: File): boolean => { + // 1. 파일 크기 검증 (5MB 제한) + const maxSize = 5 * 1024 * 1024 // 5MB + if (file.size > maxSize) { + return false + } + + // 2. MIME 타입 검증 + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + return false + } + + // 3. 파일 확장자 검증 (추가 보안) + const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'] + const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + if (!allowedExtensions.includes(fileExtension)) { + return false + } + + return true + } + + // 이미지 로드 에러 처리 + const handleImageError = () => { + setImageError(true) + setCurrentImageUrl(null) + } + + // 안전한 이미지 표시 함수 + const getDisplayImage = () => { + if (previewUrl) { + return ( + <img + src={previewUrl} + alt="Preview" + width={200} + className="rounded-lg object-cover" + onError={() => setPreviewUrl(null)} + /> + ) + } + + if (currentImageUrl && !imageError) { + return ( + <img + src={currentImageUrl} + alt="Current profile" + width={200} + className="rounded-lg object-cover" + onError={handleImageError} + // 추가 보안: referrer policy 설정 + referrerPolicy="no-referrer" + /> + ) + } + + return ( + <div className="w-[200px] h-[200px] bg-gray-200 rounded-lg flex items-center justify-center"> + <span className="text-gray-500"> + {imageError ? "Image load failed" : "No image"} + </span> + </div> + ) + } return ( <Form {...form}> @@ -188,68 +295,68 @@ export function AccountForm() { <FormField control={form.control} - name="company" - render={({ field }) => ( - <FormItem> - <FormLabel>Company</FormLabel> - <FormControl> - <Input - placeholder="Your Company name" - {...field} - readOnly - className="cursor-not-allowed bg-slate-50" - /> - </FormControl> - <FormDescription> - This is the name that will be displayed on your profile and in - emails. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - - - {/* 이미지 업로드 */} - <FormField - control={form.control} name="imageFile" render={({ field }) => ( <FormItem> <FormLabel>Profile Image</FormLabel> <FormControl> - <div className="space-y-2"> - <Input - type="file" - accept="image/*" - onChange={(e) => { - field.onChange(e.target.files) - if (e.target.files && e.target.files.length > 0) { - // 로컬 미리보기 URL - const file = e.target.files[0] - const url = URL.createObjectURL(file) - setPreviewUrl(url) - } - }} - /> - - {previewUrl ? ( - <img src={previewUrl} alt="Local Preview" width={200}/> - ) : ( - typeof field.value === "string" && - field.value && ( - <img - src={`/profiles/${field.value}`} - alt="Server Image" - width={200} - /> - ) - )} - </div> + <div className="space-y-4"> + <Input + type="file" + accept="image/jpeg,image/jpg,image/png,image/webp" + onChange={(e) => { + const files = e.target.files + field.onChange(files) + + if (files && files.length > 0) { + const file = files[0] + + // 파일 유효성 검증 + if (!isValidImageFile(file)) { + toast({ + title: "Invalid file", + description: "Please select a valid image file (PNG, JPG, JPEG, WebP, max 5MB)", + variant: "destructive", + }) + // 파일 입력 초기화 + e.target.value = '' + field.onChange(null) + return + } + + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + + const url = URL.createObjectURL(file) + setPreviewUrl(url) + setImageError(false) + } else { + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + setPreviewUrl(null) + } + }} + /> + + <div className="border-2 border-dashed border-gray-300 rounded-lg p-4"> + {getDisplayImage()} + </div> + + {previewUrl && ( + <p className="text-sm text-blue-600">새 이미지 미리보기</p> + )} + {!previewUrl && currentImageUrl && !imageError && ( + <p className="text-sm text-gray-600">현재 프로필 이미지</p> + )} + {imageError && ( + <p className="text-sm text-red-600">이미지를 불러올 수 없습니다</p> + )} + </div> </FormControl> <FormDescription> - Upload your profile image. + Upload your profile image (PNG, JPG, JPEG, WebP, max 5MB). </FormDescription> <FormMessage /> </FormItem> @@ -260,4 +367,4 @@ export function AccountForm() { </form> </Form> ) -} +}
\ No newline at end of file diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx index 2f2467a3..a285b4de 100644 --- a/components/ship-vendor-document/add-attachment-dialog.tsx +++ b/components/ship-vendor-document/add-attachment-dialog.tsx @@ -55,15 +55,15 @@ const ACCEPTED_FILE_TYPES = [ const attachmentUploadSchema = z.object({ attachments: z .array(z.instanceof(File)) - .min(1, "최소 1개의 파일을 업로드해주세요") - .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .min(1, "Please upload at least 1 file") + .max(10, "Maximum 10 files can be uploaded") .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), - "파일 크기는 50MB 이하여야 합니다" + "File size must be 50MB or less" ) .refine( (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), - "지원하지 않는 파일 형식입니다" + "Unsupported file format" ), }) @@ -128,10 +128,10 @@ function FileUploadArea({ > <Paperclip className="mx-auto h-12 w-12 text-gray-400 mb-4" /> <p className="text-sm text-gray-600 mb-2"> - 추가할 파일을 드래그하여 놓거나 클릭하여 선택하세요 + Drag files to add here or click to select </p> <p className="text-xs text-gray-500"> - PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + Supports PDF, Word, Excel, Image, Text, ZIP files (max 50MB) </p> <input ref={fileInputRef} @@ -145,7 +145,7 @@ function FileUploadArea({ {files.length > 0 && ( <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> - <p className="text-sm font-medium">선택된 파일 ({files.length}개)</p> + <p className="text-sm font-medium">Selected Files ({files.length})</p> <div className="max-h-40 overflow-y-auto space-y-2"> {files.map((file, index) => ( <div @@ -251,7 +251,7 @@ export function AddAttachmentDialog({ if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || errorData.details || '첨부파일 업로드에 실패했습니다.') + throw new Error(errorData.error || errorData.details || 'Failed to upload attachments.') } const result = await response.json() @@ -259,10 +259,10 @@ export function AddAttachmentDialog({ toast.success( result.message || - `${result.data?.uploadedFiles?.length || 0}개 첨부파일이 추가되었습니다.` + `${result.data?.uploadedFiles?.length || 0} attachments added.` ) - console.log('✅ 첨부파일 업로드 성공:', result) + console.log('✅ Attachment upload successful:', result) setTimeout(() => { handleDialogClose() @@ -270,8 +270,8 @@ export function AddAttachmentDialog({ }, 1000) } catch (error) { - console.error('❌ 첨부파일 업로드 오류:', error) - toast.error(error instanceof Error ? error.message : "첨부파일 업로드 중 오류가 발생했습니다") + console.error('❌ Attachment upload error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred while uploading attachments") } finally { setIsUploading(false) setTimeout(() => setUploadProgress(0), 2000) @@ -285,10 +285,10 @@ export function AddAttachmentDialog({ <DialogHeader className="flex-shrink-0 pb-4 border-b"> <DialogTitle className="flex items-center gap-2"> <Paperclip className="h-5 w-5" /> - 첨부파일 추가 + Add Attachments </DialogTitle> <DialogDescription className="text-sm"> - 리비전 {revisionName}에 추가 첨부파일을 업로드합니다 + Upload additional attachments to revision {revisionName} </DialogDescription> </DialogHeader> @@ -302,7 +302,7 @@ export function AddAttachmentDialog({ name="attachments" render={({ field }) => ( <FormItem> - <FormLabel className="required">첨부파일</FormLabel> + <FormLabel className="required">Attachments</FormLabel> <FormControl> <FileUploadArea files={watchedFiles || []} @@ -318,14 +318,14 @@ export function AddAttachmentDialog({ {isUploading && ( <div className="space-y-2"> <div className="flex items-center justify-between text-sm"> - <span>업로드 진행률</span> + <span>Upload Progress</span> <span>{uploadProgress.toFixed(0)}%</span> </div> <Progress value={uploadProgress} className="w-full" /> {uploadProgress === 100 && ( <div className="flex items-center gap-2 text-sm text-green-600"> <CheckCircle className="h-4 w-4" /> - <span>업로드 완료</span> + <span>Upload Complete</span> </div> )} </div> @@ -340,7 +340,7 @@ export function AddAttachmentDialog({ onClick={handleDialogClose} disabled={isUploading} > - 취소 + Cancel </Button> <Button type="submit" @@ -350,12 +350,12 @@ export function AddAttachmentDialog({ {isUploading ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 업로드 중... + Uploading... </> ) : ( <> <Upload className="mr-2 h-4 w-4" /> - 추가 + Add </> )} </Button> diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx index 83c9c658..7adc0b3a 100644 --- a/components/ship-vendor-document/new-revision-dialog.tsx +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -67,20 +67,20 @@ const ACCEPTED_FILE_TYPES = [ // drawingKind에 따른 동적 스키마 생성 const createRevisionUploadSchema = (drawingKind: string) => { const baseSchema = { - usage: z.string().min(1, "용도를 선택해주세요"), - revision: z.string().min(1, "리비전을 입력해주세요").max(50, "리비전은 50자 이내로 입력해주세요"), + usage: z.string().min(1, "Please select a usage"), + revision: z.string().min(1, "Please enter a revision").max(50, "Revision must be 50 characters or less"), comment: z.string().optional(), attachments: z .array(z.instanceof(File)) - .min(1, "최소 1개의 파일을 업로드해주세요") - .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .min(1, "Please upload at least 1 file") + .max(10, "Maximum 10 files can be uploaded") .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), - "파일 크기는 50MB 이하여야 합니다" + "File size must be 50MB or less" ) .refine( (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), - "지원하지 않는 파일 형식입니다" + "Unsupported file format" ), } @@ -88,7 +88,7 @@ const createRevisionUploadSchema = (drawingKind: string) => { if (drawingKind === 'B3') { return z.object({ ...baseSchema, - usageType: z.string().min(1, "용도 타입을 선택해주세요"), + usageType: z.string().min(1, "Please select a usage type"), }) } else { return z.object({ @@ -151,7 +151,7 @@ const getUsageTypeOptions = (usage: string) => { // 리비전 형식 가이드 생성 const getRevisionGuide = () => { - return "R01, R02, R03... 형식으로 입력하세요" + return "Enter in R01, R02, R03... format" } interface NewRevisionDialogProps { @@ -216,10 +216,10 @@ function FileUploadArea({ > <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> <p className="text-sm text-gray-600 mb-2"> - 파일을 드래그하여 놓거나 클릭하여 선택하세요 + Drag files here or click to select </p> <p className="text-xs text-gray-500"> - PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + Supports PDF, Word, Excel, Image, Text, ZIP files (max 50MB) </p> <input ref={fileInputRef} @@ -233,7 +233,7 @@ function FileUploadArea({ {files.length > 0 && ( <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> - <p className="text-sm font-medium">선택된 파일 ({files.length}개)</p> + <p className="text-sm font-medium">Selected Files ({files.length})</p> <div className="max-h-40 overflow-y-auto space-y-2"> {files.map((file, index) => ( <div @@ -386,7 +386,7 @@ export function NewRevisionDialog({ if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.') + throw new Error(errorData.error || errorData.details || 'Upload failed.') } const result = await response.json() @@ -394,10 +394,10 @@ export function NewRevisionDialog({ toast.success( result.message || - `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)` + `Revision ${data.revision} uploaded successfully. (${result.data?.uploadedFiles?.length || 0} files)` ) - console.log('✅ 업로드 성공:', result) + console.log('✅ Upload successful:', result) setTimeout(() => { handleDialogClose() @@ -405,8 +405,8 @@ export function NewRevisionDialog({ }, 1000) } catch (error) { - console.error('❌ 업로드 오류:', error) - toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + console.error('❌ Upload error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred during upload") } finally { setIsUploading(false) setTimeout(() => setUploadProgress(0), 2000) @@ -420,11 +420,11 @@ export function NewRevisionDialog({ <DialogHeader className="flex-shrink-0 pb-4 border-b"> <DialogTitle className="flex items-center gap-2"> <Upload className="h-5 w-5" /> - 새 리비전 업로드 + Upload New Revision </DialogTitle> {documentTitle && ( <DialogDescription className="text-sm space-y-1"> - <div>문서: {documentTitle}</div> + <div>Document: {documentTitle}</div> </DialogDescription> )} </DialogHeader> @@ -439,11 +439,11 @@ export function NewRevisionDialog({ name="usage" render={({ field }) => ( <FormItem> - <FormLabel className="required">용도</FormLabel> + <FormLabel className="required">Usage</FormLabel> <Select onValueChange={field.onChange} value={field.value}> <FormControl> <SelectTrigger> - <SelectValue placeholder="용도를 선택하세요" /> + <SelectValue placeholder="Select usage" /> </SelectTrigger> </FormControl> <SelectContent> @@ -466,11 +466,11 @@ export function NewRevisionDialog({ name="usageType" render={({ field }) => ( <FormItem> - <FormLabel className="required">용도 타입</FormLabel> + <FormLabel className="required">Usage Type</FormLabel> <Select onValueChange={field.onChange} value={field.value}> <FormControl> <SelectTrigger> - <SelectValue placeholder="용도 타입을 선택하세요" /> + <SelectValue placeholder="Select usage type" /> </SelectTrigger> </FormControl> <SelectContent> @@ -493,7 +493,7 @@ export function NewRevisionDialog({ name="revision" render={({ field }) => ( <FormItem> - <FormLabel className="required">리비전</FormLabel> + <FormLabel className="required">Revision</FormLabel> <FormControl> <Input placeholder={revisionGuide} @@ -514,10 +514,10 @@ export function NewRevisionDialog({ name="comment" render={({ field }) => ( <FormItem> - <FormLabel>코멘트</FormLabel> + <FormLabel>Comment</FormLabel> <FormControl> <Textarea - placeholder="리비전에 대한 설명이나 변경사항을 입력하세요 (선택사항)" + placeholder="Enter description or changes for this revision (optional)" className="resize-none" rows={3} {...field} @@ -534,7 +534,7 @@ export function NewRevisionDialog({ name="attachments" render={({ field }) => ( <FormItem> - <FormLabel className="required">첨부파일</FormLabel> + <FormLabel className="required">Attachments</FormLabel> <FormControl> <FileUploadArea files={watchedFiles || []} @@ -550,14 +550,14 @@ export function NewRevisionDialog({ {isUploading && ( <div className="space-y-2"> <div className="flex items-center justify-between text-sm"> - <span>업로드 진행률</span> + <span>Upload Progress</span> <span>{uploadProgress.toFixed(0)}%</span> </div> <Progress value={uploadProgress} className="w-full" /> {uploadProgress === 100 && ( <div className="flex items-center gap-2 text-sm text-green-600"> <CheckCircle className="h-4 w-4" /> - <span>업로드 완료</span> + <span>Upload Complete</span> </div> )} </div> @@ -572,7 +572,7 @@ export function NewRevisionDialog({ onClick={handleDialogClose} disabled={isUploading} > - 취소 + Cancel </Button> <Button type="submit" @@ -582,12 +582,12 @@ export function NewRevisionDialog({ {isUploading ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 업로드 중... + Uploading... </> ) : ( <> <Upload className="mr-2 h-4 w-4" /> - 업로드 + Upload </> )} </Button> diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 17af5436..4e133696 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -168,7 +168,7 @@ function RevisionTable({ <CardHeader> <div className="flex items-center justify-between"> <div> - <CardTitle className="text-lg">리비전</CardTitle> + <CardTitle className="text-lg">Revisions</CardTitle> </div> <Button onClick={onNewRevision} @@ -176,7 +176,7 @@ function RevisionTable({ className="flex items-center gap-2" > <Plus className="h-4 w-4" /> - 새 리비전 + New Revision </Button> </div> </CardHeader> @@ -185,17 +185,17 @@ function RevisionTable({ <Table className="tbl-compact"> <TableHeader> <TableRow> - <TableHead className="w-12">선택</TableHead> - <TableHead>리비전</TableHead> - <TableHead>카테고리</TableHead> - <TableHead>용도</TableHead> - <TableHead>타입</TableHead> {/* ✅ usageType 컬럼 */} - <TableHead>상태</TableHead> - <TableHead>업로더</TableHead> - <TableHead>코멘트</TableHead> - <TableHead>업로드일</TableHead> - <TableHead className="text-center">파일 수</TableHead> - <TableHead>액션</TableHead> + <TableHead className="w-12">Select</TableHead> + <TableHead>Revision</TableHead> + <TableHead>Category</TableHead> + <TableHead>Usage</TableHead> + <TableHead>Type</TableHead> {/* ✅ usageType 컬럼 */} + <TableHead>Status</TableHead> + <TableHead>Uploader</TableHead> + <TableHead>Comment</TableHead> + <TableHead>Upload Date</TableHead> + <TableHead className="text-center">Files</TableHead> + <TableHead>Actions</TableHead> </TableRow> </TableHeader> <TableBody> @@ -331,7 +331,7 @@ function AttachmentTable({ // ✅ 첨부파일 업로드 성공 핸들러 const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { if (!selectedRevisionId || !allData || !uploadResult?.data) { - console.log('🔄 전체 새로고침') + console.log('🔄 Full refresh') router.refresh() return } @@ -374,7 +374,7 @@ function AttachmentTable({ }) setAllData(updatedData) - console.log('✅ AttachmentTable 업데이트 완료') + console.log('✅ AttachmentTable update complete') // 메인 테이블도 업데이트 (약간의 지연 후) setTimeout(() => { @@ -382,7 +382,7 @@ function AttachmentTable({ }, 1500) } catch (error) { - console.error('❌ AttachmentTable 업데이트 실패:', error) + console.error('❌ AttachmentTable update failed:', error) router.refresh() } }, [selectedRevisionId, allData, setAllData, router]) @@ -392,7 +392,7 @@ function AttachmentTable({ <Card className="w-96 flex-shrink-0"> <CardHeader> <div className="flex items-center justify-between"> - <CardTitle className="text-lg">첨부파일</CardTitle> + <CardTitle className="text-lg">Attachments</CardTitle> {/* ✅ + 버튼 추가 */} {selectedRevisionId && selectedRevisionInfo && ( <Button @@ -402,7 +402,7 @@ function AttachmentTable({ className="flex items-center gap-2" > <Plus className="h-4 w-4" /> - 추가 + Add </Button> )} </div> @@ -411,8 +411,8 @@ function AttachmentTable({ <Table className="tbl-compact"> <TableHeader> <TableRow> - <TableHead>파일명</TableHead> - <TableHead>액션</TableHead> + <TableHead>File Name</TableHead> + <TableHead>Actions</TableHead> </TableRow> </TableHeader> <TableBody> @@ -423,8 +423,8 @@ function AttachmentTable({ <FileText className="h-8 w-8" /> <span> {!selectedRevisionId - ? '리비전을 선택해주세요' - : '첨부된 파일이 없습니다'} + ? 'Please select a revision' + : 'No attached files'} </span> {/* ✅ 리비전이 선택된 경우 추가 버튼 표시 */} {selectedRevisionId && selectedRevisionInfo && ( @@ -435,7 +435,7 @@ function AttachmentTable({ className="mt-2" > <Plus className="h-4 w-4 mr-2" /> - 첫 번째 파일 추가 + Add First File </Button> )} </div> @@ -599,10 +599,10 @@ function SubTables() { // State 업데이트 setAllData(updatedData) - console.log('✅ RevisionTable 데이터 업데이트 완료') + console.log('✅ RevisionTable data update complete') } catch (error) { - console.error('❌ RevisionTable 업데이트 실패:', error) + console.error('❌ RevisionTable update failed:', error) // 실패 시 전체 새로고침 window.location.reload() } @@ -679,7 +679,7 @@ function SubTables() { if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || '파일 다운로드에 실패했습니다.') + throw new Error(errorData.error || 'Failed to download file.') } const blob = await response.blob() @@ -692,8 +692,8 @@ function SubTables() { window.document.body.removeChild(link) window.URL.revokeObjectURL(url) } catch (error) { - console.error('파일 다운로드 오류:', error) - alert(`파일 다운로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + console.error('File download error:', error) + alert(`File download failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } }, []) @@ -707,7 +707,7 @@ function SubTables() { if (viewer.current && !isCancelled.current) { import("@pdftron/webviewer").then(({ default: WebViewer }) => { if (isCancelled.current) { - console.log("WebViewer 초기화 취소됨 (Dialog 닫힘)") + console.log("WebViewer initialization cancelled (Dialog closed)") return } @@ -764,7 +764,7 @@ function SubTables() { const tab = await UI.TabManager.addTab(blob, options) tabIds.push(tab) } catch (error) { - console.error("파일 로드 실패:", attachment.filePath, error) + console.error("File load failed:", attachment.filePath, error) } } @@ -818,9 +818,9 @@ function SubTables() { <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}> <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> <DialogHeader className="h-[38px]"> - <DialogTitle>문서 미리보기</DialogTitle> + <DialogTitle>Document Preview</DialogTitle> <DialogDescription> - 리비전 {selectedRevision?.revision} 첨부파일 + Revision {selectedRevision?.revision} attachments </DialogDescription> </DialogHeader> <div @@ -831,7 +831,7 @@ function SubTables() { <div className="flex flex-col items-center justify-center py-12"> <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> <p className="text-sm text-muted-foreground"> - 문서 뷰어 로딩 중... + Loading document viewer... </p> </div> )} @@ -885,7 +885,7 @@ function SelectedDocumentInfo() { <div className="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 p-4"> <div className="flex items-center gap-2"> <Badge variant="secondary" className="text-sm"> - 문서: {doc.docNumber} + Document: {doc.docNumber} </Badge> <span className="max-w-[300px] truncate text-sm font-medium text-gray-700"> {doc.title} @@ -893,14 +893,14 @@ function SelectedDocumentInfo() { </div> <div className="flex items-center gap-2 text-sm text-gray-600"> <span>•</span> - <span>총 {totalRevisions}개 리비전</span> + <span>Total {totalRevisions} revisions</span> {selectedRevision && ( <> <span>•</span> <Badge variant="outline" className="text-sm"> - 선택된 리비전: {selectedRevision.revision} + Selected revision: {selectedRevision.revision} </Badge> - <span>({selectedRevision.attachments.length}개 파일)</span> + <span>({selectedRevision.attachments.length} files)</span> </> )} </div> @@ -960,7 +960,7 @@ export function UserVendorDocumentDisplay({ <CardContent className="flex items-center justify-center py-8"> <div className="text-center"> <AlertCircle className="mx-auto mb-2 h-8 w-8 text-gray-400" /> - <p className="text-gray-600">데이터를 불러올 수 없습니다.</p> + <p className="text-gray-600">Unable to load data.</p> </div> </CardContent> </Card> diff --git a/components/signup/tech-vendor-join-form.tsx b/components/signup/tech-vendor-join-form.tsx index efdee322..4d17398d 100644 --- a/components/signup/tech-vendor-join-form.tsx +++ b/components/signup/tech-vendor-join-form.tsx @@ -269,9 +269,9 @@ export function TechVendorJoinForm() { }
const isFormValid = form.formState.isValid
- console.log("Form errors:", form.formState.errors);
- console.log("Form values:", form.getValues());
- console.log("Form valid:", form.formState.isValid);
+ // console.log("Form errors:", form.formState.errors);
+ // console.log("Form values:", form.getValues());
+ // console.log("Form valid:", form.formState.isValid);
// Dropzone handlers
const handleDropAccepted = (acceptedFiles: File[]) => {
diff --git a/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx b/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx index a2e0e9b6..14aa2b68 100644 --- a/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx +++ b/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx @@ -63,9 +63,9 @@ export function TechVendorPossibleItemsContainer({ <div className="flex items-center justify-between">
{/* 왼쪽: 타이틀 & 설명 */}
<div>
- <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 아이템 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">협력업체별 자재 관리</h2>
{/* <p className="text-muted-foreground">
- 기술영업 벤더별 가능 아이템을 관리합니다.
+ 기술영업 협력업체별 자재를 관리합니다.
</p> */}
</div>
diff --git a/components/tech-vendors/tech-vendor-container.tsx b/components/tech-vendors/tech-vendor-container.tsx index 94536702..8c89d3e4 100644 --- a/components/tech-vendors/tech-vendor-container.tsx +++ b/components/tech-vendors/tech-vendor-container.tsx @@ -62,7 +62,7 @@ export function TechVendorContainer({ {/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 리스트</h2>
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 협력업체 리스트</h2>
<InformationButton pagePath="evcp/tech-vendors" />
</div>
{/* <p className="text-muted-foreground">
|
