From de2ac5a2860bc25180971e7a11f852d9d44675b7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 6 Aug 2025 04:23:40 +0000 Subject: (대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/data-table/data-table-filter-list.tsx | 44 +- components/data-table/data-table-grobal-filter.tsx | 4 +- components/data-table/data-table-view-options.tsx | 12 +- components/data-table/data-table.tsx | 7 +- components/layout/Header.tsx | 13 +- components/login/login-form.tsx | 4 +- components/polices/policy-editor.tsx | 262 +++ components/polices/policy-history.tsx | 250 +++ components/polices/policy-management-client.tsx | 429 +++++ components/polices/policy-preview.tsx | 191 ++ components/qna/tiptap-editor.tsx | 98 +- components/signup/conset-step.tsx | 415 ++++ components/signup/join-form.tsx | 2021 +++++++++++--------- 13 files changed, 2752 insertions(+), 998 deletions(-) create mode 100644 components/polices/policy-editor.tsx create mode 100644 components/polices/policy-history.tsx create mode 100644 components/polices/policy-management-client.tsx create mode 100644 components/polices/policy-preview.tsx create mode 100644 components/signup/conset-step.tsx (limited to 'components') diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx index 6088e912..ea4b1f90 100644 --- a/components/data-table/data-table-filter-list.tsx +++ b/components/data-table/data-table-filter-list.tsx @@ -66,6 +66,7 @@ import { } from "@/components/ui/sortable" import { useParams } from 'next/navigation'; import { useTranslation } from '@/i18n/client' +import deepEqual from "fast-deep-equal" interface DataTableFilterListProps { table: Table @@ -78,6 +79,10 @@ interface DataTableFilterListProps { onFiltersChange?: (filters: Filter[], joinOperator: JoinOperator) => void } +export function isSame(a: unknown, b: unknown) { + return JSON.stringify(a) === JSON.stringify(b) +} + export function DataTableFilterList({ table, filterFields, @@ -88,6 +93,11 @@ export function DataTableFilterList({ onFiltersChange, }: DataTableFilterListProps) { + const prevRef = React.useRef<{ + filters: Filter[] + join: JoinOperator + } | null>(null) + const params = useParams(); const lng = params ? (params.lng as string) : 'en'; @@ -114,13 +124,25 @@ export function DataTableFilterList({ }) ) + const safeSetFilters = React.useCallback( + (next: Filter[] | ((p: Filter[]) => Filter[])) => { + setFilters((prev) => { + const value = typeof next === "function" ? next(prev) : next + return deepEqual(prev, value) ? prev : value // <─ 달라진 게 없으면 그대로 + }) + }, + [setFilters] + ) + + + // ✅ 외부 필터가 전달되면 URL 상태를 업데이트 React.useEffect(() => { - if (externalFilters && externalFilters.length > 0) { + if (externalFilters && !deepEqual(externalFilters, filters)) { console.log("=== 외부 필터 적용 ===", externalFilters); - setFilters(externalFilters); + safeSetFilters(externalFilters); } - }, [externalFilters, setFilters]); + }, [externalFilters, setFilters, safeSetFilters]); React.useEffect(() => { if (externalJoinOperator) { @@ -130,12 +152,20 @@ export function DataTableFilterList({ }, [externalJoinOperator, setJoinOperator]); // ✅ 필터 변경 시 부모에게 알림 + React.useEffect(() => { - if (onFiltersChange) { - onFiltersChange(filters, joinOperator); + const prev = prevRef.current + const changed = + !prev || + !deepEqual(prev.filters, filters) || + prev.join !== joinOperator + + if (changed) { + prevRef.current = { filters, join: joinOperator } + onFiltersChange?.(filters, joinOperator) } - }, [filters, joinOperator, onFiltersChange]); - + }, [filters, joinOperator, onFiltersChange]) + const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs) function addFilter() { diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx index a1f0a6f3..ca60bf02 100644 --- a/components/data-table/data-table-grobal-filter.tsx +++ b/components/data-table/data-table-grobal-filter.tsx @@ -24,8 +24,8 @@ export function DataTableGlobalFilter() { // Debounced callback that sets the URL param after `delay` ms const debouncedSetSearch = useDebouncedCallback((value: string) => { - setSearchValue(value) - }, 300) // 300ms or chosen delay + if (value !== searchValue) setSearchValue(value.trim() === "" ? undefined : value); + }, 300) // When user types, update local `tempValue` immediately, // then call the debounced function to update the query param diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx index 422e3065..b689adab 100644 --- a/components/data-table/data-table-view-options.tsx +++ b/components/data-table/data-table-view-options.tsx @@ -39,6 +39,7 @@ import { } from "@/components/ui/sortable" import { useTranslation } from '@/i18n/client' import { useParams, usePathname } from "next/navigation"; +import deepEqual from "fast-deep-equal" /** @@ -70,6 +71,7 @@ export function DataTableViewOptions({ }: DataTableViewOptionsProps) { const triggerRef = React.useRef(null) + const params = useParams(); const lng = params?.lng as string; const { t } = useTranslation(lng); @@ -115,11 +117,11 @@ export function DataTableViewOptions({ const finalOrder = [...nonHideable, ...columnOrder] // Now we set the table's official column order - table.setColumnOrder(finalOrder) - - // Reset auto-size when column order changes - resetAutoSize?.() - }, [columnOrder, hideableCols, table, resetAutoSize]) + if (!deepEqual(table.getState().columnOrder, finalOrder)) { + table.setColumnOrder(finalOrder) + resetAutoSize?.() + } + }, [columnOrder, hideableCols.join("|"), table, resetAutoSize]) return ( diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index 33fca5b8..b898c2ea 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -66,9 +66,14 @@ export function DataTable({ [compact] ); + const stableChildren = React.useMemo(() => { + console.log("📦 DataTable children 메모이제이션됨"); + return children; + }, [children]); + return (
- {children} + {stableChildren}
{/* 테이블 헤더 */} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 0e9e2abe..68db1426 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -23,7 +23,7 @@ import { navigationMenuTriggerStyle, } from "@/components/ui/navigation-menu"; import { SearchIcon, BellIcon, Menu } from "lucide-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams, usePathname, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; import Image from "next/image"; import { @@ -311,4 +311,13 @@ const ListItem = React.forwardRef< ); }); -ListItem.displayName = "ListItem"; \ No newline at end of file +ListItem.displayName = "ListItem"; + +export function RouteLogger() { + const path = usePathname(); + const qs = useSearchParams().toString(); + React.useEffect(() => { + console.log("[URL]", path + (qs ? "?" + qs : "")); + }, [path, qs]); + return null; + } \ No newline at end of file diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 99708dd6..b850a3d3 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -784,4 +784,6 @@ export function LoginForm({ ) -} \ No newline at end of file +} + + diff --git a/components/polices/policy-editor.tsx b/components/polices/policy-editor.tsx new file mode 100644 index 00000000..d58831e0 --- /dev/null +++ b/components/polices/policy-editor.tsx @@ -0,0 +1,262 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Separator } from '@/components/ui/separator' +import { Save, Eye, X, Info, AlertTriangle } from 'lucide-react' +import TiptapEditor from '../qna/tiptap-editor' + +interface PolicyEditorProps { + policyType: string + currentPolicy?: any + onSave: (policyType: string, version: string, content: string) => void + onCancel: () => void + onPreview: (policyType: string, content: string, version: string) => void + isLoading?: boolean +} + +export function PolicyEditor({ + policyType, + currentPolicy, + onSave, + onCancel, + onPreview, + isLoading = false +}: PolicyEditorProps) { + const [version, setVersion] = useState('') + const [content, setContent] = useState('') + const [validationErrors, setValidationErrors] = useState([]) + + console.log(content) + + const policyLabels = { + privacy_policy: '개인정보 처리방침', + terms_of_service: '이용약관' + } + + // 현재 정책 기반으로 다음 버전 생성 + useEffect(() => { + if (currentPolicy) { + const currentVersion = currentPolicy.version + const versionParts = currentVersion.split('.') + const majorVersion = parseInt(versionParts[0]) || 1 + const minorVersion = parseInt(versionParts[1]) || 0 + + // 마이너 버전 업 + const nextVersion = `${majorVersion}.${minorVersion + 1}` + setVersion(nextVersion) + setContent(currentPolicy.content || '') + } else { + setVersion('1.0') + setContent(getDefaultPolicyContent(policyType)) + } + }, [currentPolicy, policyType]) + + const getDefaultPolicyContent = (type: string) => { + if (type === 'privacy_policy') { + return `

개인정보 처리방침

+ +

제1조 (목적)

+

본 개인정보 처리방침은 eVCP(이하 "회사")가 개인정보 보호법 등 관련 법령에 따라 정보주체의 개인정보를 보호하고 이와 관련된 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같은 처리방침을 수립·공개합니다.

+ +

제2조 (개인정보의 수집 및 이용목적)

+

회사는 다음의 목적을 위하여 개인정보를 처리합니다:

+
    +
  • 회원 가입 및 관리
  • +
  • 서비스 제공 및 계약 이행
  • +
  • 고객 상담 및 불만 처리
  • +
+ +

제3조 (개인정보의 수집항목)

+

필수항목:

+
    +
  • 이메일 주소
  • +
  • 전화번호
  • +
  • 회사명
  • +
+ +

제4조 (개인정보의 보유 및 이용기간)

+

회사는 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.

+ +

제5조 (정보주체의 권리)

+

정보주체는 회사에 대해 언제든지 다음 각 호의 개인정보 보호 관련 권리를 행사할 수 있습니다:

+
    +
  • 개인정보 처리현황 통지요구
  • +
  • 개인정보 열람요구
  • +
  • 개인정보 정정·삭제요구
  • +
  • 개인정보 처리정지요구
  • +
` + } else { + return `

이용약관

+ +

제1조 (목적)

+

본 약관은 eVCP(이하 "회사")가 제공하는 서비스의 이용조건 및 절차, 회사와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.

+ +

제2조 (정의)

+
    +
  • "서비스"란 회사가 제공하는 모든 서비스를 의미합니다.
  • +
  • "회원"이란 본 약관에 동의하고 회사와 서비스 이용계약을 체결한 자를 의미합니다.
  • +
  • "업체"란 회사의 파트너로 등록된 법인 또는 개인사업자를 의미합니다.
  • +
+ +

제3조 (약관의 효력 및 변경)

+

본 약관은 서비스를 이용하고자 하는 모든 회원에 대하여 그 효력을 발생합니다.

+ +

제4조 (회원가입)

+

회원가입은 신청자가 본 약관의 내용에 대하여 동의를 한 다음 회원가입신청을 하고 회사가 이러한 신청에 대하여 승낙함으로써 체결됩니다.

+ +

제5조 (서비스의 제공)

+

회사는 회원에게 다음과 같은 서비스를 제공합니다:

+
    +
  • 업체 등록 및 관리 서비스
  • +
  • 문서 관리 서비스
  • +
  • 견적 제출 서비스
  • +
` + } + } + + const validateForm = () => { + const errors: string[] = [] + + if (!version.trim()) { + errors.push('버전을 입력해주세요.') + } else if (!/^\d+\.\d+$/.test(version.trim())) { + errors.push('버전은 "1.0" 형식으로 입력해주세요.') + } + + if (!content.trim()) { + errors.push('정책 내용을 입력해주세요.') + } else if (content.trim().length < 100) { + errors.push('정책 내용이 너무 짧습니다. (최소 100자)') + } + + // 버전 중복 체크 (현재 정책이 있는 경우) + if (currentPolicy && version === currentPolicy.version) { + errors.push('이미 존재하는 버전입니다. 다른 버전을 입력해주세요.') + } + + setValidationErrors(errors) + return errors.length === 0 + } + + const handleSave = () => { + if (validateForm()) { + onSave(policyType, version.trim(), content) + } + } + + const handlePreview = () => { + if (validateForm()) { + onPreview(policyType, content, version.trim()) + } + } + + return ( + + + + {currentPolicy ? '정책 편집' : '새 정책 생성'} - {policyLabels[policyType]} + + + {currentPolicy + ? `현재 버전 v${currentPolicy.version}을 기반으로 새 버전을 생성합니다.` + : `${policyLabels[policyType]}의 첫 번째 버전을 생성합니다.` + } + + + + {/* 경고 메시지 */} + + + + 새 버전을 저장하면 즉시 활성화되어 모든 사용자에게 적용됩니다. + 저장하기 전에 미리보기로 내용을 확인해주세요. + + + + {/* 유효성 검사 오류 */} + {validationErrors.length > 0 && ( + + + +
    + {validationErrors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+
+ )} + + {/* 버전 입력 */} +
+ + setVersion(e.target.value)} + disabled={isLoading} + className="w-32" + /> +

+ 형식: 주.부 (예: 1.0, 1.1, 2.0) +

+
+ + + + {/* 정책 내용 편집기 */} +
+ +
+ +
+

+ 리치 텍스트 편집기를 사용하여 정책 내용을 작성하세요. + 이미지, 표, 목록 등을 추가할 수 있습니다. +

+
+ + {/* 액션 버튼들 */} +
+ + +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/polices/policy-history.tsx b/components/polices/policy-history.tsx new file mode 100644 index 00000000..af6a68f2 --- /dev/null +++ b/components/polices/policy-history.tsx @@ -0,0 +1,250 @@ +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Separator } from '@/components/ui/separator' +import { Save, Eye, X, Info, AlertTriangle, Calendar, Clock } from 'lucide-react' +import { Badge } from '../ui/badge' +import { PolicyPreview } from './policy-preview' + +// ✅ 타입 정의 +interface PolicyData { + id: number + policyType: 'privacy_policy' | 'terms_of_service' + version: string + content: string + effectiveDate: string + isCurrent: boolean + createdAt: string +} + +interface PolicyHistoryProps { + policyType: 'privacy_policy' | 'terms_of_service' + policies: PolicyData[] | null | undefined // ✅ null/undefined 허용 + currentPolicy?: PolicyData | null + onActivate: (policyId: number, policyType: string) => void + onEdit: () => void + onClose: () => void + isLoading?: boolean +} + +export function PolicyHistory({ + policyType, + policies, + currentPolicy, + onActivate, + onEdit, + onClose, + isLoading = false +}: PolicyHistoryProps) { + const [viewingPolicy, setViewingPolicy] = useState(null) // ✅ 상세보기 상태 + + const policyLabels: Record = { + privacy_policy: '개인정보 처리방침', + terms_of_service: '이용약관' + } + + // ✅ 디버깅 로그 + console.log('PolicyHistory - policies:', policies, 'type:', typeof policies, 'isArray:', Array.isArray(policies)) + + // ✅ 안전한 배열 변환 + const safePolicies = Array.isArray(policies) ? policies :Array.isArray(policies.data) ? policies.data : [] + + // ✅ 내용 미리보기 함수 + const getContentPreview = (content: string): string => { + if (!content) return '내용 없음' + + // HTML 태그 제거 및 텍스트 추출 + const textContent = content.replace(/<[^>]*>/g, '').trim() + return textContent.length > 200 + ? textContent.substring(0, 200) + '...' + : textContent + } + + // ✅ 상세보기 핸들러 + const handleViewDetail = (policy: PolicyData) => { + setViewingPolicy(policy) + } + + // ✅ 상세보기 닫기 핸들러 + const handleCloseDetail = () => { + setViewingPolicy(null) + } + + // ✅ 상세보기 모드일 때 PolicyPreview 렌더링 + if (viewingPolicy) { + return ( + + ) + } + + return ( + + +
+
+ + + 정책 히스토리 + + + {policyLabels[policyType]}의 모든 버전을 확인하고 관리합니다 + +
+
+ + +
+
+
+ + {/* ✅ 로딩 상태 */} + {isLoading && ( +
+
히스토리를 불러오는 중...
+
+ )} + + {/* ✅ 에러 상태 체크 */} + {!isLoading && safePolicies && !Array.isArray(safePolicies) && ( + + + + 정책 데이터 형식이 올바르지 않습니다. (받은 데이터: {typeof safePolicies}) + + + )} + + {/* ✅ 정책 목록 */} + {!isLoading && ( +
+ {safePolicies.length === 0 ? ( +
+
+ +

등록된 정책 버전이 없습니다.

+
+ +
+ ) : ( + <> + {/* ✅ 정책 개수 표시 */} +
+
+ 총 {safePolicies.length}개 버전 +
+ {currentPolicy && ( + + 현재: v{currentPolicy.version} + + )} +
+ + {/* ✅ 정책 목록 렌더링 */} + {safePolicies.map((policy: PolicyData) => ( +
+
+
+ {/* ✅ 헤더 */} +
+

+ 버전 {policy.version} +

+ {policy.isCurrent && ( + + 현재 활성 + + )} +
+ + {/* ✅ 메타 정보 */} +
+
+ + 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} +
+
+ + 생성일: {new Date(policy.createdAt).toLocaleDateString('ko-KR')} +
+
+ + {/* ✅ 내용 미리보기 */} +
+
+ {getContentPreview(policy.content)} +
+
+
+ + {/* ✅ 액션 버튼들 */} +
+ {!policy.isCurrent && ( + + )} + +
+
+
+ ))} + + )} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/components/polices/policy-management-client.tsx b/components/polices/policy-management-client.tsx new file mode 100644 index 00000000..eecb82ff --- /dev/null +++ b/components/polices/policy-management-client.tsx @@ -0,0 +1,429 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Plus, + Edit, + Eye, + History, + Save, + X, + Shield, + FileText, + Calendar, + Clock, + CheckCircle2, + AlertCircle, + Loader2 +} from 'lucide-react' +import { PolicyEditor } from './policy-editor' +import { PolicyPreview } from './policy-preview' +import { PolicyHistory } from './policy-history' +import { useToast } from "@/hooks/use-toast"; +import { activatePolicyVersion, createPolicyVersion, getPolicyHistory } from '@/lib/polices/service' +import { useRouter } from "next/navigation" + +interface PolicyManagementClientProps { + initialData: { + currentPolicies: Record + allPolicies: Record + stats: any + } +} + +export function PolicyManagementClient({ initialData }: PolicyManagementClientProps) { + const [currentTab, setCurrentTab] = useState('privacy_policy') + const [editingPolicy, setEditingPolicy] = useState(null) + const [viewingHistory, setViewingHistory] = useState(null) + const [previewData, setPreviewData] = useState(null) + + // ✅ 초기 데이터를 안전하게 설정 + const [policies, setPolicies] = useState(() => { + const safePolicies = { ...initialData.allPolicies } + // 각 정책 타입에 대해 빈 배열로 초기화 + if (!safePolicies.privacy_policy) safePolicies.privacy_policy = [] + if (!safePolicies.terms_of_service) safePolicies.terms_of_service = [] + return safePolicies + }) + + const [currentPolicies, setCurrentPolicies] = useState(initialData.currentPolicies || {}) + const [isPending, startTransition] = useTransition() + const { toast } = useToast(); + const router = useRouter() + + const policyTypes = [ + { + key: 'privacy_policy', + label: '개인정보 처리방침', + icon: , + description: '개인정보 수집, 이용, 보관 및 파기에 관한 정책' + }, + { + key: 'terms_of_service', + label: '이용약관', + icon: , + description: '서비스 이용 시 준수해야 할 규칙과 조건' + } + ] + + const handleCreateNew = (policyType: string) => { + setEditingPolicy(policyType) + setViewingHistory(null) + setPreviewData(null) + } + + const handleEdit = (policyType: string) => { + setEditingPolicy(policyType) + setViewingHistory(null) + setPreviewData(null) + } + + const handleViewHistory = async (policyType: string) => { + setViewingHistory(policyType) + setEditingPolicy(null) + setPreviewData(null) + + startTransition(async () => { + try { + const history = await getPolicyHistory(policyType) + setPolicies(prev => ({ + ...prev, + [policyType]: history || [] // ✅ null/undefined 방지 + })) + } catch (error) { + console.error('Policy history error:', error) + toast({ + variant: "destructive", + title: "오류", + description: "정책 히스토리를 불러오는데 실패했습니다.", + }) + } + }) + } + + const handlePreview = (policyType: string, content: string, version: string) => { + setPreviewData({ + policyType, + content, + version, + effectiveDate: new Date().toISOString() + }) + setEditingPolicy(null) + setViewingHistory(null) + } + + const handleSavePolicy = async (policyType: string, version: string, content: string) => { + if (!content.trim()) { + toast({ + variant: "destructive", + title: "오류", + description: "정책 내용을 입력해주세요.", + }) + return + } + + startTransition(async () => { + try { + console.log('Saving policy:', { policyType, version }) // ✅ 디버깅 로그 + + const result = await createPolicyVersion({ + policyType: policyType as 'privacy_policy' | 'terms_of_service', + version, + content, + effectiveDate: new Date() + }) + + console.log('Save result:', result) // ✅ 디버깅 로그 + + if (result.success) { + toast({ + title: "성공", + description: "새 정책 버전이 생성되었습니다.", + }) + + // ✅ 상태 업데이트 - 안전하게 처리 + const newPolicy = result.policy + + setPolicies(prev => { + console.log('Updating policies state:', { prev, policyType, newPolicy }) // 디버깅 로그 + return { + ...prev, + [policyType]: [newPolicy, ...(prev[policyType] || [])] // ✅ 안전한 스프레드 + } + }) + + setCurrentPolicies(prev => { + console.log('Updating current policies state:', { prev, policyType, newPolicy }) // 디버깅 로그 + return { + ...prev, + [policyType]: newPolicy + } + }) + + setEditingPolicy(null) + + // ✅ Router refresh를 상태 업데이트 후에 호출 + router.refresh() + } else { + throw new Error(result.error) + } + } catch (error) { + console.error('Save policy error:', error) // ✅ 에러 로그 + toast({ + variant: "destructive", + title: "오류", + description: error?.message || "정책 저장에 실패했습니다.", + }) + } + }) + } + + const handleActivatePolicy = async (policyId: number, policyType: string) => { + startTransition(async () => { + try { + const result = await activatePolicyVersion(policyId) + + if (result.success) { + toast({ + title: "성공", + description: "정책이 활성화되었습니다.", + }) + + // ✅ 현재 정책 업데이트 - 안전하게 처리 + const activatedPolicy = (policies[policyType] || []).find(p => p.id === policyId) + if (activatedPolicy) { + setCurrentPolicies(prev => ({ + ...prev, + [policyType]: activatedPolicy + })) + + // ✅ 정책 목록의 isCurrent 상태 업데이트 + setPolicies(prev => ({ + ...prev, + [policyType]: (prev[policyType] || []).map(p => ({ + ...p, + isCurrent: p.id === policyId + })) + })) + } + + router.refresh() + } else { + throw new Error(result.error) + } + } catch (error) { + console.error('Activate policy error:', error) + toast({ + variant: "destructive", + title: "오류", + description: error?.message || "정책 활성화에 실패했습니다.", + }) + } + }) + } + + const renderMainContent = () => { + const currentPolicy = currentPolicies[currentTab] + const policyInfo = policyTypes.find(p => p.key === currentTab) + + // ✅ 디버깅 정보 + console.log('Render main content:', { + currentTab, + currentPolicy, + editingPolicy, + policiesForTab: policies[currentTab]?.length || 0 + }) + + if (editingPolicy === currentTab) { + return ( + setEditingPolicy(null)} + onPreview={handlePreview} + isLoading={isPending} + /> + ) + } + + if (viewingHistory === currentTab) { + return ( + setEditingPolicy(currentTab)} + onClose={() => setViewingHistory(null)} + isLoading={isPending} + /> + ) + } + + if (previewData && previewData.policyType === currentTab) { + return ( + handleSavePolicy(previewData.policyType, previewData.version, previewData.content)} + onEdit={() => setEditingPolicy(currentTab)} + onClose={() => setPreviewData(null)} + isLoading={isPending} + /> + ) + } + + // 기본 정책 관리 화면 + return ( + + +
+
+ + {policyInfo?.icon} + {policyInfo?.label} + + + {policyInfo?.description} + +
+
+ + {currentPolicy && ( + <> + + + + )} +
+
+
+ + {currentPolicy ? ( +
+ {/* 현재 정책 정보 */} +
+
+ +
+

+ 현재 활성 버전: v{currentPolicy.version} +

+

+ 시행일: {new Date(currentPolicy.effectiveDate).toLocaleDateString('ko-KR')} +

+
+
+ + 활성 + +
+ + {/* 정책 내용 미리보기 */} +
+

정책 내용 미리보기

+
+
1000 ? '...' : '') + }} + /> +
+
+ + {/* 메타 정보 */} +
+
+ + 생성일: {new Date(currentPolicy.createdAt).toLocaleString('ko-KR')} +
+
+ + 버전: {currentPolicy.version} +
+
+
+ ) : ( +
+ +

정책이 등록되지 않았습니다

+

+ {policyInfo?.label}의 첫 번째 버전을 생성해주세요. +

+ +
+ )} + + + ) + } + + return ( +
+
+

정책 편집

+ {isPending && ( +
+ + 처리 중... +
+ )} +
+ + + + {policyTypes.map(policy => ( + + {policy.icon} + {policy.label} + + ))} + + + {policyTypes.map(policy => ( + + {renderMainContent()} + + ))} + +
+ ) +} \ No newline at end of file diff --git a/components/polices/policy-preview.tsx b/components/polices/policy-preview.tsx new file mode 100644 index 00000000..059b2d72 --- /dev/null +++ b/components/polices/policy-preview.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { Save, Eye, X, Info, AlertTriangle, Calendar, Clock, FileText, Shield } from 'lucide-react' +import { Badge } from '../ui/badge' + +// ✅ 타입 정의 +interface PolicyPreviewData { + policyType: 'privacy_policy' | 'terms_of_service' + content: string + version: string + effectiveDate: string + id?: number + isCurrent?: boolean + createdAt?: string +} + +interface PolicyPreviewProps { + data: PolicyPreviewData + onSave?: () => void + onEdit?: () => void + onClose: () => void + isLoading?: boolean + mode?: 'preview' | 'view' // ✅ 미리보기 모드 vs 상세보기 모드 +} + +export function PolicyPreview({ + data, + onSave, + onEdit, + onClose, + isLoading = false, + mode = 'preview' +}: PolicyPreviewProps) { + const policyLabels: Record = { + privacy_policy: '개인정보 처리방침', + terms_of_service: '이용약관' + } + + const policyIcons = { + privacy_policy: , + terms_of_service: + } + + // ✅ 모드에 따른 제목과 설명 + const getTitle = () => { + return mode === 'preview' ? '정책 미리보기' : '정책 상세보기' + } + + const getDescription = () => { + if (mode === 'preview') { + return `${policyLabels[data.policyType]} v${data.version} - 저장하면 즉시 활성화됩니다` + } else { + return `${policyLabels[data.policyType]} v${data.version} 상세 내용` + } + } + + // ✅ 상태 텍스트 결정 + const getStatusText = () => { + if (mode === 'preview') return '저장 대기' + if (data.isCurrent) return '현재 활성' + return '비활성' + } + + const getStatusBadge = () => { + if (mode === 'preview') { + return 저장 대기 + } + if (data.isCurrent) { + return 현재 활성 + } + return 비활성 + } + + return ( + + +
+
+ {policyIcons[data.policyType]} +
+ {getTitle()} + + {getDescription()} + +
+
+ +
+
+ + {/* ✅ 정책 메타 정보 */} +
+
+

정책 유형

+

{policyLabels[data.policyType]}

+
+
+

버전

+

v{data.version}

+
+
+

시행일

+

+ {new Date(data.effectiveDate).toLocaleDateString('ko-KR')} +

+
+
+

상태

+
+ {getStatusBadge()} +
+
+ + {/* ✅ 상세보기 모드일 때 추가 정보 */} + {mode === 'view' && data.createdAt && ( + <> +
+

생성일

+

+ {new Date(data.createdAt).toLocaleString('ko-KR')} +

+
+
+

문서 ID

+

#{data.id}

+
+ + )} +
+ + + + {/* ✅ 정책 내용 미리보기 */} +
+
+

정책 내용

+
+ {data.content.replace(/<[^>]*>/g, '').length}자 +
+
+
+
+
+
+ + {/* ✅ 액션 버튼들 */} +
+ {mode === 'preview' ? ( + // 미리보기 모드 버튼들 + <> + + + + + ) : ( + // 상세보기 모드 버튼들 +
+ +
+ )} +
+ + + ) +} \ No newline at end of file diff --git a/components/qna/tiptap-editor.tsx b/components/qna/tiptap-editor.tsx index 5d0a84e9..b1cebf5a 100644 --- a/components/qna/tiptap-editor.tsx +++ b/components/qna/tiptap-editor.tsx @@ -1,4 +1,5 @@ import { useEditor, EditorContent } from '@tiptap/react' +import { useEffect } from 'react' // useEffect 추가 import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import { Image as TiptapImage } from '@tiptap/extension-image' @@ -26,6 +27,18 @@ interface TiptapEditorProps { height?: string; // 높이 prop 추가 } +// 이미지 크기 가져오기 헬퍼 함수 +function getImageDimensions(url: string): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + resolve({ width: img.width, height: img.height }) + } + img.onerror = reject + img.src = url + }) +} + export default function TiptapEditor({ content, setContent, disabled, height = "300px" }: TiptapEditorProps) { const editor = useEditor({ extensions: [ @@ -135,6 +148,21 @@ export default function TiptapEditor({ content, setContent, disabled, height = " }, }) + // ✅ 핵심 추가: content prop이 변경될 때 에디터 내용 동기화 + useEffect(() => { + if (editor && content !== editor.getHTML()) { + console.log('Updating editor content:', content) // 디버깅용 + editor.commands.setContent(content, false) // false: focus 유지하지 않음 + } + }, [content, editor]) + + // ✅ editable 상태 변경 처리 + useEffect(() => { + if (editor) { + editor.setEditable(!disabled) + } + }, [disabled, editor]) + async function uploadImageToServer(file: File): Promise { const formData = new FormData(); formData.append('file', file); @@ -153,39 +181,51 @@ export default function TiptapEditor({ content, setContent, disabled, height = " return url; } -// Base64 → 서버 업로드 방식으로 교체 -const handleImageUpload = async (file: File) => { - try { - if (file.size > 3 * 1024 * 1024) { - alert('이미지 크기는 3 MB 이하만 지원됩니다.'); - return; - } - if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드 가능합니다.'); - return; - } + // Base64 → 서버 업로드 방식으로 교체 + const handleImageUpload = async (file: File) => { + try { + if (file.size > 3 * 1024 * 1024) { + alert('이미지 크기는 3 MB 이하만 지원됩니다.'); + return; + } + if (!file.type.startsWith('image/')) { + alert('이미지 파일만 업로드 가능합니다.'); + return; + } - const url = await uploadImageToServer(file); // ← 업로드 & URL 획득 + const url = await uploadImageToServer(file); // ← 업로드 & URL 획득 - // 이미지 크기(너비)에 따라 style 조정 - const { width, height } = await getImageDimensions(url); - const maxWidth = 600; - const newW = width > maxWidth ? maxWidth : width; - const newH = width > maxWidth ? (height * maxWidth) / width : height; + // 이미지 크기(너비)에 따라 style 조정 + const { width, height } = await getImageDimensions(url); + const maxWidth = 600; + const newW = width > maxWidth ? maxWidth : width; + const newH = width > maxWidth ? (height * maxWidth) / width : height; + + editor + ?.chain() + .focus() + .setImage({ + src: url, + style: `width:${newW}px;height:${newH}px;max-width:100%;`, + }) + .run(); + } catch (e) { + console.error(e); + alert('이미지 업로드에 실패했습니다.'); + } + }; - editor - ?.chain() - .focus() - .setImage({ - src: url, - style: `width:${newW}px;height:${newH}px;max-width:100%;`, - }) - .run(); - } catch (e) { - console.error(e); - alert('이미지 업로드에 실패했습니다.'); + // ✅ 에디터가 준비되지 않았을 때 로딩 표시 + if (!editor) { + return ( +
+
에디터를 로딩 중...
+
+ ) } -}; // 높이 계산 (100%인 경우 flex 사용, 아니면 구체적 높이) const containerStyle = height === "100%" diff --git a/components/signup/conset-step.tsx b/components/signup/conset-step.tsx new file mode 100644 index 00000000..3260a7b7 --- /dev/null +++ b/components/signup/conset-step.tsx @@ -0,0 +1,415 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { ChevronDown, ChevronUp, FileText, Shield, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getCurrentPolicies } from '@/lib/polices/service'; + +// ✅ 정책 데이터 타입 개선 +interface PolicyData { + id: number; + policyType: 'privacy_policy' | 'terms_of_service'; + version: string; + content: string; // HTML 형태의 리치텍스트 + effectiveDate: string; + isCurrent: boolean; + createdAt: string; +} + +interface PolicyVersions { + privacy_policy?: PolicyData; + terms_of_service?: PolicyData; +} + +interface ConsentStepProps { + data: { + privacy: boolean; + terms: boolean; + marketing: boolean; + }; + onChange: (updater: (prev: any) => any) => void; + onNext: () => void; +} + +export default function ConsentStep({ data, onChange, onNext }: ConsentStepProps) { + const [policyData, setPolicyData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showPrivacyModal, setShowPrivacyModal] = useState(false); + const [showTermsModal, setShowTermsModal] = useState(false); + const [expandedSections, setExpandedSections] = useState>({}); + + const isValid = data.privacy && data.terms; + + // 정책 데이터 로드 + useEffect(() => { + fetchPolicyData(); + }, []); + + const fetchPolicyData = async () => { + try { + setLoading(true); + setError(null); + + const result = await getCurrentPolicies(); + + if (result.success) { + console.log('Policy data loaded:', result.data); + setPolicyData(result.data); + } else { + setError(result.error || '정책 데이터를 불러올 수 없습니다.'); + console.error('Failed to fetch policy data:', result.error); + } + } catch (error) { + const errorMessage = '정책 데이터를 불러오는 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('Failed to fetch policy data:', error); + } finally { + setLoading(false); + } + }; + + const handleConsentChange = (type: string, checked: boolean) => { + onChange(prev => ({ ...prev, [type]: checked })); + }; + + const toggleSection = (section: string) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + // ✅ HTML에서 텍스트만 추출하는 함수 + const stripHtmlTags = (html: string): string => { + if (!html) return ''; + + // HTML 태그 제거 + return html + .replace(/<[^>]*>/g, '') // 모든 HTML 태그 제거 + .replace(/ /g, ' ') // non-breaking space 처리 + .replace(/&/g, '&') // HTML entities 처리 + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') // 연속된 공백을 하나로 + .trim(); + }; + + // ✅ 정책 미리보기 텍스트 생성 + const renderPolicyPreview = (policy: PolicyData, maxLength = 200): string => { + if (!policy?.content) return '내용 없음'; + + const textContent = stripHtmlTags(policy.content); + return textContent.length > maxLength + ? textContent.substring(0, maxLength) + '...' + : textContent; + }; + + // ✅ 로딩 상태 + if (loading) { + return ( +
+
+ + 정책 내용을 불러오는 중... +
+
+ ); + } + + // ✅ 에러 상태 + if (error || !policyData) { + return ( +
+
+ {error || '정책 내용을 불러올 수 없습니다.'} +
+ +
+ ); + } + + // ✅ 필수 정책이 없는 경우 + if (!policyData.privacy_policy || !policyData.terms_of_service) { + return ( +
+
+ 일부 정책이 설정되지 않았습니다. 관리자에게 문의해주세요. +
+
+ {!policyData.privacy_policy && '개인정보 처리방침이 없습니다.'}
+ {!policyData.terms_of_service && '이용약관이 없습니다.'} +
+
+ ); + } + + return ( +
+
+

서비스 이용 약관 동의

+

+ 서비스 이용을 위해 다음 약관에 동의해주세요. 각 항목을 클릭하여 상세 내용을 확인할 수 있습니다. +

+
+ +
+ {/* ✅ 개인정보 처리방침 */} + {policyData.privacy_policy && ( + } + title="개인정보 처리방침" + description="개인정보 수집, 이용, 보관 및 파기에 관한 정책입니다." + expanded={expandedSections.privacy} + onToggleExpand={() => toggleSection('privacy')} + onShowModal={() => setShowPrivacyModal(true)} + /> + )} + + + + {/* ✅ 이용약관 */} + {policyData.terms_of_service && ( + } + title="이용약관" + description="서비스 이용 시 준수해야 할 규칙과 조건입니다." + expanded={expandedSections.terms} + onToggleExpand={() => toggleSection('terms')} + onShowModal={() => setShowTermsModal(true)} + /> + )} + + + {/* ✅ 전체 동의 */} +
+
+ { + const checked = e.target.checked; + onChange(() => ({ + privacy: checked, + terms: checked, + marketing: checked + })); + }} + className="h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + +
+
+
+ +
+ +
+ + {/* ✅ 개인정보 처리방침 상세 모달 */} + {showPrivacyModal && policyData.privacy_policy && ( + setShowPrivacyModal(false)} + onAgree={() => { + onChange(prev => ({ ...prev, privacy: true })); + setShowPrivacyModal(false); + }} + /> + )} + + {/* ✅ 이용약관 상세 모달 */} + {showTermsModal && policyData.terms_of_service && ( + setShowTermsModal(false)} + onAgree={() => { + onChange(prev => ({ ...prev, terms: true })); + setShowTermsModal(false); + }} + /> + )} +
+ ); +} + +// ✅ 개별 정책 동의 섹션 컴포넌트 +interface PolicyConsentSectionProps { + id: string; + type: string; + checked: boolean; + onChange: (type: string, checked: boolean) => void; + policy: PolicyData; + isRequired: boolean; + icon: React.ReactNode; + title: string; + description: string; + expanded: boolean; + onToggleExpand: () => void; + onShowModal: () => void; +} + +function PolicyConsentSection({ + id, type, checked, onChange, policy, isRequired, icon, title, description, + expanded, onToggleExpand, onShowModal +}: PolicyConsentSectionProps) { + // ✅ HTML에서 텍스트 추출 + const renderPolicyPreview = (content: string, maxLength = 300): string => { + if (!content) return '내용 없음'; + + const textContent = content + .replace(/<[^>]*>/g, '') // HTML 태그 제거 + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + + return textContent.length > maxLength + ? textContent.substring(0, maxLength) + '...' + : textContent; + }; + + return ( +
+ {/* 체크박스와 기본 정보 */} +
+ onChange(type, e.target.checked)} + className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> +
+
+ {icon} + +
+ +

{description}

+ + {/* ✅ 정책 미리보기 - HTML 내용 표시 */} +
+ {renderPolicyPreview(policy.content, expanded ? 1000 : 200)} +
+ + {/* 액션 버튼들 */} +
+ + | + + | + + 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} + +
+
+
+
+ ); +} + +// ✅ 정책 상세 모달 컴포넌트 +interface PolicyModalProps { + policy: PolicyData; + onClose: () => void; + onAgree: () => void; +} + +function PolicyModal({ policy, onClose, onAgree }: PolicyModalProps) { + const getPolicyTitle = (policyType: string): string => { + return policyType === 'privacy_policy' ? '개인정보 처리방침' : '이용약관'; + }; + + return ( +
+
+ {/* 헤더 */} +
+
+

+ {getPolicyTitle(policy.policyType)} +

+

+ 버전 {policy.version} | 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} +

+
+ +
+ + {/* ✅ 내용 - HTML 직접 렌더링 */} + +
+ + + {/* 푸터 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 60f600b9..e9773d28 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -1,32 +1,19 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm, useFieldArray } from "react-hook-form" -import { useRouter, useSearchParams, useParams } from "next/navigation" - -import i18nIsoCountries from "i18n-iso-countries" -import enLocale from "i18n-iso-countries/langs/en.json" -import koLocale from "i18n-iso-countries/langs/ko.json" - -import { Button } from "@/components/ui/button" -import { Separator } from "@/components/ui/separator" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { toast } from "@/hooks/use-toast" +'use client' + +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Progress } from '@/components/ui/progress'; +import { Check, ChevronRight, User, Building, FileText, Plus, X, ChevronsUpDown, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useRouter, useParams, useSearchParams } from 'next/navigation'; +import { useTranslation } from '@/i18n/client'; +import { toast } from '@/hooks/use-toast'; import { Popover, PopoverTrigger, PopoverContent, -} from "@/components/ui/popover" +} from '@/components/ui/popover'; import { Command, CommandList, @@ -34,21 +21,14 @@ import { CommandEmpty, CommandGroup, CommandItem, -} from "@/components/ui/command" -import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react" -import { cn } from "@/lib/utils" -import { useTranslation } from "@/i18n/client" - -import { getVendorTypes } from "@/lib/vendors/service" -import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations" +} from '@/components/ui/command'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" - +} from '@/components/ui/select'; import { Dropzone, DropzoneZone, @@ -56,7 +36,7 @@ import { DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, -} from "@/components/ui/dropzone" +} from '@/components/ui/dropzone'; import { FileList, FileListItem, @@ -66,33 +46,33 @@ import { FileListName, FileListDescription, FileListAction, -} from "@/components/ui/file-list" -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) - -const locale = "ko" -const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }) +} from '@/components/ui/file-list'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import prettyBytes from 'pretty-bytes'; + +// 기존 JoinForm에서 가져온 데이터들 +import i18nIsoCountries from "i18n-iso-countries"; +import enLocale from "i18n-iso-countries/langs/en.json"; +import koLocale from "i18n-iso-countries/langs/ko.json"; +import { getVendorTypes } from '@/lib/vendors/service'; +import ConsentStep from './conset-step'; + +i18nIsoCountries.registerLocale(enLocale); +i18nIsoCountries.registerLocale(koLocale); + +const locale = "ko"; +const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }); const countryArray = Object.entries(countryMap).map(([code, label]) => ({ code, label, -})) +})); -// Sort countries to put Korea first, then alphabetically const sortedCountryArray = [...countryArray].sort((a, b) => { - // Put Korea (KR) at the top if (a.code === "KR") return -1; if (b.code === "KR") return 1; - - // Otherwise sort alphabetically return a.label.localeCompare(b.label); }); -// Add English names for Korean locale const enhancedCountryArray = sortedCountryArray.map(country => ({ ...country, label: locale === "ko" && country.code === "KR" @@ -100,7 +80,6 @@ const enhancedCountryArray = sortedCountryArray.map(country => ({ : country.label })); -// Contact task options const contactTaskOptions = [ { value: "PRESIDENT_DIRECTOR", label: "회사대표 President/Director" }, { value: "SALES_MANAGEMENT", label: "영업관리 Sales Management" }, @@ -114,8 +93,7 @@ const contactTaskOptions = [ { value: "FIELD_SERVICE_ENGINEER", label: "FSE(야드작업자) Field Service Engineer" } ]; -// Comprehensive list of country dial codes -export const countryDialCodes: { [key: string]: string } = { +export const countryDialCodes = { AF: "+93", AL: "+355", DZ: "+213", AS: "+1-684", AD: "+376", AO: "+244", AI: "+1-264", AG: "+1-268", AR: "+54", AM: "+374", AW: "+297", AU: "+61", AT: "+43", AZ: "+994", BS: "+1-242", BH: "+973", BD: "+880", BB: "+1-246", @@ -153,312 +131,1102 @@ export const countryDialCodes: { [key: string]: string } = { ZW: "+263" }; -const MAX_FILE_SIZE = 3e9 - -export function JoinForm() { - const params = useParams() || {}; - const lng = params.lng ? String(params.lng) : "ko"; - const { t } = useTranslation(lng, "translation") - - const router = useRouter() - const searchParams = useSearchParams() || new URLSearchParams(); - const defaultTaxId = searchParams.get("taxID") ?? "" - - // Define VendorType interface - interface VendorType { - id: number; - code: string; - nameKo: string; - nameEn: string; - } - - // Vendor Types state with proper typing - const [vendorTypes, setVendorTypes] = React.useState([]) - const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true) - - // 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) - - // Fetch vendor types on component mount - React.useEffect(() => { - async function loadVendorTypes() { - setIsLoadingVendorTypes(true) - try { - const result = await getVendorTypes() - if (result.data) { - setVendorTypes(result.data) - } - } catch (error) { - console.error("Failed to load vendor types:", error) - toast({ - variant: "destructive", - title: "Error", - description: "Failed to load vendor types", - }) - } finally { - setIsLoadingVendorTypes(false) +const MAX_FILE_SIZE = 3e9; + +// 스텝 정의 +const STEPS = [ + { id: 1, title: '약관 동의', description: '서비스 이용 약관 동의', icon: FileText }, + { id: 2, title: '계정 생성', description: '개인 계정 정보 입력', icon: User }, + { id: 3, title: '업체 등록', description: '업체 정보 및 서류 제출', icon: Building } +]; + +export default function JoinForm() { + const params = useParams() || {}; + const lng = params.lng ? String(params.lng) : "ko"; + const { t } = useTranslation(lng, "translation"); + const router = useRouter(); + const searchParams = useSearchParams() || new URLSearchParams(); + const defaultTaxId = searchParams.get("taxID") ?? ""; + + const [currentStep, setCurrentStep] = useState(1); + const [completedSteps, setCompletedSteps] = useState(new Set()); + + // 각 스텝별 데이터 + const [consentData, setConsentData] = useState({ + privacy: false, + terms: false, + marketing: false + }); + + const [accountData, setAccountData] = useState({ + name: '', + email: '', + password: '', + confirmPassword: '', + phone: '' + }); + + const [vendorData, setVendorData] = useState({ + vendorName: "", + vendorTypeId: undefined, + items: "", + taxId: defaultTaxId, + address: "", + email: "", + phone: "", + country: "", + website: "", + representativeName: "", + representativeBirth: "", + representativeEmail: "", + representativePhone: "", + corporateRegistrationNumber: "", + representativeWorkExpirence: false, + contacts: [ + { + contactName: "", + contactPosition: "", + contactDepartment: "", + contactTask: "", + contactEmail: "", + contactPhone: "", + }, + ], + }); + + // 업체 타입 및 파일 상태 + const [vendorTypes, setVendorTypes] = useState([]); + const [isLoadingVendorTypes, setIsLoadingVendorTypes] = useState(true); + const [businessRegistrationFiles, setBusinessRegistrationFiles] = useState([]); + const [isoCertificationFiles, setIsoCertificationFiles] = useState([]); + const [creditReportFiles, setCreditReportFiles] = useState([]); + const [bankAccountFiles, setBankAccountFiles] = useState([]); + + const [policyVersions, setPolicyVersions] = useState({ + privacy_policy: '1.0', + terms_of_service: '1.0' + }); + + const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100; + + // 정책 버전 및 업체 타입 로드 + useEffect(() => { + fetchPolicyVersions(); + loadVendorTypes(); + }, []); + + const fetchPolicyVersions = async () => { + try { + const response = await fetch('/api/consent/policy-versions'); + const versions = await response.json(); + setPolicyVersions(versions); + } catch (error) { + console.error('Failed to fetch policy versions:', error); + } + }; + + const loadVendorTypes = async () => { + setIsLoadingVendorTypes(true); + try { + const result = await getVendorTypes(); + if (result.data) { + setVendorTypes(result.data); } + } catch (error) { + console.error("Failed to load vendor types:", error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load vendor types", + }); + } finally { + setIsLoadingVendorTypes(false); + } + }; + + const handleStepComplete = (step) => { + setCompletedSteps(prev => new Set([...prev, step])); + if (step < STEPS.length) { + setCurrentStep(step + 1); + } + }; + + const handleStepClick = (stepId) => { + if (stepId <= Math.max(...completedSteps) + 1) { + setCurrentStep(stepId); } + }; + + // 전화번호 플레이스홀더 함수들 + const getPhonePlaceholder = (countryCode) => { + if (!countryCode || !countryDialCodes[countryCode]) return "+82 010-1234-5678"; - loadVendorTypes() - }, []) - - // React Hook Form - const form = useForm({ - resolver: zodResolver(createVendorSchema), - defaultValues: { - vendorName: "", - vendorTypeId: undefined, - items: "", - taxId: defaultTaxId, - address: "", - email: "", - phone: "", - country: "", - representativeName: "", - representativeBirth: "", - representativeEmail: "", - representativePhone: "", - corporateRegistrationNumber: "", - representativeWorkExpirence: false, - // contacts (updated with new fields) - contacts: [ - { - contactName: "", - contactPosition: "", - contactDepartment: "", - contactTask: "", - contactEmail: "", - contactPhone: "", - }, - ], + const dialCode = countryDialCodes[countryCode]; + + switch (countryCode) { + case 'KR': return `${dialCode} 010-1234-5678`; + case 'US': + case 'CA': return `${dialCode} 555-123-4567`; + case 'JP': return `${dialCode} 90-1234-5678`; + case 'CN': return `${dialCode} 138-0013-8000`; + case 'GB': return `${dialCode} 20-7946-0958`; + case 'DE': return `${dialCode} 30-12345678`; + case 'FR': return `${dialCode} 1-42-86-83-16`; + default: return `${dialCode} 전화번호`; + } + }; + + const getPhoneDescription = (countryCode) => { + if (!countryCode) return "국가를 먼저 선택해주세요."; + + const dialCode = countryDialCodes[countryCode]; + + switch (countryCode) { + case 'KR': return `${dialCode}로 시작하는 국제번호 또는 010으로 시작하는 국내번호를 입력하세요.`; + case 'US': + case 'CA': return `${dialCode}로 시작하는 10자리 번호를 입력하세요.`; + case 'JP': return `${dialCode}로 시작하는 일본 전화번호를 입력하세요.`; + case 'CN': return `${dialCode}로 시작하는 중국 전화번호를 입력하세요.`; + default: return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`; + } + }; + + return ( +
+ {/* 진행률 표시 */} +
+
+

파트너 등록

+ + {currentStep} / {STEPS.length} + +
+ + + {/* 스텝 네비게이션 */} +
+ {STEPS.map((step, index) => { + const Icon = step.icon; + const isCompleted = completedSteps.has(step.id); + const isCurrent = currentStep === step.id; + const isAccessible = step.id <= Math.max(...completedSteps) + 1; + + return ( + +
handleStepClick(step.id)} + > +
+ {isCompleted ? ( + + ) : ( + + )} +
+
+
+ {step.title} +
+
+ {step.description} +
+
+
+ + {index < STEPS.length - 1 && ( + + )} +
+ ); + })} +
+
+ + {/* 스텝 콘텐츠 */} +
+ {currentStep === 1 && ( + handleStepComplete(1)} + policyVersions={policyVersions} + /> + )} + + {currentStep === 2 && ( + handleStepComplete(2)} + onBack={() => setCurrentStep(1)} + /> + )} + + {currentStep === 3 && ( + setCurrentStep(2)} + onComplete={() => { + handleStepComplete(3); + // 완료 후 대시보드로 이동 + router.push(`/${lng}/partners/dashboard`); + }} + accountData={accountData} + consentData={consentData} + vendorTypes={vendorTypes} + isLoadingVendorTypes={isLoadingVendorTypes} + businessRegistrationFiles={businessRegistrationFiles} + setBusinessRegistrationFiles={setBusinessRegistrationFiles} + isoCertificationFiles={isoCertificationFiles} + setIsoCertificationFiles={setIsoCertificationFiles} + creditReportFiles={creditReportFiles} + setCreditReportFiles={setCreditReportFiles} + bankAccountFiles={bankAccountFiles} + setBankAccountFiles={setBankAccountFiles} + getPhonePlaceholder={getPhonePlaceholder} + getPhoneDescription={getPhoneDescription} + enhancedCountryArray={enhancedCountryArray} + contactTaskOptions={contactTaskOptions} + lng={lng} + policyVersions={policyVersions} + /> + )} +
+
+ ); +} + + +// Step 2: 계정 생성 +function AccountStep({ data, onChange, onNext, onBack }) { + const [isLoading, setIsLoading] = useState(false); + + const isValid = data.name && data.email && data.password && + data.confirmPassword && data.phone && + data.password === data.confirmPassword; + + const handleInputChange = (field, value) => { + onChange(prev => ({ ...prev, [field]: value })); + }; + + const handleNext = async () => { + if (!isValid) return; + + setIsLoading(true); + try { + // 이메일 중복 확인 + const response = await fetch('/api/auth/check-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: data.email }) + }); + + const result = await response.json(); + + if (!response.ok) { + if (result.error === 'EMAIL_EXISTS') { + alert('이미 사용 중인 이메일입니다.'); + return; + } + throw new Error(result.error); + } + + onNext(); + } catch (error) { + console.error('Email check error:', error); + alert('이메일 확인 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

계정 정보 입력

+

+ 서비스 이용을 위한 개인 계정을 생성합니다. +

+
+ +
+
+ + handleInputChange('name', e.target.value)} + /> +
+ +
+ + handleInputChange('email', e.target.value)} + /> +
+ +
+ + handleInputChange('password', e.target.value)} + /> +
+ +
+ + handleInputChange('confirmPassword', e.target.value)} + /> + {data.confirmPassword && data.password !== data.confirmPassword && ( +

비밀번호가 일치하지 않습니다.

+ )} +
+ +
+ + handleInputChange('phone', e.target.value)} + /> +

