summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-22 02:57:00 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-22 02:57:00 +0000
commitee57cc221ff2edafd3c0f12a181214c602ed257e (patch)
tree148f552f503798f7a350d6eff936b889f16be49f /components
parent14f61e24947fb92dd71ec0a7196a6e815f8e66da (diff)
(대표님, 최겸) 이메일 템플릿, 벤더데이터 변경사항 대응, 기술영업 변경요구사항 구현
Diffstat (limited to 'components')
-rw-r--r--components/additional-info/join-form.tsx311
-rw-r--r--components/additional-info/tech-vendor-info-form.tsx188
-rw-r--r--components/layout/Header.tsx4
-rw-r--r--components/settings/account-form.tsx283
-rw-r--r--components/ship-vendor-document/add-attachment-dialog.tsx40
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx62
-rw-r--r--components/ship-vendor-document/user-vendor-document-table-container.tsx76
-rw-r--r--components/signup/tech-vendor-join-form.tsx6
-rw-r--r--components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx4
-rw-r--r--components/tech-vendors/tech-vendor-container.tsx2
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">