From fbb3b7f05737f9571b04b0a8f4f15c0928de8545 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 01:43:36 +0000 Subject: (대표님) 변경사항 20250707 10시 43분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/BidProjectSelector.tsx | 10 +- components/data-table/data-table-grobal-filter.tsx | 1 - components/data-table/data-table-sort-list.tsx | 45 ++- components/data-table/data-table.tsx | 40 +- .../form-data-report-temp-upload-dialog.tsx | 112 +++--- components/form-data/temp-download-btn.tsx | 46 --- components/information/information-button.tsx | 189 +++++---- components/login/login-form-shi.tsx | 14 +- components/login/login-form.tsx | 287 +++++++------- components/signup/join-form.tsx | 379 ++++++++++++------ .../tech-vendor-possible-items-container.tsx | 102 +++++ components/ui/file-actions.tsx | 440 +++++++++++++++++++++ components/ui/text-utils.tsx | 131 ++++++ 13 files changed, 1322 insertions(+), 474 deletions(-) delete mode 100644 components/form-data/temp-download-btn.tsx create mode 100644 components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx create mode 100644 components/ui/file-actions.tsx create mode 100644 components/ui/text-utils.tsx (limited to 'components') 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 { shallow?: boolean } +let renderCount = 0; + export function DataTableSortList({ table, debounceMs, shallow, }: DataTableSortListProps) { + renderCount++; + const id = React.useId() const initialSorting = (table.initialState.sorting ?? []) as ExtendedSortingState + // ✅ 파서를 안정화 - 한 번만 생성되도록 수정 + 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({ }) ) + // ✅ debouncedSetSorting - 컴포넌트 최상위로 이동 + const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs); + + // ✅ uniqueSorting 메모이제이션 const uniqueSorting = React.useMemo( () => sorting.filter( @@ -82,8 +97,7 @@ export function DataTableSortList({ [sorting] ) - const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs) - + // ✅ sortableColumns 메모이제이션 const sortableColumns = React.useMemo( () => table @@ -100,7 +114,8 @@ export function DataTableSortList({ [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({ desc: false, }, ]) - } + }, [sortableColumns, sorting, setSorting]); - function updateSort({ + const updateSort = React.useCallback(({ id, field, debounced = false, @@ -123,7 +138,7 @@ export function DataTableSortList({ id: string field: Partial> debounced?: boolean - }) { + }) => { const updateFunction = debounced ? debouncedSetSorting : setSorting updateFunction((prevSorting) => { @@ -134,13 +149,17 @@ export function DataTableSortList({ ) 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 ( ({ ) -} +} \ 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 extends React.HTMLAttributes { 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({ 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 (
@@ -62,7 +74,7 @@ export function DataTable({ {/* 테이블 헤더 */} {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { if (header.column.getIsGrouped()) { return null @@ -73,7 +85,7 @@ export function DataTable({ 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 ( - - - - Vendor Document Template - - {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 + return ( + + + + Vendor Document Template + + {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 하여주시기 바랍니다. */} - - - - - -
- - setTabValue("upload")} - className="flex-1" - > - Upload Template File - - setTabValue("uploaded")} - className="flex-1" - > - Uploaded Template File List - - -
- - - - - - -
-
-
- ); -}; \ No newline at end of file + + +
+
+ +
+ + setTabValue("upload")} + className="flex-1" + > + Upload Template File + + setTabValue("uploaded")} + className="flex-1" + > + Uploaded Template File List + + +
+ + + + + + +
+
+
+ ); + }; \ 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 ( - - ); -}; \ 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(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({
-
- {/* 공지사항 섹션 */} - {notices.length > 0 && ( -
-
-

공지사항

- {notices.length}개 -
-
-
- {notices.map((notice) => ( -
handleNoticeClick(notice)} - > -
-
- {notice.title} -
-
- {formatDate(notice.createdAt)} - {notice.authorName && ( - {notice.authorName} - )} +
+ {isLoading ? ( +
+ + 정보를 불러오는 중... +
+ ) : ( +
+ {/* 공지사항 섹션 */} +
+
+

공지사항

+ {notices.length > 0 && ( + {notices.length}개 + )} +
+ {notices.length > 0 ? ( +
+
+ {notices.map((notice) => ( +
handleNoticeClick(notice)} + > +
+
+ {notice.title} +
+
+ {formatDate(notice.createdAt)} + {notice.authorName && ( + {notice.authorName} + )} +
+
-
+ ))}
- ))} -
+
+ ) : ( +
+
+ 공지사항이 없습니다 +
+
+ )}
-
- )} - {/* 인포메이션 컨텐츠 */} - {information?.informationContent && ( -
-
-

안내사항

- {hasEditPermission && information && ( - - )} -
-
-
- {information.informationContent} + {/* 인포메이션 컨텐츠 */} +
+
+

안내사항

+ {hasEditPermission && information && ( + + )} +
+
+ {information?.informationContent ? ( +
+ {information.informationContent} +
+ ) : ( +
+ 안내사항이 없습니다 +
+ )}
-
- )} - {/* 첨부파일 */} - {information?.attachmentFileName && ( -
-

첨부파일

-
-
-
-
- {information.attachmentFileName} -
- {information.attachmentFileSize && ( -
- {information.attachmentFileSize} + {/* 첨부파일 */} +
+

첨부파일

+
+ {information?.attachmentFileName ? ( +
+
+
+ {information.attachmentFileName} +
+ {information.attachmentFileSize && ( +
+ {information.attachmentFileSize} +
+ )}
- )} -
- + +
+ ) : ( +
+ 첨부파일이 없습니다 +
+ )}
)} - - {!information && notices.length === 0 && ( -
-

이 페이지에 대한 정보가 없습니다.

-
- )}
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({
{/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - // ( */} -
+ {/* {!otpSent ? ( */} + + {/* */}
@@ -269,7 +269,7 @@ export function LoginFormSHI({
- ) + {/* ) : ( @@ -323,7 +323,7 @@ export function LoginFormSHI({
- )} + )} */}
{t('termsMessage')} {t('termsOfService')} {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('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 (
{/* Left Content */} @@ -405,7 +424,7 @@ export function LoginForm({

SMS 인증

- {mfaUserEmail}로 로그인하셨습니다 + {mfaUserEmail}로 1차 인증이 완료되었습니다

등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요 @@ -457,7 +476,7 @@ export function LoginForm({ className="h-10" value={username} onChange={(e) => setUsername(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} />

@@ -469,16 +488,16 @@ export function LoginForm({ className="h-10" value={password} onChange={(e) => setPassword(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} />
)} @@ -495,7 +514,7 @@ export function LoginForm({ className="h-10" value={sgipsUsername} onChange={(e) => setSgipsUsername(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} />
@@ -507,16 +526,16 @@ export function LoginForm({ className="h-10" value={sgipsPassword} onChange={(e) => setSgipsPassword(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} />

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" > @@ -595,13 +609,14 @@ export function LoginForm({ 인증번호를 받지 못하셨나요?

@@ -755,7 +770,7 @@ export function LoginForm({
{t("agreement")}{" "} {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([]) const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true) - // File states - const [selectedFiles, setSelectedFiles] = React.useState([]) + // Individual file states + const [businessRegistrationFiles, setBusinessRegistrationFiles] = React.useState([]) + const [isoCertificationFiles, setIsoCertificationFiles] = React.useState([]) + const [creditReportFiles, setCreditReportFiles] = React.useState([]) + const [bankAccountFiles, setBankAccountFiles] = React.useState([]) 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>, + 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; + }) => ( +
+
+
+ {title} + {required && *} +
+

{description}

+
+ + + {({ maxSize }) => ( + + +
+ +
+ 파일 업로드 + + 드래그 또는 클릭 + {maxSize ? ` (최대: ${prettyBytes(maxSize)})` : null} + +
+
+
+ )} +
+ + {files.length > 0 && ( +
+ + + {files.map((file, i) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(i)}> + + + + + ))} + + +
+ )} +
+ ) // Render return ( @@ -391,7 +533,7 @@ export function JoinForm() {

기본 정보

- {/* Vendor Type - New Field */} + {/* Vendor Type */} - {/* Items - New Field */} + {/* Items */} - {/* Country - Updated with enhanced list */} + {/* Country */} - - {/* Phone - Updated with country code hint */} + {/* Phone */} - {/* Email - Updated with company domain guidance */} + {/* Email */}
- {/* contactName - All required now */} + {/* contactName */} - {/* contactPosition - Now required */} + {/* contactPosition */} - {/* contactPhone - Now required */} + {/* contactPhone */}

한국 사업자 정보

- {/* 대표자 등... all now required for Korean companies */}
)} /> + + ( + + + + +
+ + 대표자 삼성중공업 근무이력 + + + 대표자가 삼성중공업에서 근무한 경험이 있는 경우 체크해주세요. + +
+
+ )} + /> +
)} {/* ───────────────────────────────────────── - 첨부파일 (사업자등록증 등) + Required Document Uploads ───────────────────────────────────────── */} -
-

기타 첨부파일

- ( - - - 첨부 파일 - - - 사업자등록증, ISO 9001 인증서, 회사 브로셔, 기본 소개자료 등을 첨부해주세요. - - - {({ maxSize }) => ( - - -
- -
- 파일 업로드 - - 드래그 또는 클릭 - {maxSize - ? ` (최대: ${prettyBytes(maxSize)})` - : null} - -
-
-
- )} -
- {selectedFiles.length > 0 && ( -
- - - {selectedFiles.map((file, i) => ( - - - - - {file.name} - - {prettyBytes(file.size)} - - - removeFile(i)}> - - - - - ))} - - -
- )} -
- )} +
+

필수 첨부 서류

+ + {/* Business Registration */} + + + + + {/* ISO Certification */} + + + + + {/* Credit Report */} + + + {/* Bank Account Copy - Only for non-Korean companies */} + {form.watch("country") !== "KR" && ( + <> + + + + )}
{/* ───────────────────────────────────────── 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 ( + <> + {/* 상단 영역: 제목 왼쪽 / 벤더 타입 선택기 오른쪽 */} +
+ {/* 왼쪽: 타이틀 & 설명 */} +
+

기술영업 벤더 아이템 관리

+

+ 기술영업 벤더별 가능 아이템을 관리합니다. +

+
+ + {/* 오른쪽: 벤더 타입 드롭다운 */} + + + + + + {vendorTypes.map((vendor) => ( + handleVendorTypeChange(vendor.id)} + className={vendor.id === vendorType ? "bg-muted" : ""} + > + {vendor.name} + + ))} + + +
+ + {/* 컨텐츠 영역 */} +
+
+ {children} +
+
+ + ); +} \ 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 = ({ 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 ( + + ); +}; + +/** + * 기본 파일 다운로드 버튼 + */ +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 = ({ + 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 ( + + ); + } + + return ( + + + + + + + {error ? `오류: ${error} (클릭하여 재시도)` : `${fileName} 다운로드`} + + + + ); +}; + +/** + * 미리보기 버튼 + */ +interface FilePreviewButtonProps extends Omit { + fallbackToDownload?: boolean; +} + +export const FilePreviewButton: React.FC = ({ + 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 ( + + ); + } + + if (isLoading) { + return ( + + ); + } + + return ( + + + + + + + {error + ? `오류: ${error} (클릭하여 재시도)` + : fileInfo.canPreview + ? `${fileName} 미리보기` + : `${fileName} 다운로드` + } + + + + ); +}; + +/** + * 드롭다운 파일 액션 버튼 (미리보기 + 다운로드) + */ +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 = ({ + 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 ( + + ); + } + + if (error) { + return ( + + + + + + +
+
오류 발생
+
{error}
+
클릭하여 재시도
+
+
+
+
+ ); + } + + return ( + + + + + + {fileInfo.canPreview && ( + <> + + + {fileInfo.icon} 미리보기 + + + + )} + + + {description} 다운로드 + + + + ); +}; + +/** + * 스마트 파일 액션 버튼 (자동 판단) + */ +interface SmartFileActionButtonProps extends Omit { + showLabel?: boolean; +} + +export const SmartFileActionButton: React.FC = ({ + 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 ( + + ); + } + + const actionText = fileInfo.canPreview ? '미리보기' : '다운로드'; + const IconComponent = fileInfo.canPreview ? Eye : Download; + + return ( + + + + + + + {error + ? `오류: ${error} (클릭하여 재시도)` + : `${fileInfo.icon} ${fileName} ${actionText}` + } + + + + ); +}; + +/** + * 파일명 링크 컴포넌트 + */ +interface FileNameLinkProps { + filePath: string; + fileName: string; + className?: string; + showIcon?: boolean; + maxLength?: number; +} + +export const FileNameLink: React.FC = ({ + 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 ( + + ); +}; \ 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 - + + if (text.length <= maxLength) { + return {text} + } + + const truncated = text.slice(0, maxLength) + "..." + + if (!showTooltip) { + return {truncated} + } + + return ( + + + + + {truncated} + + + +

{text}

+
+
+
+ ) +} + +export function ExpandableText({ + text, + maxLength = 100, + className = "" +}: { + text: string | null + maxLength?: number + className?: string +}) { + const [isExpanded, setIsExpanded] = useState(false) + + if (!text) return - + + if (text.length <= maxLength) { + return {text} + } + + return ( + +
+ + + +
+
+ ) +} + +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 - + } + + return ( +
+ {postalCode && ( +
+ 우편번호: {postalCode} +
+ )} + {address && ( +
+ {address} +
+ )} + {addressDetail && ( +
+ {addressDetail} +
+ )} + {addressEng && ( +
+ {addressEng} +
+ )} +
+ ) +} \ No newline at end of file -- cgit v1.2.3