diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:43:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:43:36 +0000 |
| commit | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (patch) | |
| tree | 343247117a7587b8ef5c418c9528d1cf2e0b6f1c /components | |
| parent | 9945ad119686a4c3a66f7b57782750f78a366cfb (diff) | |
(대표님) 변경사항 20250707 10시 43분
Diffstat (limited to 'components')
| -rw-r--r-- | components/BidProjectSelector.tsx | 10 | ||||
| -rw-r--r-- | components/data-table/data-table-grobal-filter.tsx | 1 | ||||
| -rw-r--r-- | components/data-table/data-table-sort-list.tsx | 45 | ||||
| -rw-r--r-- | components/data-table/data-table.tsx | 40 | ||||
| -rw-r--r-- | components/form-data/form-data-report-temp-upload-dialog.tsx | 112 | ||||
| -rw-r--r-- | components/form-data/temp-download-btn.tsx | 46 | ||||
| -rw-r--r-- | components/information/information-button.tsx | 189 | ||||
| -rw-r--r-- | components/login/login-form-shi.tsx | 14 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 287 | ||||
| -rw-r--r-- | components/signup/join-form.tsx | 379 | ||||
| -rw-r--r-- | components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx | 102 | ||||
| -rw-r--r-- | components/ui/file-actions.tsx | 440 | ||||
| -rw-r--r-- | components/ui/text-utils.tsx | 131 |
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 |
