summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/BidProjectSelector.tsx10
-rw-r--r--components/data-table/data-table-grobal-filter.tsx1
-rw-r--r--components/data-table/data-table-sort-list.tsx45
-rw-r--r--components/data-table/data-table.tsx40
-rw-r--r--components/form-data/form-data-report-temp-upload-dialog.tsx112
-rw-r--r--components/form-data/temp-download-btn.tsx46
-rw-r--r--components/information/information-button.tsx189
-rw-r--r--components/login/login-form-shi.tsx14
-rw-r--r--components/login/login-form.tsx287
-rw-r--r--components/signup/join-form.tsx379
-rw-r--r--components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx102
-rw-r--r--components/ui/file-actions.tsx440
-rw-r--r--components/ui/text-utils.tsx131
13 files changed, 1322 insertions, 474 deletions
diff --git a/components/BidProjectSelector.tsx b/components/BidProjectSelector.tsx
index 8e229b10..5cbcfee6 100644
--- a/components/BidProjectSelector.tsx
+++ b/components/BidProjectSelector.tsx
@@ -6,18 +6,20 @@ import { Button } from "@/components/ui/button"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
import { cn } from "@/lib/utils"
-import { getBidProjects, type Project } from "@/lib/rfqs/service"
+import { getBidProjects, type Project } from "@/lib/techsales-rfq/service"
interface ProjectSelectorProps {
selectedProjectId?: number | null;
onProjectSelect: (project: Project) => void;
placeholder?: string;
+ pjtType?: 'SHIP' | 'TOP' | 'HULL';
}
export function EstimateProjectSelector ({
selectedProjectId,
onProjectSelect,
- placeholder = "프로젝트 선택..."
+ placeholder = "프로젝트 선택...",
+ pjtType
}: ProjectSelectorProps) {
const [open, setOpen] = React.useState(false)
const [searchTerm, setSearchTerm] = React.useState("")
@@ -30,7 +32,7 @@ export function EstimateProjectSelector ({
async function loadAllProjects() {
setIsLoading(true);
try {
- const allProjects = await getBidProjects();
+ const allProjects = await getBidProjects(pjtType);
setProjects(allProjects);
// 초기 선택된 프로젝트가 있으면 설정
@@ -48,7 +50,7 @@ export function EstimateProjectSelector ({
}
loadAllProjects();
- }, [selectedProjectId]);
+ }, [selectedProjectId, pjtType]);
// 클라이언트 측에서 검색어로 필터링
const filteredProjects = React.useMemo(() => {
diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx
index 240e9fa7..a1f0a6f3 100644
--- a/components/data-table/data-table-grobal-filter.tsx
+++ b/components/data-table/data-table-grobal-filter.tsx
@@ -17,7 +17,6 @@ export function DataTableGlobalFilter() {
eq: (a, b) => a === b,
clearOnDefault: true,
shallow: false,
- history: "replace"
})
// Local tempValue to update instantly on user keystroke
diff --git a/components/data-table/data-table-sort-list.tsx b/components/data-table/data-table-sort-list.tsx
index c3c537ac..c752f2f4 100644
--- a/components/data-table/data-table-sort-list.tsx
+++ b/components/data-table/data-table-sort-list.tsx
@@ -54,19 +54,30 @@ interface DataTableSortListProps<TData> {
shallow?: boolean
}
+let renderCount = 0;
+
export function DataTableSortList<TData>({
table,
debounceMs,
shallow,
}: DataTableSortListProps<TData>) {
+ renderCount++;
+
const id = React.useId()
const initialSorting = (table.initialState.sorting ??
[]) as ExtendedSortingState<TData>
+ // ✅ 파서를 안정화 - 한 번만 생성되도록 수정
+ const sortingParser = React.useMemo(() => {
+ // 첫 번째 행의 데이터를 안정적으로 가져오기
+ const sampleData = table.getRowModel().rows[0]?.original;
+ return getSortingStateParser(sampleData);
+ }, []); // ✅ 빈 dependency - 한 번만 생성
+
const [sorting, setSorting] = useQueryState(
"sort",
- getSortingStateParser(table.getRowModel().rows[0]?.original)
+ sortingParser
.withDefault(initialSorting)
.withOptions({
clearOnDefault: true,
@@ -74,6 +85,10 @@ export function DataTableSortList<TData>({
})
)
+ // ✅ debouncedSetSorting - 컴포넌트 최상위로 이동
+ const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs);
+
+ // ✅ uniqueSorting 메모이제이션
const uniqueSorting = React.useMemo(
() =>
sorting.filter(
@@ -82,8 +97,7 @@ export function DataTableSortList<TData>({
[sorting]
)
- const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs)
-
+ // ✅ sortableColumns 메모이제이션
const sortableColumns = React.useMemo(
() =>
table
@@ -100,7 +114,8 @@ export function DataTableSortList<TData>({
[sorting, table]
)
- function addSort() {
+ // ✅ 함수들을 useCallback으로 메모이제이션
+ const addSort = React.useCallback(() => {
const firstAvailableColumn = sortableColumns.find(
(column) => !sorting.some((s) => s.id === column.id)
)
@@ -113,9 +128,9 @@ export function DataTableSortList<TData>({
desc: false,
},
])
- }
+ }, [sortableColumns, sorting, setSorting]);
- function updateSort({
+ const updateSort = React.useCallback(({
id,
field,
debounced = false,
@@ -123,7 +138,7 @@ export function DataTableSortList<TData>({
id: string
field: Partial<ExtendedColumnSort<TData>>
debounced?: boolean
- }) {
+ }) => {
const updateFunction = debounced ? debouncedSetSorting : setSorting
updateFunction((prevSorting) => {
@@ -134,13 +149,17 @@ export function DataTableSortList<TData>({
)
return updatedSorting
})
- }
+ }, [debouncedSetSorting, setSorting]);
- function removeSort(id: string) {
+ const removeSort = React.useCallback((id: string) => {
void setSorting((prevSorting) =>
prevSorting.filter((item) => item.id !== id)
)
- }
+ }, [setSorting]);
+
+ const resetSorting = React.useCallback(() => {
+ setSorting(null);
+ }, [setSorting]);
return (
<Sortable
@@ -167,7 +186,7 @@ export function DataTableSortList<TData>({
<ArrowDownUp className="size-3" aria-hidden="true" />
<span className="hidden sm:inline">
- 정렬
+ 정렬
</span>
{uniqueSorting.length > 0 && (
@@ -357,7 +376,7 @@ export function DataTableSortList<TData>({
size="sm"
variant="outline"
className="rounded"
- onClick={() => setSorting(null)}
+ onClick={resetSorting}
>
Reset sorting
</Button>
@@ -367,4 +386,4 @@ export function DataTableSortList<TData>({
</Popover>
</Sortable>
)
-}
+} \ No newline at end of file
diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx
index 64afcb7e..33fca5b8 100644
--- a/components/data-table/data-table.tsx
+++ b/components/data-table/data-table.tsx
@@ -25,6 +25,25 @@ interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
compact?: boolean // 컴팩트 모드 옵션 추가
}
+// ✅ compactStyles를 정적으로 정의 (매번 새로 생성 방지)
+const COMPACT_STYLES = {
+ row: "h-7", // 행 높이 축소
+ cell: "py-1 px-2 text-sm", // 셀 패딩 축소 및 폰트 크기 조정
+ groupRow: "py-1 bg-muted/20 text-sm", // 그룹 행 패딩 축소
+ emptyRow: "h-16", // 데이터 없을 때 행 높이 조정
+ header: "py-1 px-2 text-sm", // 헤더 패딩 축소
+ headerHeight: "h-8", // 헤더 높이 축소
+};
+
+const NORMAL_STYLES = {
+ row: "",
+ cell: "",
+ groupRow: "bg-muted/20",
+ emptyRow: "h-24",
+ header: "",
+ headerHeight: "",
+};
+
/**
* 멀티 그룹핑 + 그룹 토글 + 그룹 컬럼/헤더 숨김 + Indent + 리사이징 + 컴팩트 모드
*/
@@ -41,18 +60,11 @@ export function DataTable<TData>({
useAutoSizeColumns(table, autoSizeColumns)
- // 컴팩트 모드를 위한 클래스 정의
- const compactStyles = compact ? {
- row: "h-7", // 행 높이 축소
- cell: "py-1 px-2 text-sm", // 셀 패딩 축소 및 폰트 크기 조정
- groupRow: "py-1 bg-muted/20 text-sm", // 그룹 행 패딩 축소
- emptyRow: "h-16", // 데이터 없을 때 행 높이 조정
- } : {
- row: "",
- cell: "",
- groupRow: "bg-muted/20",
- emptyRow: "h-24",
- }
+ // ✅ compactStyles를 useMemo로 메모이제이션
+ const compactStyles = React.useMemo(() =>
+ compact ? COMPACT_STYLES : NORMAL_STYLES,
+ [compact]
+ );
return (
<div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}>
@@ -62,7 +74,7 @@ export function DataTable<TData>({
{/* 테이블 헤더 */}
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}>
+ <TableRow key={headerGroup.id} className={compactStyles.headerHeight}>
{headerGroup.headers.map((header) => {
if (header.column.getIsGrouped()) {
return null
@@ -73,7 +85,7 @@ export function DataTable<TData>({
key={header.id}
colSpan={header.colSpan}
data-column-id={header.column.id}
- className={compact ? "py-1 px-2 text-sm" : ""}
+ className={compactStyles.header}
style={{
...getCommonPinningStylesWithBorder({
column: header.column,
diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx
index e4d78248..fe137daf 100644
--- a/components/form-data/form-data-report-temp-upload-dialog.tsx
+++ b/components/form-data/form-data-report-temp-upload-dialog.tsx
@@ -11,17 +11,11 @@ import {
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { TempDownloadBtn } from "./temp-download-btn";
import { VarListDownloadBtn } from "./var-list-download-btn";
import { FormDataReportTempUploadTab } from "./form-data-report-temp-upload-tab";
import { FormDataReportTempUploadedListTab } from "./form-data-report-temp-uploaded-list-tab";
import { DataTableColumnJSON } from "./form-data-table-columns";
+import { FileActionsDropdown } from "../ui/file-actions";
interface FormDataReportTempUploadDialogProps {
columnsJSON: DataTableColumnJSON[];
@@ -44,54 +38,60 @@ export const FormDataReportTempUploadDialog: FC<
formCode,
uploaderType,
}) => {
- const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload");
+ const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload");
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}>
- <DialogHeader className="gap-2">
- <DialogTitle>Vendor Document Template</DialogTitle>
- <DialogDescription className="flex justify-around gap-[16px] ">
- {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}>
+ <DialogHeader className="gap-2">
+ <DialogTitle>Vendor Document Template</DialogTitle>
+ <DialogDescription className="flex justify-around gap-[16px] ">
+ {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드
하여주시기 바랍니다. */}
- <TempDownloadBtn />
- <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} />
- </DialogDescription>
- </DialogHeader>
- <Tabs value={tabValue}>
- <div className="flex justify-between items-center">
- <TabsList className="w-full">
- <TabsTrigger
- value="upload"
- onClick={() => setTabValue("upload")}
- className="flex-1"
- >
- Upload Template File
- </TabsTrigger>
- <TabsTrigger
- value="uploaded"
- onClick={() => setTabValue("uploaded")}
- className="flex-1"
- >
- Uploaded Template File List
- </TabsTrigger>
- </TabsList>
- </div>
- <TabsContent value="upload">
- <FormDataReportTempUploadTab
- packageId={packageId}
- formId={formId}
- uploaderType={uploaderType}
- />
- </TabsContent>
- <TabsContent value="uploaded">
- <FormDataReportTempUploadedListTab
- packageId={packageId}
- formId={formId}
- />
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
- );
-}; \ No newline at end of file
+ <FileActionsDropdown
+ filePath={"/vendorFormReportSample"}
+ fileName={"sample_template_file.docx"}
+ variant="ghost"
+ size="icon"
+ description="Sample File"
+ />
+ <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} />
+ </DialogDescription>
+ </DialogHeader>
+ <Tabs value={tabValue}>
+ <div className="flex justify-between items-center">
+ <TabsList className="w-full">
+ <TabsTrigger
+ value="upload"
+ onClick={() => setTabValue("upload")}
+ className="flex-1"
+ >
+ Upload Template File
+ </TabsTrigger>
+ <TabsTrigger
+ value="uploaded"
+ onClick={() => setTabValue("uploaded")}
+ className="flex-1"
+ >
+ Uploaded Template File List
+ </TabsTrigger>
+ </TabsList>
+ </div>
+ <TabsContent value="upload">
+ <FormDataReportTempUploadTab
+ packageId={packageId}
+ formId={formId}
+ uploaderType={uploaderType}
+ />
+ </TabsContent>
+ <TabsContent value="uploaded">
+ <FormDataReportTempUploadedListTab
+ packageId={packageId}
+ formId={formId}
+ />
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ );
+ }; \ No newline at end of file
diff --git a/components/form-data/temp-download-btn.tsx b/components/form-data/temp-download-btn.tsx
deleted file mode 100644
index 793022d6..00000000
--- a/components/form-data/temp-download-btn.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-"use client";
-
-import React from "react";
-import Image from "next/image";
-import { useToast } from "@/hooks/use-toast";
-import { toast as toastMessage } from "sonner";
-import { saveAs } from "file-saver";
-import { Button } from "@/components/ui/button";
-import { getReportTempFileData } from "@/lib/forms/services";
-
-export const TempDownloadBtn = () => {
- const { toast } = useToast();
-
- const downloadTempFile = async () => {
- try {
- const { fileName, fileType, base64 } = await getReportTempFileData();
-
- saveAs(`data:${fileType};base64,${base64}`, fileName);
-
- toastMessage.success("Report Sample File 다운로드 완료!");
- } catch (err) {
- console.log(err);
- toast({
- title: "Error",
- description: "Sample File을 찾을 수가 없습니다.",
- variant: "destructive",
- });
- }
- };
- return (
- <Button
- variant="outline"
- className="relative px-[8px] py-[6px] flex-1"
- aria-label="Template Sample Download"
- onClick={downloadTempFile}
- >
- <Image
- src="/icons/temp_sample_icon.svg"
- alt="Template Sample Download Icon"
- width={16}
- height={16}
- />
- <div className='text-[12px]'>Sample Template Download</div>
- </Button>
- );
-}; \ No newline at end of file
diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx
index 38e8cb12..f8707439 100644
--- a/components/information/information-button.tsx
+++ b/components/information/information-button.tsx
@@ -11,7 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
-import { Info, Download, Edit } from "lucide-react"
+import { Info, Download, Edit, Loader2 } from "lucide-react"
import { getCachedPageInformation, getCachedEditPermission } from "@/lib/information/service"
import { getCachedPageNotices } from "@/lib/notice/service"
import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog"
@@ -48,11 +48,13 @@ export function InformationButton({
const [selectedNotice, setSelectedNotice] = useState<NoticeWithAuthor | null>(null)
const [isNoticeViewDialogOpen, setIsNoticeViewDialogOpen] = useState(false)
const [dataLoaded, setDataLoaded] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
// 데이터 로드 함수 (단순화)
const loadData = React.useCallback(async () => {
if (dataLoaded) return // 이미 로드되었으면 중복 방지
+ setIsLoading(true)
try {
// pagePath 정규화 (앞의 / 제거)
const normalizedPath = pagePath.startsWith('/') ? pagePath.slice(1) : pagePath
@@ -74,6 +76,8 @@ export function InformationButton({
}
} catch (error) {
console.error("데이터 로딩 중 오류:", error)
+ } finally {
+ setIsLoading(false)
}
}, [pagePath, session?.user?.id, dataLoaded])
@@ -140,100 +144,119 @@ export function InformationButton({
</div>
</DialogHeader>
- <div className="mt-4 space-y-6">
- {/* 공지사항 섹션 */}
- {notices.length > 0 && (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-semibold">공지사항</h4>
- <span className="text-xs text-gray-500">{notices.length}개</span>
- </div>
- <div className="max-h-60 overflow-y-auto border rounded-lg bg-gray-50 p-2">
- <div className="space-y-2">
- {notices.map((notice) => (
- <div
- key={notice.id}
- className="p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
- onClick={() => handleNoticeClick(notice)}
- >
- <div className="space-y-1">
- <h5 className="font-medium text-sm line-clamp-2">
- {notice.title}
- </h5>
- <div className="flex items-center gap-3 text-xs text-gray-500">
- <span>{formatDate(notice.createdAt)}</span>
- {notice.authorName && (
- <span>{notice.authorName}</span>
- )}
+ <div className="mt-4">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-6 w-6 animate-spin text-gray-500" />
+ <span className="ml-2 text-gray-500">정보를 불러오는 중...</span>
+ </div>
+ ) : (
+ <div className="space-y-6">
+ {/* 공지사항 섹션 */}
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-semibold">공지사항</h4>
+ {notices.length > 0 && (
+ <span className="text-xs text-gray-500">{notices.length}개</span>
+ )}
+ </div>
+ {notices.length > 0 ? (
+ <div className="max-h-60 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {notices.map((notice) => (
+ <div
+ key={notice.id}
+ className="p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
+ onClick={() => handleNoticeClick(notice)}
+ >
+ <div className="space-y-1">
+ <h5 className="font-medium text-sm line-clamp-2">
+ {notice.title}
+ </h5>
+ <div className="flex items-center gap-3 text-xs text-gray-500">
+ <span>{formatDate(notice.createdAt)}</span>
+ {notice.authorName && (
+ <span>{notice.authorName}</span>
+ )}
+ </div>
+ </div>
</div>
- </div>
+ ))}
</div>
- ))}
- </div>
+ </div>
+ ) : (
+ <div className="bg-gray-50 border rounded-lg p-4">
+ <div className="text-center text-gray-500">
+ 공지사항이 없습니다
+ </div>
+ </div>
+ )}
</div>
- </div>
- )}
- {/* 인포메이션 컨텐츠 */}
- {information?.informationContent && (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-semibold">안내사항</h4>
- {hasEditPermission && information && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleEditClick}
- className="flex items-center gap-2 mr-2"
- >
- <Edit className="h-4 w-4" />
- 편집
- </Button>
- )}
- </div>
- <div className="bg-gray-50 border rounded-lg p-4">
- <div className="text-sm text-gray-600 whitespace-pre-wrap max-h-40 overflow-y-auto">
- {information.informationContent}
+ {/* 인포메이션 컨텐츠 */}
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-semibold">안내사항</h4>
+ {hasEditPermission && information && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditClick}
+ className="flex items-center gap-2 mr-2"
+ >
+ <Edit className="h-4 w-4" />
+ 편집
+ </Button>
+ )}
+ </div>
+ <div className="bg-gray-50 border rounded-lg p-4">
+ {information?.informationContent ? (
+ <div className="text-sm text-gray-600 whitespace-pre-wrap max-h-40 overflow-y-auto">
+ {information.informationContent}
+ </div>
+ ) : (
+ <div className="text-center text-gray-500">
+ 안내사항이 없습니다
+ </div>
+ )}
</div>
</div>
- </div>
- )}
- {/* 첨부파일 */}
- {information?.attachmentFileName && (
- <div className="space-y-3">
- <h4 className="font-semibold">첨부파일</h4>
- <div className="bg-gray-50 border rounded-lg p-4">
- <div className="flex items-center justify-between p-3 bg-white rounded border">
- <div className="flex-1">
- <div className="text-sm font-medium">
- {information.attachmentFileName}
- </div>
- {information.attachmentFileSize && (
- <div className="text-xs text-gray-500 mt-1">
- {information.attachmentFileSize}
+ {/* 첨부파일 */}
+ <div className="space-y-3">
+ <h4 className="font-semibold">첨부파일</h4>
+ <div className="bg-gray-50 border rounded-lg p-4">
+ {information?.attachmentFileName ? (
+ <div className="flex items-center justify-between p-3 bg-white rounded border">
+ <div className="flex-1">
+ <div className="text-sm font-medium">
+ {information.attachmentFileName}
+ </div>
+ {information.attachmentFileSize && (
+ <div className="text-xs text-gray-500 mt-1">
+ {information.attachmentFileSize}
+ </div>
+ )}
</div>
- )}
- </div>
- <Button
- size="sm"
- variant="outline"
- onClick={handleDownload}
- className="flex items-center gap-1"
- >
- <Download className="h-3 w-3" />
- 다운로드
- </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={handleDownload}
+ className="flex items-center gap-1"
+ >
+ <Download className="h-3 w-3" />
+ 다운로드
+ </Button>
+ </div>
+ ) : (
+ <div className="text-center text-gray-500">
+ 첨부파일이 없습니다
+ </div>
+ )}
</div>
</div>
</div>
)}
-
- {!information && notices.length === 0 && (
- <div className="text-center py-8 text-gray-500">
- <p>이 페이지에 대한 정보가 없습니다.</p>
- </div>
- )}
</div>
</DialogContent>
</Dialog>
diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx
index 6be8d5c8..862f9f8a 100644
--- a/components/login/login-form-shi.tsx
+++ b/components/login/login-form-shi.tsx
@@ -99,12 +99,12 @@ export function LoginFormSHI({
try {
// next-auth의 Credentials Provider로 로그인 시도
- const result = await signIn('credentials', {
+ const result = await signIn('credentials-otp', {
email,
code: otp,
redirect: false, // 커스텀 처리 위해 redirect: false
});
-
+
if (result?.ok) {
// 토스트 메시지 표시
toast({
@@ -204,9 +204,9 @@ export function LoginFormSHI({
<div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
{/* Here's your existing login/OTP forms: */}
- {!otpSent ? (
- // ( */}
- <form onSubmit={handleSubmit} className="p-6 md:p-8">
+ {/* {!otpSent ? ( */}
+
+ <form onSubmit={handleOtpSubmit} className="p-6 md:p-8">
{/* <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> */}
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center">
@@ -269,7 +269,7 @@ export function LoginFormSHI({
</div>
</div>
</form>
- )
+ {/* )
: (
@@ -323,7 +323,7 @@ export function LoginFormSHI({
</div>
</div>
</form>
- )}
+ )} */}
<div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
{t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')}
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
index bb588ba0..a71fd15e 100644
--- a/components/login/login-form.tsx
+++ b/components/login/login-form.tsx
@@ -38,12 +38,13 @@ export function LoginForm({
// 상태 관리
const [loginMethod, setLoginMethod] = useState<LoginMethod>('username');
- const [isLoading, setIsLoading] = useState(false);
+ const [isFirstAuthLoading, setIsFirstAuthLoading] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
// MFA 관련 상태
const [showMfaForm, setShowMfaForm] = useState(false);
const [mfaToken, setMfaToken] = useState('');
+ const [tempAuthKey, setTempAuthKey] = useState('');
const [mfaUserId, setMfaUserId] = useState('');
const [mfaUserEmail, setMfaUserEmail] = useState('');
const [mfaCountdown, setMfaCountdown] = useState(0);
@@ -56,6 +57,9 @@ export function LoginForm({
const [sgipsUsername, setSgipsUsername] = useState('');
const [sgipsPassword, setSgipsPassword] = useState('');
+ const [isMfaLoading, setIsMfaLoading] = useState(false);
+ const [isSmsLoading, setIsSmsLoading] = useState(false);
+
// 서버 액션 상태
const [passwordResetState, passwordResetAction] = useFormState(requestPasswordResetAction, {
success: false,
@@ -100,29 +104,56 @@ export function LoginForm({
}
}, [passwordResetState, toast, t]);
- // SMS 토큰 전송
- const handleSendSms = async () => {
- if (!mfaUserId || mfaCountdown > 0) return;
+ // 1차 인증 수행 (공통 함수)
+ const performFirstAuth = async (username: string, password: string, provider: 'email' | 'sgips') => {
+ try {
+ const response = await fetch('/api/auth/first-auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username,
+ password,
+ provider
+ }),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.error || '인증에 실패했습니다.');
+ }
+
+ return result;
+ } catch (error) {
+ console.error('First auth error:', error);
+ throw error;
+ }
+ };
+
+ // SMS 토큰 전송 (userId 파라미터 추가)
+ const handleSendSms = async (userIdParam?: string) => {
+ const targetUserId = userIdParam || mfaUserId;
+ if (!targetUserId || mfaCountdown > 0) return;
- setIsLoading(true);
+ setIsSmsLoading(true);
try {
- // SMS 전송 API 호출 (실제 구현 필요)
const response = await fetch('/api/auth/send-sms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ userId: mfaUserId }),
+ body: JSON.stringify({ userId: targetUserId }),
});
if (response.ok) {
- setMfaCountdown(60); // 60초 카운트다운
+ setMfaCountdown(60);
toast({
title: 'SMS 전송 완료',
description: '인증번호를 전송했습니다.',
});
} else {
+ const errorData = await response.json();
toast({
title: t('errorTitle'),
- description: 'SMS 전송에 실패했습니다.',
+ description: errorData.message || 'SMS 전송에 실패했습니다.',
variant: 'destructive',
});
}
@@ -134,11 +165,11 @@ export function LoginForm({
variant: 'destructive',
});
} finally {
- setIsLoading(false);
+ setIsSmsLoading(false);
}
};
- // MFA 토큰 검증
+ // MFA 토큰 검증 및 최종 로그인
const handleMfaSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -151,26 +182,34 @@ export function LoginForm({
return;
}
- setIsLoading(true);
+ if (!tempAuthKey) {
+ toast({
+ title: t('errorTitle'),
+ description: '인증 세션이 만료되었습니다. 다시 로그인해주세요.',
+ variant: 'destructive',
+ });
+ setShowMfaForm(false);
+ return;
+ }
+
+ setIsMfaLoading(true);
try {
- // MFA 토큰 검증 API 호출
- const response = await fetch('/api/auth/verify-mfa', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- userId: mfaUserId,
- token: mfaToken
- }),
+ // NextAuth의 credentials-mfa 프로바이더로 최종 인증
+ const result = await signIn('credentials-mfa', {
+ userId: mfaUserId,
+ smsToken: mfaToken,
+ tempAuthKey: tempAuthKey,
+ redirect: false,
});
- if (response.ok) {
+ if (result?.ok) {
toast({
title: '인증 완료',
description: '로그인이 완료되었습니다.',
});
- // callbackUrl 처리
+ // 콜백 URL 처리
const callbackUrlParam = searchParams?.get('callbackUrl');
if (callbackUrlParam) {
try {
@@ -184,10 +223,24 @@ export function LoginForm({
router.push(`/${lng}/partners/dashboard`);
}
} else {
- const errorData = await response.json();
+ let errorMessage = '인증번호가 올바르지 않습니다.';
+
+ if (result?.error) {
+ switch (result.error) {
+ case 'CredentialsSignin':
+ errorMessage = '인증번호가 올바르지 않거나 만료되었습니다.';
+ break;
+ case 'AccessDenied':
+ errorMessage = '접근이 거부되었습니다.';
+ break;
+ default:
+ errorMessage = 'MFA 인증에 실패했습니다.';
+ }
+ }
+
toast({
title: t('errorTitle'),
- description: errorData.message || '인증번호가 올바르지 않습니다.',
+ description: errorMessage,
variant: 'destructive',
});
}
@@ -199,11 +252,11 @@ export function LoginForm({
variant: 'destructive',
});
} finally {
- setIsLoading(false);
+ setIsMfaLoading(false);
}
};
- // 일반 사용자명/패스워드 로그인 처리 (간소화된 버전)
+ // 일반 사용자명/패스워드 1차 인증 처리
const handleUsernameLogin = async (e: React.FormEvent) => {
e.preventDefault();
@@ -216,76 +269,53 @@ export function LoginForm({
return;
}
- setIsLoading(true);
+ setIsFirstAuthLoading(true);
try {
- // NextAuth credentials-password provider로 로그인
- const result = await signIn('credentials-password', {
- username: username,
- password: password,
- redirect: false,
- });
+ // 1차 인증만 수행 (세션 생성 안함)
+ const authResult = await performFirstAuth(username, password, 'email');
- if (result?.ok) {
- // 로그인 1차 성공 - 바로 MFA 화면으로 전환
+ if (authResult.success) {
toast({
- title: t('loginSuccess'),
- description: '1차 인증이 완료되었습니다.',
+ title: '1차 인증 완료',
+ description: 'SMS 인증을 진행합니다.',
});
- // 모든 사용자는 MFA 필수이므로 바로 MFA 폼으로 전환
- setMfaUserId(username); // 입력받은 username 사용
- setMfaUserEmail(username); // 입력받은 username 사용 (보통 이메일)
+ // MFA 화면으로 전환
+ setTempAuthKey(authResult.tempAuthKey);
+ setMfaUserId(authResult.userId);
+ setMfaUserEmail(authResult.email);
setShowMfaForm(true);
- // 자동으로 SMS 전송
+ // 자동으로 SMS 전송 (userId 직접 전달)
setTimeout(() => {
- handleSendSms();
+ handleSendSms(authResult.userId);
}, 500);
toast({
title: 'SMS 인증 필요',
description: '등록된 전화번호로 인증번호를 전송합니다.',
});
-
- } else {
- // 로그인 실패 처리
- let errorMessage = t('invalidCredentials');
-
- if (result?.error) {
- switch (result.error) {
- case 'CredentialsSignin':
- errorMessage = t('invalidCredentials');
- break;
- case 'AccessDenied':
- errorMessage = t('accessDenied');
- break;
- default:
- errorMessage = t('defaultErrorMessage');
- }
- }
-
- toast({
- title: t('errorTitle'),
- description: errorMessage,
- variant: 'destructive',
- });
}
- } catch (error) {
- console.error('S-GIPS Login error:', error);
+ } catch (error: any) {
+ console.error('Username login error:', error);
+
+ let errorMessage = t('invalidCredentials');
+ if (error.message) {
+ errorMessage = error.message;
+ }
+
toast({
title: t('errorTitle'),
- description: t('defaultErrorMessage'),
+ description: errorMessage,
variant: 'destructive',
});
} finally {
- setIsLoading(false);
+ setIsFirstAuthLoading(false);
}
};
-
- // S-Gips 로그인 처리
- // S-Gips 로그인 처리 (간소화된 버전)
+ // S-Gips 1차 인증 처리
const handleSgipsLogin = async (e: React.FormEvent) => {
e.preventDefault();
@@ -298,73 +328,62 @@ export function LoginForm({
return;
}
- setIsLoading(true);
+ setIsFirstAuthLoading(true);
try {
- // NextAuth credentials-password provider로 로그인 (S-Gips 구분)
- const result = await signIn('credentials-password', {
- username: sgipsUsername,
- password: sgipsPassword,
- provider: 'sgips', // S-Gips 구분을 위한 추가 파라미터
- redirect: false,
- });
+ // S-Gips 1차 인증만 수행 (세션 생성 안함)
+ const authResult = await performFirstAuth(sgipsUsername, sgipsPassword, 'sgips');
- if (result?.ok) {
- // S-Gips 1차 인증 성공 - 바로 MFA 화면으로 전환
+ if (authResult.success) {
toast({
- title: t('loginSuccess'),
- description: 'S-Gips 인증이 완료되었습니다.',
+ title: 'S-Gips 인증 완료',
+ description: 'SMS 인증을 진행합니다.',
});
- // S-Gips도 MFA 필수이므로 바로 MFA 폼으로 전환
- setMfaUserId(sgipsUsername);
- setMfaUserEmail(sgipsUsername);
+ // MFA 화면으로 전환
+ setTempAuthKey(authResult.tempAuthKey);
+ setMfaUserId(authResult.userId);
+ setMfaUserEmail(authResult.email);
setShowMfaForm(true);
- // 자동으로 SMS 전송
+ // 자동으로 SMS 전송 (userId 직접 전달)
setTimeout(() => {
- handleSendSms();
+ handleSendSms(authResult.userId);
}, 500);
toast({
title: 'SMS 인증 시작',
description: 'S-Gips 등록 전화번호로 인증번호를 전송합니다.',
});
-
- } else {
- let errorMessage = t('sgipsLoginFailed');
-
- if (result?.error) {
- switch (result.error) {
- case 'CredentialsSignin':
- errorMessage = t('invalidSgipsCredentials');
- break;
- case 'AccessDenied':
- errorMessage = t('sgipsAccessDenied');
- break;
- default:
- errorMessage = t('sgipsSystemError');
- }
- }
-
- toast({
- title: t('errorTitle'),
- description: errorMessage,
- variant: 'destructive',
- });
}
- } catch (error) {
+ } catch (error: any) {
console.error('S-Gips login error:', error);
+
+ let errorMessage = t('sgipsLoginFailed');
+ if (error.message) {
+ errorMessage = error.message;
+ }
+
toast({
title: t('errorTitle'),
- description: t('sgipsSystemError'),
+ description: errorMessage,
variant: 'destructive',
});
} finally {
- setIsLoading(false);
+ setIsFirstAuthLoading(false);
}
};
+ // MFA 화면에서 뒤로 가기
+ const handleBackToLogin = () => {
+ setShowMfaForm(false);
+ setMfaToken('');
+ setTempAuthKey('');
+ setMfaUserId('');
+ setMfaUserEmail('');
+ setMfaCountdown(0);
+ };
+
return (
<div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
{/* Left Content */}
@@ -405,7 +424,7 @@ export function LoginForm({
</div>
<h1 className="text-2xl font-bold">SMS 인증</h1>
<p className="text-sm text-muted-foreground mt-2">
- {mfaUserEmail}로 로그인하셨습니다
+ {mfaUserEmail}로 1차 인증이 완료되었습니다
</p>
<p className="text-xs text-muted-foreground mt-1">
등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요
@@ -457,7 +476,7 @@ export function LoginForm({
className="h-10"
value={username}
onChange={(e) => setUsername(e.target.value)}
- disabled={isLoading}
+ disabled={isFirstAuthLoading}
/>
</div>
<div className="grid gap-2">
@@ -469,16 +488,16 @@ export function LoginForm({
className="h-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
- disabled={isLoading}
+ disabled={isFirstAuthLoading}
/>
</div>
<Button
type="submit"
className="w-full"
variant="samsung"
- disabled={isLoading || !username || !password}
+ disabled={isFirstAuthLoading || !username || !password}
>
- {isLoading ? '로그인 중...' : t('login')}
+ {isFirstAuthLoading ? '인증 중...' : t('login')}
</Button>
</form>
)}
@@ -495,7 +514,7 @@ export function LoginForm({
className="h-10"
value={sgipsUsername}
onChange={(e) => setSgipsUsername(e.target.value)}
- disabled={isLoading}
+ disabled={isFirstAuthLoading}
/>
</div>
<div className="grid gap-2">
@@ -507,16 +526,16 @@ export function LoginForm({
className="h-10"
value={sgipsPassword}
onChange={(e) => setSgipsPassword(e.target.value)}
- disabled={isLoading}
+ disabled={isFirstAuthLoading}
/>
</div>
<Button
type="submit"
className="w-full"
variant="default"
- disabled={isLoading || !sgipsUsername || !sgipsPassword}
+ disabled={isFirstAuthLoading || !sgipsUsername || !sgipsPassword}
>
- {isLoading ? 'S-Gips 인증 중...' : 'S-Gips 로그인'}
+ {isFirstAuthLoading ? 'S-Gips 인증 중...' : 'S-Gips 로그인'}
</Button>
<p className="text-xs text-muted-foreground text-center">
S-Gips 계정으로 로그인하면 자동으로 SMS 인증이 진행됩니다.
@@ -553,6 +572,7 @@ export function LoginForm({
variant="link"
className="text-green-600 hover:text-green-800 text-sm"
onClick={() => {
+ setTempAuthKey('test-temp-key');
setMfaUserId('test-user');
setMfaUserEmail('test@example.com');
setShowMfaForm(true);
@@ -572,13 +592,7 @@ export function LoginForm({
type="button"
variant="ghost"
size="sm"
- onClick={() => {
- setShowMfaForm(false);
- setMfaToken('');
- setMfaUserId('');
- setMfaUserEmail('');
- setMfaCountdown(0);
- }}
+ onClick={handleBackToLogin}
className="text-blue-600 hover:text-blue-800"
>
<ArrowLeft className="w-4 h-4 mr-1" />
@@ -595,13 +609,14 @@ export function LoginForm({
인증번호를 받지 못하셨나요?
</p>
<Button
- onClick={handleSendSms}
- disabled={isLoading || mfaCountdown > 0}
+ onClick={() => handleSendSms()}
+ disabled={isSmsLoading || mfaCountdown > 0}
variant="outline"
size="sm"
className="w-full"
+ type="button"
>
- {isLoading ? (
+ {isSmsLoading ? (
'전송 중...'
) : mfaCountdown > 0 ? (
`재전송 가능 (${mfaCountdown}초)`
@@ -641,9 +656,9 @@ export function LoginForm({
type="submit"
className="w-full"
variant="samsung"
- disabled={isLoading || mfaToken.length !== 6}
+ disabled={isMfaLoading || mfaToken.length !== 6}
>
- {isLoading ? '인증 중...' : '인증 완료'}
+ {isMfaLoading ? '인증 중...' : '인증 완료'}
</Button>
</form>
@@ -755,7 +770,7 @@ export function LoginForm({
<div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
{t("agreement")}{" "}
<Link
- href={`/${lng}/privacy`} // 개인정보처리방침만 남김
+ href={`/${lng}/privacy`}
className="underline underline-offset-4 hover:text-primary"
>
{t("privacyPolicy")}
diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx
index 30449a63..ecaf6bc3 100644
--- a/components/signup/join-form.tsx
+++ b/components/signup/join-form.tsx
@@ -39,7 +39,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useTranslation } from "@/i18n/client"
-import { createVendor, getVendorTypes } from "@/lib/vendors/service"
+import { getVendorTypes } from "@/lib/vendors/service"
import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations"
import {
Select,
@@ -70,6 +70,7 @@ import {
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import prettyBytes from "pretty-bytes"
+import { Checkbox } from "../ui/checkbox"
i18nIsoCountries.registerLocale(enLocale)
i18nIsoCountries.registerLocale(koLocale)
@@ -161,8 +162,11 @@ export function JoinForm() {
const [vendorTypes, setVendorTypes] = React.useState<VendorType[]>([])
const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true)
- // File states
- const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ // Individual file states
+ const [businessRegistrationFiles, setBusinessRegistrationFiles] = React.useState<File[]>([])
+ const [isoCertificationFiles, setIsoCertificationFiles] = React.useState<File[]>([])
+ const [creditReportFiles, setCreditReportFiles] = React.useState<File[]>([])
+ const [bankAccountFiles, setBankAccountFiles] = React.useState<File[]>([])
const [isSubmitting, setIsSubmitting] = React.useState(false)
@@ -207,7 +211,7 @@ export function JoinForm() {
representativeEmail: "",
representativePhone: "",
corporateRegistrationNumber: "",
- attachedFiles: undefined,
+ representativeWorkExpirence: false,
// contacts (no isPrimary)
contacts: [
{
@@ -220,11 +224,31 @@ export function JoinForm() {
},
mode: "onChange",
})
- 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);
+ // Custom validation for file uploads
+ const validateRequiredFiles = () => {
+ const errors = []
+
+ if (businessRegistrationFiles.length === 0) {
+ errors.push("사업자등록증을 업로드해주세요.")
+ }
+
+ if (isoCertificationFiles.length === 0) {
+ errors.push("ISO 인증서를 업로드해주세요.")
+ }
+
+ if (creditReportFiles.length === 0) {
+ errors.push("신용평가보고서를 업로드해주세요.")
+ }
+
+ if (form.watch("country") !== "KR" && bankAccountFiles.length === 0) {
+ errors.push("대금지급 통장사본을 업로드해주세요.")
+ }
+
+ return errors
+ }
+
+ const isFormValid = form.formState.isValid && validateRequiredFiles().length === 0
// Field array for contacts
const { fields: contactFields, append: addContact, remove: removeContact } =
@@ -233,36 +257,53 @@ export function JoinForm() {
name: "contacts",
})
- // Dropzone handlers
- const handleDropAccepted = (acceptedFiles: File[]) => {
- const newFiles = [...selectedFiles, ...acceptedFiles]
- setSelectedFiles(newFiles)
- form.setValue("attachedFiles", newFiles, { shouldValidate: true })
- }
- const handleDropRejected = (fileRejections: any[]) => {
- fileRejections.forEach((rej) => {
- toast({
- variant: "destructive",
- title: "File Error",
- description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`,
+ // File upload handlers
+ const createFileUploadHandler = (
+ setFiles: React.Dispatch<React.SetStateAction<File[]>>,
+ currentFiles: File[]
+ ) => ({
+ onDropAccepted: (acceptedFiles: File[]) => {
+ const newFiles = [...currentFiles, ...acceptedFiles]
+ setFiles(newFiles)
+ },
+ onDropRejected: (fileRejections: any[]) => {
+ fileRejections.forEach((rej) => {
+ toast({
+ variant: "destructive",
+ title: "File Error",
+ description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`,
+ })
})
- })
- }
- const removeFile = (index: number) => {
- const updated = [...selectedFiles]
- updated.splice(index, 1)
- setSelectedFiles(updated)
- form.setValue("attachedFiles", updated, { shouldValidate: true })
- }
+ },
+ removeFile: (index: number) => {
+ const updated = [...currentFiles]
+ updated.splice(index, 1)
+ setFiles(updated)
+ }
+ })
+
+ const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles)
+ const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles)
+ const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles)
+ const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles)
// Submit
async function onSubmit(values: CreateVendorSchema) {
+ const fileErrors = validateRequiredFiles()
+ if (fileErrors.length > 0) {
+ toast({
+ variant: "destructive",
+ title: "파일 업로드 필수",
+ description: fileErrors.join("\n"),
+ })
+ return
+ }
+
setIsSubmitting(true)
try {
- const mainFiles = values.attachedFiles
- ? Array.from(values.attachedFiles as FileList)
- : []
+ const formData = new FormData()
+ // Add vendor data
const vendorData = {
vendorName: values.vendorName,
vendorTypeId: values.vendorTypeId,
@@ -279,16 +320,40 @@ export function JoinForm() {
representativeBirth: values.representativeBirth || "",
representativeEmail: values.representativeEmail || "",
representativePhone: values.representativePhone || "",
- corporateRegistrationNumber: values.corporateRegistrationNumber || ""
+ corporateRegistrationNumber: values.corporateRegistrationNumber || "",
+ representativeWorkExpirence: values.representativeWorkExpirence || false
+ }
+
+ formData.append('vendorData', JSON.stringify(vendorData))
+ formData.append('contacts', JSON.stringify(values.contacts))
+
+ // Add files with specific types
+ businessRegistrationFiles.forEach(file => {
+ formData.append('businessRegistration', file)
+ })
+
+ isoCertificationFiles.forEach(file => {
+ formData.append('isoCertification', file)
+ })
+
+ creditReportFiles.forEach(file => {
+ formData.append('creditReport', file)
+ })
+
+ if (values.country !== "KR") {
+ bankAccountFiles.forEach(file => {
+ formData.append('bankAccount', file)
+ })
}
- const result = await createVendor({
- vendorData,
- files: mainFiles,
- contacts: values.contacts,
+ const response = await fetch('/api/vendors', {
+ method: 'POST',
+ body: formData,
})
- if (!result.error) {
+ const result = await response.json()
+
+ if (response.ok) {
toast({
title: "등록 완료",
description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)",
@@ -340,7 +405,7 @@ export function JoinForm() {
}
};
- const getPhoneDescription = (countryCode: string) => {
+ const getPhoneDescription = (countryCode: string) => {
if (!countryCode) return "국가를 먼저 선택해주세요.";
const dialCode = countryDialCodes[countryCode];
@@ -359,7 +424,84 @@ export function JoinForm() {
return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`;
}
};
-
+
+ // File display component
+ const FileUploadSection = ({
+ title,
+ description,
+ files,
+ onDropAccepted,
+ onDropRejected,
+ removeFile,
+ required = true
+ }: {
+ title: string;
+ description: string;
+ files: File[];
+ onDropAccepted: (files: File[]) => void;
+ onDropRejected: (rejections: any[]) => void;
+ removeFile: (index: number) => void;
+ required?: boolean;
+ }) => (
+ <div className="space-y-4">
+ <div>
+ <h5 className="text-sm font-medium">
+ {title}
+ {required && <span className="text-red-500 ml-1">*</span>}
+ </h5>
+ <p className="text-xs text-muted-foreground mt-1">{description}</p>
+ </div>
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ multiple
+ onDropAccepted={onDropAccepted}
+ onDropRejected={onDropRejected}
+ disabled={isSubmitting}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-4">
+ <DropzoneUploadIcon />
+ <div className="grid gap-1">
+ <DropzoneTitle>파일 업로드</DropzoneTitle>
+ <DropzoneDescription>
+ 드래그 또는 클릭
+ {maxSize ? ` (최대: ${prettyBytes(maxSize)})` : null}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {files.length > 0 && (
+ <div className="mt-2">
+ <ScrollArea className="max-h-32">
+ <FileList className="gap-2">
+ {files.map((file, i) => (
+ <FileListItem key={file.name + i}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {prettyBytes(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeFile(i)}>
+ <X className="h-4 w-4" />
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </ScrollArea>
+ </div>
+ )}
+ </div>
+ )
// Render
return (
@@ -391,7 +533,7 @@ export function JoinForm() {
<div className="rounded-md border p-4 space-y-4">
<h4 className="text-md font-semibold">기본 정보</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- {/* Vendor Type - New Field */}
+ {/* Vendor Type */}
<FormField
control={form.control}
name="vendorTypeId"
@@ -481,7 +623,7 @@ export function JoinForm() {
)}
/>
- {/* Items - New Field */}
+ {/* Items */}
<FormField
control={form.control}
name="items"
@@ -516,7 +658,7 @@ export function JoinForm() {
)}
/>
- {/* Country - Updated with enhanced list */}
+ {/* Country */}
<FormField
control={form.control}
name="country"
@@ -583,8 +725,7 @@ export function JoinForm() {
)
}}
/>
-
- {/* Phone - Updated with country code hint */}
+ {/* Phone */}
<FormField
control={form.control}
name="phone"
@@ -611,7 +752,7 @@ export function JoinForm() {
)}
/>
- {/* Email - Updated with company domain guidance */}
+ {/* Email */}
<FormField
control={form.control}
name="email"
@@ -679,7 +820,7 @@ export function JoinForm() {
className="bg-muted/10 rounded-md p-4 space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
- {/* contactName - All required now */}
+ {/* contactName */}
<FormField
control={form.control}
name={`contacts.${index}.contactName`}
@@ -696,7 +837,7 @@ export function JoinForm() {
)}
/>
- {/* contactPosition - Now required */}
+ {/* contactPosition */}
<FormField
control={form.control}
name={`contacts.${index}.contactPosition`}
@@ -730,7 +871,7 @@ export function JoinForm() {
)}
/>
- {/* contactPhone - Now required */}
+ {/* contactPhone */}
<FormField
control={form.control}
name={`contacts.${index}.contactPhone`}
@@ -777,7 +918,6 @@ export function JoinForm() {
<div className="rounded-md border p-4 space-y-4">
<h4 className="text-md font-semibold">한국 사업자 정보</h4>
- {/* 대표자 등... all now required for Korean companies */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
@@ -858,78 +998,89 @@ export function JoinForm() {
</FormItem>
)}
/>
+
+<FormField
+ control={form.control}
+ name="representativeWorkExpirence"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ 대표자 삼성중공업 근무이력
+ </FormLabel>
+ <FormDescription>
+ 대표자가 삼성중공업에서 근무한 경험이 있는 경우 체크해주세요.
+ </FormDescription>
+ </div>
+ </FormItem>
+ )}
+ />
+
</div>
</div>
)}
{/* ─────────────────────────────────────────
- 첨부파일 (사업자등록증 등)
+ Required Document Uploads
───────────────────────────────────────── */}
- <div className="rounded-md border p-4 space-y-4">
- <h4 className="text-md font-semibold">기타 첨부파일</h4>
- <FormField
- control={form.control}
- name="attachedFiles"
- render={() => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 첨부 파일
- </FormLabel>
- <FormDescription>
- 사업자등록증, ISO 9001 인증서, 회사 브로셔, 기본 소개자료 등을 첨부해주세요.
- </FormDescription>
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- multiple
- onDropAccepted={handleDropAccepted}
- onDropRejected={handleDropRejected}
- disabled={isSubmitting}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-4">
- <DropzoneUploadIcon />
- <div className="grid gap-1">
- <DropzoneTitle>파일 업로드</DropzoneTitle>
- <DropzoneDescription>
- 드래그 또는 클릭
- {maxSize
- ? ` (최대: ${prettyBytes(maxSize)})`
- : null}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
- {selectedFiles.length > 0 && (
- <div className="mt-2">
- <ScrollArea className="max-h-32">
- <FileList className="gap-2">
- {selectedFiles.map((file, i) => (
- <FileListItem key={file.name + i}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.name}</FileListName>
- <FileListDescription>
- {prettyBytes(file.size)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction onClick={() => removeFile(i)}>
- <X className="h-4 w-4" />
- </FileListAction>
- </FileListHeader>
- </FileListItem>
- ))}
- </FileList>
- </ScrollArea>
- </div>
- )}
- </FormItem>
- )}
+ <div className="rounded-md border p-4 space-y-6">
+ <h4 className="text-md font-semibold">필수 첨부 서류</h4>
+
+ {/* Business Registration */}
+ <FileUploadSection
+ title="사업자등록증"
+ description="사업자등록증 스캔본 또는 사진을 업로드해주세요. 모든 내용이 선명하게 보여야 합니다."
+ files={businessRegistrationFiles}
+ onDropAccepted={businessRegistrationHandler.onDropAccepted}
+ onDropRejected={businessRegistrationHandler.onDropRejected}
+ removeFile={businessRegistrationHandler.removeFile}
/>
+
+ <Separator />
+
+ {/* ISO Certification */}
+ <FileUploadSection
+ title="ISO 인증서"
+ description="ISO 9001, ISO 14001 등 품질/환경 관리 인증서를 업로드해주세요. 유효기간이 확인 가능해야 합니다."
+ files={isoCertificationFiles}
+ onDropAccepted={isoCertificationHandler.onDropAccepted}
+ onDropRejected={isoCertificationHandler.onDropRejected}
+ removeFile={isoCertificationHandler.removeFile}
+ />
+
+ <Separator />
+
+ {/* Credit Report */}
+ <FileUploadSection
+ title="신용평가보고서"
+ description="신용평가기관(KIS, NICE 등)에서 발급한 발행 1년 이내의 신용평가보고서를 업로드해주세요. 전년도 재무제표 필수표시. 신규업체, 영세업체로 재무제표 및 신용평가 결과가 없을 경우는 국세, 지방세 납입 증명으로 신용평가를 갈음할 수 있음"
+ files={creditReportFiles}
+ onDropAccepted={creditReportHandler.onDropAccepted}
+ onDropRejected={creditReportHandler.onDropRejected}
+ removeFile={creditReportHandler.removeFile}
+ />
+
+ {/* Bank Account Copy - Only for non-Korean companies */}
+ {form.watch("country") !== "KR" && (
+ <>
+ <Separator />
+ <FileUploadSection
+ title="대금지급 통장사본"
+ description="대금 지급용 은행 계좌의 통장 사본 또는 계좌증명서를 업로드해주세요. 계좌번호와 예금주명이 명확히 보여야 합니다."
+ files={bankAccountFiles}
+ onDropAccepted={bankAccountHandler.onDropAccepted}
+ onDropRejected={bankAccountHandler.onDropRejected}
+ removeFile={bankAccountHandler.removeFile}
+ />
+ </>
+ )}
</div>
{/* ─────────────────────────────────────────
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
new file mode 100644
index 00000000..90b28176
--- /dev/null
+++ b/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx
@@ -0,0 +1,102 @@
+"use client"
+
+import * as React from "react"
+import { useRouter, usePathname, useSearchParams } from "next/navigation"
+import { ChevronDown } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+interface VendorType {
+ id: string;
+ name: string;
+ value: string;
+}
+
+interface TechVendorPossibleItemsContainerProps {
+ vendorTypes: VendorType[];
+ children: React.ReactNode;
+}
+
+export function TechVendorPossibleItemsContainer({
+ vendorTypes,
+ children,
+}: TechVendorPossibleItemsContainerProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParamsObj = useSearchParams();
+
+ // useSearchParams를 메모이제이션하여 안정적인 참조 생성
+ const searchParams = React.useMemo(
+ () => searchParamsObj || new URLSearchParams(),
+ [searchParamsObj]
+ );
+
+ // URL에서 현재 선택된 벤더 타입 가져오기
+ const vendorType = searchParams.get("vendorType") || "all";
+
+ // 선택한 벤더 타입에 해당하는 이름 찾기
+ const selectedVendor = vendorTypes.find((vendor) => vendor.id === vendorType)?.name || "전체";
+
+ // 벤더 타입 변경 핸들러
+ const handleVendorTypeChange = React.useCallback((value: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value === "all") {
+ params.delete("vendorType");
+ } else {
+ params.set("vendorType", value);
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [router, pathname, searchParams]);
+
+
+
+ return (
+ <>
+ {/* 상단 영역: 제목 왼쪽 / 벤더 타입 선택기 오른쪽 */}
+ <div className="flex items-center justify-between">
+ {/* 왼쪽: 타이틀 & 설명 */}
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 아이템 관리</h2>
+ <p className="text-muted-foreground">
+ 기술영업 벤더별 가능 아이템을 관리합니다.
+ </p>
+ </div>
+
+ {/* 오른쪽: 벤더 타입 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" className="min-w-[150px]">
+ {selectedVendor}
+ <ChevronDown className="ml-2 h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ {vendorTypes.map((vendor) => (
+ <DropdownMenuItem
+ key={vendor.id}
+ onClick={() => handleVendorTypeChange(vendor.id)}
+ className={vendor.id === vendorType ? "bg-muted" : ""}
+ >
+ {vendor.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {/* 컨텐츠 영역 */}
+ <section className="overflow-hidden">
+ <div>
+ {children}
+ </div>
+ </section>
+ </>
+ );
+} \ No newline at end of file
diff --git a/components/ui/file-actions.tsx b/components/ui/file-actions.tsx
new file mode 100644
index 00000000..ed2103d3
--- /dev/null
+++ b/components/ui/file-actions.tsx
@@ -0,0 +1,440 @@
+// components/ui/file-actions.tsx
+// 재사용 가능한 파일 액션 컴포넌트들
+
+"use client";
+
+import * as React from "react";
+import {
+ Download,
+ Eye,
+ Paperclip,
+ Loader2,
+ AlertCircle,
+ FileText,
+ Image as ImageIcon,
+ Archive
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+import { useMultiFileDownload } from "@/hooks/use-file-download";
+import { getFileInfo, quickDownload, quickPreview, smartFileAction } from "@/lib/file-download";
+import { cn } from "@/lib/utils";
+
+/**
+ * 파일 아이콘 컴포넌트
+ */
+interface FileIconProps {
+ fileName: string;
+ className?: string;
+}
+
+export const FileIcon: React.FC<FileIconProps> = ({ fileName, className }) => {
+ const fileInfo = getFileInfo(fileName);
+
+ const iconMap = {
+ pdf: FileText,
+ document: FileText,
+ spreadsheet: FileText,
+ image: ImageIcon,
+ archive: Archive,
+ other: Paperclip,
+ };
+
+ const IconComponent = iconMap[fileInfo.type];
+
+ return (
+ <IconComponent className={cn("h-4 w-4", className)} />
+ );
+};
+
+/**
+ * 기본 파일 다운로드 버튼
+ */
+interface FileDownloadButtonProps {
+ filePath: string;
+ fileName: string;
+ variant?: "default" | "ghost" | "outline";
+ size?: "default" | "sm" | "lg" | "icon";
+ children?: React.ReactNode;
+ className?: string;
+ showIcon?: boolean;
+ disabled?: boolean;
+}
+
+export const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({
+ filePath,
+ fileName,
+ variant = "ghost",
+ size = "icon",
+ children,
+ className,
+ showIcon = true,
+ disabled,
+}) => {
+ const { downloadFile, isFileLoading, getFileError } = useMultiFileDownload();
+
+ const isLoading = isFileLoading(filePath);
+ const error = getFileError(filePath);
+
+ const handleClick = () => {
+ if (!disabled && !isLoading) {
+ quickDownload(filePath, fileName);
+ }
+ };
+
+ if (isLoading) {
+ return (
+ <Button variant={variant} size={size} disabled className={className}>
+ <Loader2 className="h-4 w-4 animate-spin" />
+ {children}
+ </Button>
+ );
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={variant}
+ size={size}
+ onClick={handleClick}
+ disabled={disabled}
+ className={cn(
+ error && "text-destructive hover:text-destructive",
+ className
+ )}
+ >
+ {error ? (
+ <AlertCircle className="h-4 w-4" />
+ ) : showIcon ? (
+ <Download className="h-4 w-4" />
+ ) : null}
+ {children}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {error ? `오류: ${error} (클릭하여 재시도)` : `${fileName} 다운로드`}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+};
+
+/**
+ * 미리보기 버튼
+ */
+interface FilePreviewButtonProps extends Omit<FileDownloadButtonProps, 'children'> {
+ fallbackToDownload?: boolean;
+}
+
+export const FilePreviewButton: React.FC<FilePreviewButtonProps> = ({
+ filePath,
+ fileName,
+ variant = "ghost",
+ size = "icon",
+ className,
+ fallbackToDownload = true,
+ disabled,
+}) => {
+ const { isFileLoading, getFileError } = useMultiFileDownload();
+ const fileInfo = getFileInfo(fileName);
+
+ const isLoading = isFileLoading(filePath);
+ const error = getFileError(filePath);
+
+ const handleClick = () => {
+ if (!disabled && !isLoading) {
+ if (fileInfo.canPreview) {
+ quickPreview(filePath, fileName);
+ } else if (fallbackToDownload) {
+ quickDownload(filePath, fileName);
+ }
+ }
+ };
+
+ if (!fileInfo.canPreview && !fallbackToDownload) {
+ return (
+ <Button variant={variant} size={size} disabled className={className}>
+ <Eye className="h-4 w-4 opacity-50" />
+ </Button>
+ );
+ }
+
+ if (isLoading) {
+ return (
+ <Button variant={variant} size={size} disabled className={className}>
+ <Loader2 className="h-4 w-4 animate-spin" />
+ </Button>
+ );
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={variant}
+ size={size}
+ onClick={handleClick}
+ disabled={disabled}
+ className={cn(
+ error && "text-destructive hover:text-destructive",
+ className
+ )}
+ >
+ {error ? (
+ <AlertCircle className="h-4 w-4" />
+ ) : fileInfo.canPreview ? (
+ <Eye className="h-4 w-4" />
+ ) : (
+ <Download className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {error
+ ? `오류: ${error} (클릭하여 재시도)`
+ : fileInfo.canPreview
+ ? `${fileName} 미리보기`
+ : `${fileName} 다운로드`
+ }
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+};
+
+/**
+ * 드롭다운 파일 액션 버튼 (미리보기 + 다운로드)
+ */
+interface FileActionsDropdownProps {
+ filePath: string;
+ fileName: string;
+ description?: string;
+ variant?: "default" | "ghost" | "outline";
+ size?: "default" | "sm" | "lg" | "icon";
+ className?: string;
+ disabled?: boolean;
+ triggerIcon?: React.ReactNode;
+}
+
+export const FileActionsDropdown: React.FC<FileActionsDropdownProps> = ({
+ filePath,
+ fileName,
+ variant = "ghost",
+ size = "icon",
+ className,
+ disabled,
+ triggerIcon,
+ description
+}) => {
+ const { isFileLoading, getFileError } = useMultiFileDownload();
+ const fileInfo = getFileInfo(fileName);
+
+ const isLoading = isFileLoading(filePath);
+ const error = getFileError(filePath);
+
+ const handlePreview = () => quickPreview(filePath, fileName);
+ const handleDownload = () => quickDownload(filePath, fileName);
+
+ if (isLoading) {
+ return (
+ <Button variant={variant} size={size} disabled className={className}>
+ <Loader2 className="h-4 w-4 animate-spin" />
+ </Button>
+ );
+ }
+
+ if (error) {
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={variant}
+ size={size}
+ onClick={handleDownload}
+ className={cn("text-destructive hover:text-destructive", className)}
+ >
+ <AlertCircle className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-sm">
+ <div className="font-medium text-destructive">오류 발생</div>
+ <div className="text-muted-foreground">{error}</div>
+ <div className="mt-1 text-xs">클릭하여 재시도</div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ }
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant={variant}
+ size={size}
+ disabled={disabled}
+ className={className}
+ >
+ {triggerIcon || <Paperclip className="h-4 w-4" />}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {fileInfo.canPreview && (
+ <>
+ <DropdownMenuItem onClick={handlePreview}>
+ <Eye className="mr-2 h-4 w-4" />
+ {fileInfo.icon} 미리보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ </>
+ )}
+ <DropdownMenuItem onClick={handleDownload}>
+ <Download className="mr-2 h-4 w-4" />
+ {description} 다운로드
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+};
+
+/**
+ * 스마트 파일 액션 버튼 (자동 판단)
+ */
+interface SmartFileActionButtonProps extends Omit<FileDownloadButtonProps, 'children'> {
+ showLabel?: boolean;
+}
+
+export const SmartFileActionButton: React.FC<SmartFileActionButtonProps> = ({
+ filePath,
+ fileName,
+ variant = "ghost",
+ size = "icon",
+ className,
+ showLabel = false,
+ disabled,
+}) => {
+ const { isFileLoading, getFileError } = useMultiFileDownload();
+ const fileInfo = getFileInfo(fileName);
+
+ const isLoading = isFileLoading(filePath);
+ const error = getFileError(filePath);
+
+ const handleClick = () => {
+ if (!disabled && !isLoading) {
+ smartFileAction(filePath, fileName);
+ }
+ };
+
+ if (isLoading) {
+ return (
+ <Button variant={variant} size={size} disabled className={className}>
+ <Loader2 className="h-4 w-4 animate-spin" />
+ {showLabel && <span className="ml-2">처리 중...</span>}
+ </Button>
+ );
+ }
+
+ const actionText = fileInfo.canPreview ? '미리보기' : '다운로드';
+ const IconComponent = fileInfo.canPreview ? Eye : Download;
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={variant}
+ size={size}
+ onClick={handleClick}
+ disabled={disabled}
+ className={cn(
+ error && "text-destructive hover:text-destructive",
+ className
+ )}
+ >
+ {error ? (
+ <AlertCircle className="h-4 w-4" />
+ ) : (
+ <IconComponent className="h-4 w-4" />
+ )}
+ {showLabel && (
+ <span className="ml-2">
+ {error ? '재시도' : actionText}
+ </span>
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {error
+ ? `오류: ${error} (클릭하여 재시도)`
+ : `${fileInfo.icon} ${fileName} ${actionText}`
+ }
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+};
+
+/**
+ * 파일명 링크 컴포넌트
+ */
+interface FileNameLinkProps {
+ filePath: string;
+ fileName: string;
+ className?: string;
+ showIcon?: boolean;
+ maxLength?: number;
+}
+
+export const FileNameLink: React.FC<FileNameLinkProps> = ({
+ filePath,
+ fileName,
+ className,
+ showIcon = true,
+ maxLength = 200,
+}) => {
+ const fileInfo = getFileInfo(fileName);
+
+ const handleClick = () => {
+ smartFileAction(filePath, fileName);
+ };
+
+ const displayName = fileName.length > maxLength
+ ? `${fileName.substring(0, maxLength)}...`
+ : fileName;
+
+ return (
+ <button
+ onClick={handleClick}
+ className={cn(
+ "flex items-center gap-1 text-blue-600 hover:text-blue-800 hover:underline cursor-pointer text-left",
+ className
+ )}
+ title={`${fileInfo.icon} ${fileName} ${fileInfo.canPreview ? '미리보기' : '다운로드'}`}
+ >
+ {showIcon && (
+ <span className="text-xs flex-shrink-0">{fileInfo.icon}</span>
+ )}
+ <span className="truncate">{displayName}</span>
+ </button>
+ );
+}; \ No newline at end of file
diff --git a/components/ui/text-utils.tsx b/components/ui/text-utils.tsx
new file mode 100644
index 00000000..a3507dd0
--- /dev/null
+++ b/components/ui/text-utils.tsx
@@ -0,0 +1,131 @@
+"use client"
+
+import { useState } from "react"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { ChevronDown, ChevronUp } from "lucide-react"
+
+export function TruncatedText({
+ text,
+ maxLength = 50,
+ showTooltip = true
+}: {
+ text: string | null
+ maxLength?: number
+ showTooltip?: boolean
+}) {
+ if (!text) return <span className="text-muted-foreground">-</span>
+
+ if (text.length <= maxLength) {
+ return <span>{text}</span>
+ }
+
+ const truncated = text.slice(0, maxLength) + "..."
+
+ if (!showTooltip) {
+ return <span>{truncated}</span>
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="cursor-help border-b border-dotted border-gray-400">
+ {truncated}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent className="max-w-xs">
+ <p className="whitespace-pre-wrap">{text}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+}
+
+export function ExpandableText({
+ text,
+ maxLength = 100,
+ className = ""
+}: {
+ text: string | null
+ maxLength?: number
+ className?: string
+}) {
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ if (!text) return <span className="text-muted-foreground">-</span>
+
+ if (text.length <= maxLength) {
+ return <span className={className}>{text}</span>
+ }
+
+ return (
+ <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
+ <div className={className}>
+ <CollapsibleTrigger asChild>
+ <button className="text-left w-full group">
+ <span className="whitespace-pre-wrap">
+ {isExpanded ? text : text.slice(0, maxLength) + "..."}
+ </span>
+ <span className="inline-flex items-center ml-2 text-blue-600 hover:text-blue-800">
+ {isExpanded ? (
+ <>
+ <ChevronUp className="w-3 h-3" />
+ <span className="text-xs ml-1">접기</span>
+ </>
+ ) : (
+ <>
+ <ChevronDown className="w-3 h-3" />
+ <span className="text-xs ml-1">더보기</span>
+ </>
+ )}
+ </span>
+ </button>
+ </CollapsibleTrigger>
+ </div>
+ </Collapsible>
+ )
+}
+
+export function AddressDisplay({
+ address,
+ addressEng,
+ postalCode,
+ addressDetail
+}: {
+ address: string | null
+ addressEng: string | null
+ postalCode: string | null
+ addressDetail: string | null
+}) {
+ const hasAnyAddress = address || addressEng || postalCode || addressDetail
+
+ if (!hasAnyAddress) {
+ return <span className="text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="space-y-1">
+ {postalCode && (
+ <div className="text-xs text-muted-foreground">
+ 우편번호: {postalCode}
+ </div>
+ )}
+ {address && (
+ <div className="font-medium break-words">
+ {address}
+ </div>
+ )}
+ {addressDetail && (
+ <div className="text-sm text-muted-foreground break-words">
+ {addressDetail}
+ </div>
+ )}
+ {addressEng && (
+ <div className="text-sm text-muted-foreground break-words italic">
+ {addressEng}
+ </div>
+ )}
+ </div>
+ )
+} \ No newline at end of file