+ SMS 인증에 사용됩니다. 국제번호 형식으로 입력해주세요. +

+
+
+ +
+ + +
+
+ ); +} + +// Step 3: 업체 등록 (기존 JoinForm 내용) +function VendorStep(props) { + return ; +} + + +// 완전한 업체 등록 폼 컴포넌트 (기존 JoinForm 내용) +function CompleteVendorForm({ + data, onChange, onBack, onComplete, accountData, consentData, + vendorTypes, isLoadingVendorTypes, businessRegistrationFiles, setBusinessRegistrationFiles, + isoCertificationFiles, setIsoCertificationFiles, creditReportFiles, setCreditReportFiles, + bankAccountFiles, setBankAccountFiles, getPhonePlaceholder, getPhoneDescription, + enhancedCountryArray, contactTaskOptions, lng, policyVersions +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + + // 담당자 관리 함수들 + const addContact = () => { + onChange(prev => ({ + ...prev, + contacts: [...prev.contacts, { + contactName: "", + contactPosition: "", + contactDepartment: "", + contactTask: "", + contactEmail: "", + contactPhone: "", + }] + })); + }; + + const removeContact = (index) => { + onChange(prev => ({ + ...prev, + contacts: prev.contacts.filter((_, i) => i !== index) + })); + }; + + const updateContact = (index, field, value) => { + onChange(prev => ({ + ...prev, + contacts: prev.contacts.map((contact, i) => + i === index ? { ...contact, [field]: value } : contact + ) + })); + }; + + // 폼 입력 변경 핸들러 + const handleInputChange = (field, value) => { + onChange(prev => ({ ...prev, [field]: value })); + }; + + // 파일 업로드 핸들러들 + const createFileUploadHandler = (setFiles, currentFiles) => ({ + onDropAccepted: (acceptedFiles) => { + const newFiles = [...currentFiles, ...acceptedFiles]; + setFiles(newFiles); }, - mode: "onChange", - }) + onDropRejected: (fileRejections) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }); + }); + }, + removeFile: (index) => { + const updated = [...currentFiles]; + updated.splice(index, 1); + setFiles(updated); + } + }); - // Custom validation for file uploads + const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles); + const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles); + const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles); + const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles); + + // 유효성 검사 const validateRequiredFiles = () => { - const errors = [] + const errors = []; if (businessRegistrationFiles.length === 0) { - errors.push("사업자등록증을 업로드해주세요.") + errors.push("사업자등록증을 업로드해주세요."); } if (isoCertificationFiles.length === 0) { - errors.push("ISO 인증서를 업로드해주세요.") + errors.push("ISO 인증서를 업로드해주세요."); } if (creditReportFiles.length === 0) { - errors.push("신용평가보고서를 업로드해주세요.") + errors.push("신용평가보고서를 업로드해주세요."); } - if (form.watch("country") !== "KR" && bankAccountFiles.length === 0) { - errors.push("대금지급 통장사본을 업로드해주세요.") + if (data.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 } = - useFieldArray({ - control: form.control, - name: "contacts", - }) - - // 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"}`, - }) - }) - }, - removeFile: (index: number) => { - const updated = [...currentFiles] - updated.splice(index, 1) - setFiles(updated) - } - }) + return errors; + }; - const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles) - const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles) - const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles) - const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles) + const isFormValid = data.vendorName && data.vendorTypeId && data.items && + data.country && data.phone && data.email && + data.contacts.length > 0 && + data.contacts[0].contactName && + validateRequiredFiles().length === 0; - // Submit - async function onSubmit(values: CreateVendorSchema) { - const fileErrors = validateRequiredFiles() + // 최종 제출 + const handleSubmit = async () => { + const fileErrors = validateRequiredFiles(); if (fileErrors.length > 0) { toast({ variant: "destructive", title: "파일 업로드 필수", description: fileErrors.join("\n"), - }) - return + }); + return; } - setIsSubmitting(true) + setIsSubmitting(true); try { - const formData = new FormData() - - // Add vendor data - const vendorData = { - vendorName: values.vendorName, - vendorTypeId: values.vendorTypeId, - items: values.items, - vendorCode: values.vendorCode, - website: values.website, - taxId: values.taxId, - address: values.address, - email: values.email, - phone: values.phone, - country: values.country, - status: "PENDING_REVIEW" as const, - representativeName: values.representativeName || "", - representativeBirth: values.representativeBirth || "", - representativeEmail: values.representativeEmail || "", - representativePhone: values.representativePhone || "", - corporateRegistrationNumber: values.corporateRegistrationNumber || "", - representativeWorkExpirence: values.representativeWorkExpirence || false - } + const formData = new FormData(); + + // 통합 데이터 준비 + const completeData = { + account: accountData, + vendor: { + ...data, + email: data.email || accountData.email, // 업체 이메일이 없으면 계정 이메일 사용 + }, + consents: { + privacy_policy: { + agreed: consentData.privacy, + version: policyVersions.privacy_policy + }, + terms_of_service: { + agreed: consentData.terms, + version: policyVersions.terms_of_service + }, + marketing: { + agreed: consentData.marketing, + version: policyVersions.privacy_policy + } + } + }; - formData.append('vendorData', JSON.stringify(vendorData)) - formData.append('contacts', JSON.stringify(values.contacts)) + formData.append('completeData', JSON.stringify(completeData)); - // Add files with specific types + // 파일들 추가 businessRegistrationFiles.forEach(file => { - formData.append('businessRegistration', file) - }) + formData.append('businessRegistration', file); + }); isoCertificationFiles.forEach(file => { - formData.append('isoCertification', file) - }) + formData.append('isoCertification', file); + }); creditReportFiles.forEach(file => { - formData.append('creditReport', file) - }) + formData.append('creditReport', file); + }); - if (values.country !== "KR") { + if (data.country !== "KR") { bankAccountFiles.forEach(file => { - formData.append('bankAccount', file) - }) + formData.append('bankAccount', file); + }); } - const response = await fetch('/api/vendors', { + const response = await fetch('/api/auth/signup-with-vendor', { method: 'POST', body: formData, - }) + }); - const result = await response.json() + const result = await response.json(); if (response.ok) { toast({ title: "등록 완료", - description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)", - }) - router.push("/ko/partners") + description: "회원가입 및 업체 등록이 완료되었습니다. 관리자 승인 후 서비스를 이용하실 수 있습니다.", + }); + onComplete(); } else { toast({ variant: "destructive", title: "오류", description: result.error || "등록에 실패했습니다.", - }) + }); } - } catch (error: any) { - console.error(error) + } catch (error) { + console.error(error); toast({ variant: "destructive", title: "서버 에러", description: error.message || "에러가 발생했습니다.", - }) + }); } finally { - setIsSubmitting(false) - } - } - - // Get country code for phone number placeholder - const getPhonePlaceholder = (countryCode: string) => { - if (!countryCode || !countryDialCodes[countryCode]) return "+82 010-1234-5678"; - - const dialCode = countryDialCodes[countryCode]; - - switch (countryCode) { - case 'KR': - return `${dialCode} 010-1234-5678`; - case 'US': - case 'CA': - return `${dialCode} 555-123-4567`; - case 'JP': - return `${dialCode} 90-1234-5678`; - case 'CN': - return `${dialCode} 138-0013-8000`; - case 'GB': - return `${dialCode} 20-7946-0958`; - case 'DE': - return `${dialCode} 30-12345678`; - case 'FR': - return `${dialCode} 1-42-86-83-16`; - default: - return `${dialCode} 전화번호`; + setIsSubmitting(false); } }; - const getPhoneDescription = (countryCode: string) => { - if (!countryCode) return "국가를 먼저 선택해주세요."; - - const dialCode = countryDialCodes[countryCode]; - - switch (countryCode) { - case 'KR': - return `${dialCode}로 시작하는 국제번호 또는 010으로 시작하는 국내번호를 입력하세요.`; - case 'US': - case 'CA': - return `${dialCode}로 시작하는 10자리 번호를 입력하세요.`; - case 'JP': - return `${dialCode}로 시작하는 일본 전화번호를 입력하세요.`; - case 'CN': - return `${dialCode}로 시작하는 중국 전화번호를 입력하세요.`; - default: - return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`; - } - }; + return ( +
+
+

업체 정보 등록

+

+ 업체 정보와 필요한 서류를 등록해주세요. 모든 정보는 관리자 검토 후 승인됩니다. +

+
- // 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; - }) => ( + {/* 기본 정보 */} +
+

기본 정보

+
+ {/* 업체 유형 */} +
+ + + + + + + + + + No vendor type found. + + {vendorTypes.map((type) => ( + handleInputChange('vendorTypeId', type.id)} + > + + {lng === "ko" ? type.nameKo : type.nameEn} + + ))} + + + + + +
+ + {/* 업체명 */} +
+ + handleInputChange('vendorName', e.target.value)} + disabled={isSubmitting} + /> +

+ {data.country === "KR" + ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." + : "해외 업체의 경우 영문 회사명을 입력하세요."} +

+
+ + {/* 공급품목 */} +
+ + handleInputChange('items', e.target.value)} + disabled={isSubmitting} + /> +

+ 공급 가능한 제품/서비스를 입력하세요 +

+
+ + {/* 사업자등록번호 */} +
+ + handleInputChange('taxId', e.target.value)} + disabled={isSubmitting} + placeholder="123-45-67890" + /> +
+ + {/* 주소 */} +
+ + handleInputChange('address', e.target.value)} + disabled={isSubmitting} + /> +
+ + {/* 국가 */} +
+ + + + + + + + + + No country found. + + {enhancedCountryArray.map((country) => ( + handleInputChange('country', country.code)} + > + + {country.label} + + ))} + + + + + +
+ + {/* 대표 전화 */} +
+ + handleInputChange('phone', e.target.value)} + placeholder={getPhonePlaceholder(data.country)} + disabled={isSubmitting} + /> +

+ {getPhoneDescription(data.country)} +

+
+ + {/* 대표 이메일 */} +
+ + handleInputChange('email', e.target.value)} + disabled={isSubmitting} + placeholder={accountData.email} + /> +

+ 비워두면 계정 이메일({accountData.email})을 사용합니다. +

+
+ + {/* 웹사이트 */} +
+ + handleInputChange('website', e.target.value)} + disabled={isSubmitting} + /> +
+
+
+ + {/* 담당자 정보 */} +
+
+

담당자 정보 (최소 1명)

+ +
+ +
+ {data.contacts.map((contact, index) => ( +
+
+
+ + updateContact(index, 'contactName', e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + updateContact(index, 'contactPosition', e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + updateContact(index, 'contactDepartment', e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + +
+ +
+ + updateContact(index, 'contactEmail', e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + updateContact(index, 'contactPhone', e.target.value)} + placeholder={getPhonePlaceholder(data.country)} + disabled={isSubmitting} + /> +
+
+ + {data.contacts.length > 1 && ( +
+ +
+ )} +
+ ))} +
+
+ + {/* 한국 사업자 정보 */} + {data.country === "KR" && ( +
+

한국 사업자 정보

+
+
+ + handleInputChange('representativeName', e.target.value)} + disabled={isSubmitting} + /> +
+
+ + handleInputChange('representativeBirth', e.target.value)} + disabled={isSubmitting} + /> +
+
+ + handleInputChange('representativeEmail', e.target.value)} + disabled={isSubmitting} + /> +
+
+ + handleInputChange('representativePhone', e.target.value)} + disabled={isSubmitting} + /> +
+
+ + handleInputChange('corporateRegistrationNumber', e.target.value)} + disabled={isSubmitting} + /> +
+
+ handleInputChange('representativeWorkExpirence', e.target.checked)} + disabled={isSubmitting} + className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + +
+
+
+ )} + + {/* 필수 첨부 서류 */} +
+

필수 첨부 서류

+ + + + + + + + {data.country !== "KR" && ( + + )} +
+ +
+ + +
+
+ ); +} + +// 파일 업로드 섹션 컴포넌트 +function FileUploadSection({ + title, + description, + files, + onDropAccepted, + onDropRejected, + removeFile, + isSubmitting, + required = true +}) { + return (
@@ -517,654 +1285,5 @@ export function JoinForm() {
)}
- ) - - // Render - return ( -
-
-
-
-

- {defaultTaxId}{" "} - {t("joinForm.title", { - defaultValue: "Vendor Administrator Creation", - })} -

-

- {t("joinForm.description", { - defaultValue: - "Please provide basic company information and attach any required documents (e.g., business registration). We will review and approve as soon as possible.", - })} -

-
- - - -
- - {/* ───────────────────────────────────────── - Basic Info - ───────────────────────────────────────── */} -
-

기본 정보

-
- {/* Vendor Type */} - { - const selectedType = vendorTypes.find(type => type.id === field.value); - const displayName = lng === "ko" ? - (selectedType?.nameKo || "") : - (selectedType?.nameEn || ""); - - return ( - - - 업체유형 - - - - - - - - - - - - No vendor type found. - - {vendorTypes.map((type) => ( - field.onChange(type.id)} - > - - {lng === "ko" ? type.nameKo : type.nameEn} - - ))} - - - - - - - - ); - }} - /> - - {/* vendorName */} - ( - - - 업체명 - - - - - - {form.watch("country") === "KR" - ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." - : "해외 업체의 경우 영문 회사명을 입력하세요."} - - - - )} - /> - - {/* Items */} - ( - - - 공급품목 - - - - - - 공급 가능한 제품/서비스를 입력하세요 - - - - )} - /> - - {/* Address */} - ( - - 주소 - - - - - - )} - /> - - {/* Country */} - { - const selectedCountry = enhancedCountryArray.find( - (c) => c.code === field.value - ) - return ( - - - 국가 - - - - - - - - - - - - No country found. - - {enhancedCountryArray.map((country) => ( - - field.onChange(country.code) - } - > - - {country.label} - - ))} - - - - - - - - ) - }} - /> - {/* Phone */} - ( - - - 대표 전화 - - - - - - {getPhoneDescription(form.watch("country"))} - - - - )} - /> - - {/* Email */} - ( - - - 대표 이메일 - - - - - - 회사 도메인 이메일을 사용하세요. (naver.com, gmail.com, daum.net 등의 개인 이메일은 지양해주세요) - - - - )} - /> - - {/* Website */} - ( - - 웹사이트 - - - - - - )} - /> -
-
- - {/* ───────────────────────────────────────── - 담당자 정보 (contacts) - ───────────────────────────────────────── */} -
-
-

담당자 정보 (최소 1명)

- -
- -
- {contactFields.map((contact, index) => ( -
-
- {/* contactName */} - ( - - - 담당자명 - - - - - - - )} - /> - - {/* contactPosition */} - ( - - - 직급 - - - - - - - )} - /> - - {/* contactDepartment */} - ( - - - 부서 - - - - - - - )} - /> - - {/* contactTask - Dropdown */} - ( - - - 담당업무 - - - - - )} - /> - - {/* contactEmail */} - ( - - - 이메일 - - - - - - - )} - /> - - {/* contactPhone */} - ( - - - 전화번호 - - - - - - - )} - /> -
- - {/* Remove contact button row */} - {contactFields.length > 1 && ( -
- -
- )} -
- ))} -
-
- - {/* ───────────────────────────────────────── - 한국 사업자 (country === "KR") - ───────────────────────────────────────── */} - {form.watch("country") === "KR" && ( -
-

한국 사업자 정보

- -
- ( - - - 대표자 이름 - - - - - - - )} - /> - ( - - - 대표자 생년월일 - - - - - - - )} - /> - ( - - - 대표자 이메일 - - - - - - - )} - /> - ( - - - 대표자 전화번호 - - - - - - - )} - /> - ( - - - 법인등록번호 - - - - - - - )} - /> - - ( - - - - -
- - 대표자 삼성중공업 근무이력 - - - 대표자가 삼성중공업에서 근무한 경험이 있는 경우 체크해주세요. - -
-
- )} - /> - -
-
- )} - - {/* ───────────────────────────────────────── - Required Document Uploads - ───────────────────────────────────────── */} -
-

필수 첨부 서류

- - {/* Business Registration */} - - - - - {/* ISO Certification */} - - - - - {/* Credit Report */} - - - {/* Bank Account Copy - Only for non-Korean companies */} - {form.watch("country") !== "KR" && ( - <> - - - - )} -
- - {/* ───────────────────────────────────────── - Submit - ───────────────────────────────────────── */} -
- -
- - -
-
-
- ) -} \ No newline at end of file + ); +} -- cgit v1.2.3