diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/data-table/data-table-filter-list.tsx | 44 | ||||
| -rw-r--r-- | components/data-table/data-table-grobal-filter.tsx | 4 | ||||
| -rw-r--r-- | components/data-table/data-table-view-options.tsx | 12 | ||||
| -rw-r--r-- | components/data-table/data-table.tsx | 7 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 13 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 4 | ||||
| -rw-r--r-- | components/polices/policy-editor.tsx | 262 | ||||
| -rw-r--r-- | components/polices/policy-history.tsx | 250 | ||||
| -rw-r--r-- | components/polices/policy-management-client.tsx | 429 | ||||
| -rw-r--r-- | components/polices/policy-preview.tsx | 191 | ||||
| -rw-r--r-- | components/qna/tiptap-editor.tsx | 98 | ||||
| -rw-r--r-- | components/signup/conset-step.tsx | 415 | ||||
| -rw-r--r-- | components/signup/join-form.tsx | 2021 |
13 files changed, 2752 insertions, 998 deletions
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<TData> { table: Table<TData> @@ -78,6 +79,10 @@ interface DataTableFilterListProps<TData> { onFiltersChange?: (filters: Filter<TData>[], joinOperator: JoinOperator) => void } +export function isSame(a: unknown, b: unknown) { + return JSON.stringify(a) === JSON.stringify(b) +} + export function DataTableFilterList<TData>({ table, filterFields, @@ -88,6 +93,11 @@ export function DataTableFilterList<TData>({ onFiltersChange, }: DataTableFilterListProps<TData>) { + const prevRef = React.useRef<{ + filters: Filter<TData>[] + join: JoinOperator + } | null>(null) + const params = useParams(); const lng = params ? (params.lng as string) : 'en'; @@ -114,13 +124,25 @@ export function DataTableFilterList<TData>({ }) ) + const safeSetFilters = React.useCallback( + (next: Filter<TData>[] | ((p: Filter<TData>[]) => Filter<TData>[])) => { + 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<TData>({ }, [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<TData>({ }: DataTableViewOptionsProps<TData>) { const triggerRef = React.useRef<HTMLButtonElement>(null) + const params = useParams(); const lng = params?.lng as string; const { t } = useTranslation(lng); @@ -115,11 +117,11 @@ export function DataTableViewOptions<TData>({ 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<TData>({ [compact] ); + const stableChildren = React.useMemo(() => { + console.log("📦 DataTable children 메모이제이션됨"); + return children; + }, [children]); + return ( <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}> - {children} + {stableChildren} <div className="max-w-[100vw] overflow-auto" style={{ maxHeight: maxHeight || '35rem' }} > <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> {/* 테이블 헤더 */} 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< </li> ); }); -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({ </div> </div> ) -}
\ 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<string[]>([]) + + 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 `<h1>개인정보 처리방침</h1> + +<h2>제1조 (목적)</h2> +<p>본 개인정보 처리방침은 eVCP(이하 "회사")가 개인정보 보호법 등 관련 법령에 따라 정보주체의 개인정보를 보호하고 이와 관련된 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같은 처리방침을 수립·공개합니다.</p> + +<h2>제2조 (개인정보의 수집 및 이용목적)</h2> +<p>회사는 다음의 목적을 위하여 개인정보를 처리합니다:</p> +<ul> + <li>회원 가입 및 관리</li> + <li>서비스 제공 및 계약 이행</li> + <li>고객 상담 및 불만 처리</li> +</ul> + +<h2>제3조 (개인정보의 수집항목)</h2> +<p><strong>필수항목:</strong></p> +<ul> + <li>이메일 주소</li> + <li>전화번호</li> + <li>회사명</li> +</ul> + +<h2>제4조 (개인정보의 보유 및 이용기간)</h2> +<p>회사는 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.</p> + +<h2>제5조 (정보주체의 권리)</h2> +<p>정보주체는 회사에 대해 언제든지 다음 각 호의 개인정보 보호 관련 권리를 행사할 수 있습니다:</p> +<ul> + <li>개인정보 처리현황 통지요구</li> + <li>개인정보 열람요구</li> + <li>개인정보 정정·삭제요구</li> + <li>개인정보 처리정지요구</li> +</ul>` + } else { + return `<h1>이용약관</h1> + +<h2>제1조 (목적)</h2> +<p>본 약관은 eVCP(이하 "회사")가 제공하는 서비스의 이용조건 및 절차, 회사와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p> + +<h2>제2조 (정의)</h2> +<ul> + <li><strong>"서비스"</strong>란 회사가 제공하는 모든 서비스를 의미합니다.</li> + <li><strong>"회원"</strong>이란 본 약관에 동의하고 회사와 서비스 이용계약을 체결한 자를 의미합니다.</li> + <li><strong>"업체"</strong>란 회사의 파트너로 등록된 법인 또는 개인사업자를 의미합니다.</li> +</ul> + +<h2>제3조 (약관의 효력 및 변경)</h2> +<p>본 약관은 서비스를 이용하고자 하는 모든 회원에 대하여 그 효력을 발생합니다.</p> + +<h2>제4조 (회원가입)</h2> +<p>회원가입은 신청자가 본 약관의 내용에 대하여 동의를 한 다음 회원가입신청을 하고 회사가 이러한 신청에 대하여 승낙함으로써 체결됩니다.</p> + +<h2>제5조 (서비스의 제공)</h2> +<p>회사는 회원에게 다음과 같은 서비스를 제공합니다:</p> +<ul> + <li>업체 등록 및 관리 서비스</li> + <li>문서 관리 서비스</li> + <li>견적 제출 서비스</li> +</ul>` + } + } + + 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 ( + <Card> + <CardHeader> + <CardTitle> + {currentPolicy ? '정책 편집' : '새 정책 생성'} - {policyLabels[policyType]} + </CardTitle> + <CardDescription> + {currentPolicy + ? `현재 버전 v${currentPolicy.version}을 기반으로 새 버전을 생성합니다.` + : `${policyLabels[policyType]}의 첫 번째 버전을 생성합니다.` + } + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {/* 경고 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 새 버전을 저장하면 즉시 활성화되어 모든 사용자에게 적용됩니다. + 저장하기 전에 미리보기로 내용을 확인해주세요. + </AlertDescription> + </Alert> + + {/* 유효성 검사 오류 */} + {validationErrors.length > 0 && ( + <Alert variant="destructive"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1"> + {validationErrors.map((error, index) => ( + <li key={index}>{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {/* 버전 입력 */} + <div className="space-y-2"> + <Label htmlFor="version">버전</Label> + <Input + id="version" + placeholder="예: 1.1" + value={version} + onChange={(e) => setVersion(e.target.value)} + disabled={isLoading} + className="w-32" + /> + <p className="text-xs text-muted-foreground"> + 형식: 주.부 (예: 1.0, 1.1, 2.0) + </p> + </div> + + <Separator /> + + {/* 정책 내용 편집기 */} + <div className="space-y-2"> + <Label>정책 내용</Label> + <div className="border rounded-md"> + <TiptapEditor + content={content} + setContent={setContent} + disabled={isLoading} + height="500px" + /> + </div> + <p className="text-xs text-muted-foreground"> + 리치 텍스트 편집기를 사용하여 정책 내용을 작성하세요. + 이미지, 표, 목록 등을 추가할 수 있습니다. + </p> + </div> + + {/* 액션 버튼들 */} + <div className="flex justify-between pt-4"> + <Button + variant="outline" + onClick={onCancel} + disabled={isLoading} + > + <X className="h-4 w-4 mr-2" /> + 취소 + </Button> + + <div className="flex gap-2"> + <Button + variant="outline" + onClick={handlePreview} + disabled={isLoading || !content.trim() || !version.trim()} + > + <Eye className="h-4 w-4 mr-2" /> + 미리보기 + </Button> + <Button + onClick={handleSave} + disabled={isLoading || !content.trim() || !version.trim()} + > + <Save className="h-4 w-4 mr-2" /> + {isLoading ? '저장 중...' : '저장'} + </Button> + </div> + </div> + </CardContent> + </Card> + ) +}
\ 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<PolicyData | null>(null) // ✅ 상세보기 상태 + + const policyLabels: Record<string, string> = { + 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 ( + <PolicyPreview + data={{ + policyType: viewingPolicy.policyType, + content: viewingPolicy.content, + version: viewingPolicy.version, + effectiveDate: viewingPolicy.effectiveDate, + id: viewingPolicy.id, + isCurrent: viewingPolicy.isCurrent, + createdAt: viewingPolicy.createdAt + }} + onClose={handleCloseDetail} + mode="view" + /> + ) + } + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + <Clock className="h-5 w-5" /> + 정책 히스토리 + </CardTitle> + <CardDescription> + {policyLabels[policyType]}의 모든 버전을 확인하고 관리합니다 + </CardDescription> + </div> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={onEdit} + disabled={isLoading} + > + 새 버전 생성 + </Button> + <Button + variant="outline" + size="sm" + onClick={onClose} + disabled={isLoading} + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + </CardHeader> + <CardContent> + {/* ✅ 로딩 상태 */} + {isLoading && ( + <div className="flex items-center justify-center py-8"> + <div className="text-muted-foreground">히스토리를 불러오는 중...</div> + </div> + )} + + {/* ✅ 에러 상태 체크 */} + {!isLoading && safePolicies && !Array.isArray(safePolicies) && ( + <Alert variant="destructive" className="mb-4"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + 정책 데이터 형식이 올바르지 않습니다. (받은 데이터: {typeof safePolicies}) + </AlertDescription> + </Alert> + )} + + {/* ✅ 정책 목록 */} + {!isLoading && ( + <div className="space-y-4"> + {safePolicies.length === 0 ? ( + <div className="text-center py-8"> + <div className="text-muted-foreground mb-2"> + <Info className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p>등록된 정책 버전이 없습니다.</p> + </div> + <Button onClick={onEdit} size="sm"> + 첫 번째 버전 생성하기 + </Button> + </div> + ) : ( + <> + {/* ✅ 정책 개수 표시 */} + <div className="flex items-center justify-between mb-4"> + <div className="text-sm text-muted-foreground"> + 총 {safePolicies.length}개 버전 + </div> + {currentPolicy && ( + <Badge variant="outline" className="text-xs"> + 현재: v{currentPolicy.version} + </Badge> + )} + </div> + + {/* ✅ 정책 목록 렌더링 */} + {safePolicies.map((policy: PolicyData) => ( + <div + key={policy.id} + className={`p-4 border rounded-lg transition-colors ${ + policy.isCurrent + ? 'border-green-200 bg-green-50' + : 'border-border hover:bg-muted/30' + }`} + > + <div className="flex items-start justify-between"> + <div className="flex-1 min-w-0"> + {/* ✅ 헤더 */} + <div className="flex items-center gap-2 mb-2"> + <h4 className="font-medium text-base"> + 버전 {policy.version} + </h4> + {policy.isCurrent && ( + <Badge className="bg-green-100 text-green-800 text-xs"> + 현재 활성 + </Badge> + )} + </div> + + {/* ✅ 메타 정보 */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3 text-sm text-muted-foreground"> + <div className="flex items-center gap-1"> + <Calendar className="h-3 w-3" /> + 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} + </div> + <div className="flex items-center gap-1"> + <Clock className="h-3 w-3" /> + 생성일: {new Date(policy.createdAt).toLocaleDateString('ko-KR')} + </div> + </div> + + {/* ✅ 내용 미리보기 */} + <div className="mt-2"> + <div className="text-sm bg-muted/50 p-3 rounded border max-h-20 overflow-hidden"> + {getContentPreview(policy.content)} + </div> + </div> + </div> + + {/* ✅ 액션 버튼들 */} + <div className="flex flex-col gap-2 ml-4 flex-shrink-0"> + {!policy.isCurrent && ( + <Button + size="sm" + onClick={() => onActivate(policy.id, policyType)} + disabled={isLoading} + className="whitespace-nowrap" + > + 활성화 + </Button> + )} + <Button + variant="outline" + size="sm" + onClick={() => handleViewDetail(policy)} + disabled={isLoading} + className="whitespace-nowrap" + > + <Eye className="h-3 w-3 mr-1" /> + 상세보기 + </Button> + </div> + </div> + </div> + ))} + </> + )} + </div> + )} + </CardContent> + </Card> + ) +}
\ 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<string, any> + allPolicies: Record<string, any[]> + stats: any + } +} + +export function PolicyManagementClient({ initialData }: PolicyManagementClientProps) { + const [currentTab, setCurrentTab] = useState('privacy_policy') + const [editingPolicy, setEditingPolicy] = useState<string | null>(null) + const [viewingHistory, setViewingHistory] = useState<string | null>(null) + const [previewData, setPreviewData] = useState<any>(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: <Shield className="h-4 w-4" />, + description: '개인정보 수집, 이용, 보관 및 파기에 관한 정책' + }, + { + key: 'terms_of_service', + label: '이용약관', + icon: <FileText className="h-4 w-4" />, + 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 ( + <PolicyEditor + policyType={currentTab} + currentPolicy={currentPolicy} + onSave={handleSavePolicy} + onCancel={() => setEditingPolicy(null)} + onPreview={handlePreview} + isLoading={isPending} + /> + ) + } + + if (viewingHistory === currentTab) { + return ( + <PolicyHistory + policyType={currentTab} + policies={policies[currentTab] || []} // ✅ 안전한 기본값 + currentPolicy={currentPolicy} + onActivate={handleActivatePolicy} + onEdit={() => setEditingPolicy(currentTab)} + onClose={() => setViewingHistory(null)} + isLoading={isPending} + /> + ) + } + + if (previewData && previewData.policyType === currentTab) { + return ( + <PolicyPreview + data={previewData} + onSave={() => handleSavePolicy(previewData.policyType, previewData.version, previewData.content)} + onEdit={() => setEditingPolicy(currentTab)} + onClose={() => setPreviewData(null)} + isLoading={isPending} + /> + ) + } + + // 기본 정책 관리 화면 + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + {policyInfo?.icon} + {policyInfo?.label} + </CardTitle> + <CardDescription> + {policyInfo?.description} + </CardDescription> + </div> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => handleCreateNew(currentTab)} + disabled={isPending} + > + <Plus className="h-4 w-4 mr-1" /> + 새 버전 + </Button> + {currentPolicy && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => handleEdit(currentTab)} + disabled={isPending} + > + <Edit className="h-4 w-4 mr-1" /> + 편집 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleViewHistory(currentTab)} + disabled={isPending} + > + <History className="h-4 w-4 mr-1" /> + 히스토리 + </Button> + </> + )} + </div> + </div> + </CardHeader> + <CardContent> + {currentPolicy ? ( + <div className="space-y-4"> + {/* 현재 정책 정보 */} + <div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-3"> + <CheckCircle2 className="h-5 w-5 text-green-600" /> + <div> + <p className="font-medium text-green-900"> + 현재 활성 버전: v{currentPolicy.version} + </p> + <p className="text-sm text-green-700"> + 시행일: {new Date(currentPolicy.effectiveDate).toLocaleDateString('ko-KR')} + </p> + </div> + </div> + <Badge variant="secondary" className="bg-green-100 text-green-800"> + 활성 + </Badge> + </div> + + {/* 정책 내용 미리보기 */} + <div className="space-y-2"> + <h4 className="font-medium">정책 내용 미리보기</h4> + <div className="bg-muted/50 p-4 rounded-md max-h-64 overflow-y-auto"> + <div + className="prose prose-sm max-w-none" + dangerouslySetInnerHTML={{ + __html: currentPolicy.content.substring(0, 1000) + (currentPolicy.content.length > 1000 ? '...' : '') + }} + /> + </div> + </div> + + {/* 메타 정보 */} + <div className="grid grid-cols-2 gap-4 pt-4 border-t"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Calendar className="h-4 w-4" /> + 생성일: {new Date(currentPolicy.createdAt).toLocaleString('ko-KR')} + </div> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Clock className="h-4 w-4" /> + 버전: {currentPolicy.version} + </div> + </div> + </div> + ) : ( + <div className="text-center py-12"> + <AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> + <h3 className="text-lg font-medium mb-2">정책이 등록되지 않았습니다</h3> + <p className="text-muted-foreground mb-4"> + {policyInfo?.label}의 첫 번째 버전을 생성해주세요. + </p> + <Button onClick={() => handleCreateNew(currentTab)}> + <Plus className="h-4 w-4 mr-2" /> + 첫 번째 버전 생성 + </Button> + </div> + )} + </CardContent> + </Card> + ) + } + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <h2 className="text-2xl font-semibold">정책 편집</h2> + {isPending && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + 처리 중... + </div> + )} + </div> + + <Tabs value={currentTab} onValueChange={setCurrentTab}> + <TabsList> + {policyTypes.map(policy => ( + <TabsTrigger + key={policy.key} + value={policy.key} + className="flex items-center gap-2" + > + {policy.icon} + {policy.label} + </TabsTrigger> + ))} + </TabsList> + + {policyTypes.map(policy => ( + <TabsContent key={policy.key} value={policy.key}> + {renderMainContent()} + </TabsContent> + ))} + </Tabs> + </div> + ) +}
\ 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<string, string> = { + privacy_policy: '개인정보 처리방침', + terms_of_service: '이용약관' + } + + const policyIcons = { + privacy_policy: <Shield className="h-5 w-5" />, + terms_of_service: <FileText className="h-5 w-5" /> + } + + // ✅ 모드에 따른 제목과 설명 + 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 <Badge variant="outline" className="text-orange-600">저장 대기</Badge> + } + if (data.isCurrent) { + return <Badge className="bg-green-100 text-green-800">현재 활성</Badge> + } + return <Badge variant="secondary">비활성</Badge> + } + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + {policyIcons[data.policyType]} + <div> + <CardTitle>{getTitle()}</CardTitle> + <CardDescription> + {getDescription()} + </CardDescription> + </div> + </div> + <Button variant="outline" size="sm" onClick={onClose}> + <X className="h-4 w-4" /> + </Button> + </div> + </CardHeader> + <CardContent className="space-y-6"> + {/* ✅ 정책 메타 정보 */} + <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"> + <div> + <p className="text-sm font-medium">정책 유형</p> + <p className="text-sm text-muted-foreground">{policyLabels[data.policyType]}</p> + </div> + <div> + <p className="text-sm font-medium">버전</p> + <p className="text-sm text-muted-foreground">v{data.version}</p> + </div> + <div> + <p className="text-sm font-medium">시행일</p> + <p className="text-sm text-muted-foreground"> + {new Date(data.effectiveDate).toLocaleDateString('ko-KR')} + </p> + </div> + <div> + <p className="text-sm font-medium">상태</p> + <div className="flex items-center gap-2"> + {getStatusBadge()} + </div> + </div> + + {/* ✅ 상세보기 모드일 때 추가 정보 */} + {mode === 'view' && data.createdAt && ( + <> + <div> + <p className="text-sm font-medium">생성일</p> + <p className="text-sm text-muted-foreground"> + {new Date(data.createdAt).toLocaleString('ko-KR')} + </p> + </div> + <div> + <p className="text-sm font-medium">문서 ID</p> + <p className="text-sm text-muted-foreground">#{data.id}</p> + </div> + </> + )} + </div> + + <Separator /> + + {/* ✅ 정책 내용 미리보기 */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="font-medium">정책 내용</h4> + <div className="text-xs text-muted-foreground"> + {data.content.replace(/<[^>]*>/g, '').length}자 + </div> + </div> + <div className="bg-white border rounded-md p-6 max-h-96 overflow-y-auto"> + <div + className="prose prose-sm max-w-none" + dangerouslySetInnerHTML={{ __html: data.content }} + /> + </div> + </div> + + {/* ✅ 액션 버튼들 */} + <div className="flex justify-between pt-4"> + {mode === 'preview' ? ( + // 미리보기 모드 버튼들 + <> + <Button + variant="outline" + onClick={onEdit} + disabled={isLoading} + > + 편집으로 돌아가기 + </Button> + + <Button + onClick={onSave} + disabled={isLoading} + className="bg-green-600 hover:bg-green-700" + > + <Save className="h-4 w-4 mr-2" /> + {isLoading ? '저장 중...' : '저장 및 활성화'} + </Button> + </> + ) : ( + // 상세보기 모드 버튼들 + <div className="w-full flex justify-end"> + <Button + variant="outline" + onClick={onClose} + disabled={isLoading} + > + 닫기 + </Button> + </div> + )} + </div> + </CardContent> + </Card> + ) +}
\ 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<string> { 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 ( + <div + className="border rounded-md bg-background flex items-center justify-center" + style={{ height }} + > + <div className="text-muted-foreground text-sm">에디터를 로딩 중...</div> + </div> + ) } -}; // 높이 계산 (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<PolicyVersions | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [showPrivacyModal, setShowPrivacyModal] = useState(false); + const [showTermsModal, setShowTermsModal] = useState(false); + const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({}); + + 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 ( + <div className="flex items-center justify-center p-8"> + <div className="flex items-center gap-2 text-gray-600"> + <Loader2 className="h-4 w-4 animate-spin" /> + 정책 내용을 불러오는 중... + </div> + </div> + ); + } + + // ✅ 에러 상태 + if (error || !policyData) { + return ( + <div className="text-center p-8"> + <div className="text-red-600 mb-4"> + {error || '정책 내용을 불러올 수 없습니다.'} + </div> + <Button onClick={fetchPolicyData} variant="outline"> + 다시 시도 + </Button> + </div> + ); + } + + // ✅ 필수 정책이 없는 경우 + if (!policyData.privacy_policy || !policyData.terms_of_service) { + return ( + <div className="text-center p-8"> + <div className="text-amber-600 mb-4"> + 일부 정책이 설정되지 않았습니다. 관리자에게 문의해주세요. + </div> + <div className="text-sm text-gray-500"> + {!policyData.privacy_policy && '개인정보 처리방침이 없습니다.'}<br /> + {!policyData.terms_of_service && '이용약관이 없습니다.'} + </div> + </div> + ); + } + + return ( + <div className="space-y-6"> + <div> + <h2 className="text-xl font-semibold mb-2">서비스 이용 약관 동의</h2> + <p className="text-gray-600 text-sm"> + 서비스 이용을 위해 다음 약관에 동의해주세요. 각 항목을 클릭하여 상세 내용을 확인할 수 있습니다. + </p> + </div> + + <div className="space-y-4"> + {/* ✅ 개인정보 처리방침 */} + {policyData.privacy_policy && ( + <PolicyConsentSection + id="privacy-consent" + type="privacy" + checked={data.privacy} + onChange={handleConsentChange} + policy={policyData.privacy_policy} + isRequired={true} + icon={<Shield className="w-4 h-4" />} + title="개인정보 처리방침" + description="개인정보 수집, 이용, 보관 및 파기에 관한 정책입니다." + expanded={expandedSections.privacy} + onToggleExpand={() => toggleSection('privacy')} + onShowModal={() => setShowPrivacyModal(true)} + /> + )} + + <Separator /> + + {/* ✅ 이용약관 */} + {policyData.terms_of_service && ( + <PolicyConsentSection + id="terms-consent" + type="terms" + checked={data.terms} + onChange={handleConsentChange} + policy={policyData.terms_of_service} + isRequired={true} + icon={<FileText className="w-4 h-4" />} + title="이용약관" + description="서비스 이용 시 준수해야 할 규칙과 조건입니다." + expanded={expandedSections.terms} + onToggleExpand={() => toggleSection('terms')} + onShowModal={() => setShowTermsModal(true)} + /> + )} + + + {/* ✅ 전체 동의 */} + <div className="pt-4 border-t bg-gray-50 p-4 rounded-lg"> + <div className="flex items-center space-x-3"> + <input + type="checkbox" + id="all-consent" + checked={data.privacy && data.terms && data.marketing} + onChange={(e) => { + 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" + /> + <label htmlFor="all-consent" className="text-base font-medium cursor-pointer"> + 위 내용에 모두 동의합니다 + </label> + </div> + </div> + </div> + + <div className="flex justify-end"> + <Button onClick={onNext} disabled={!isValid} size="lg"> + 다음 단계로 + </Button> + </div> + + {/* ✅ 개인정보 처리방침 상세 모달 */} + {showPrivacyModal && policyData.privacy_policy && ( + <PolicyModal + policy={policyData.privacy_policy} + onClose={() => setShowPrivacyModal(false)} + onAgree={() => { + onChange(prev => ({ ...prev, privacy: true })); + setShowPrivacyModal(false); + }} + /> + )} + + {/* ✅ 이용약관 상세 모달 */} + {showTermsModal && policyData.terms_of_service && ( + <PolicyModal + policy={policyData.terms_of_service} + onClose={() => setShowTermsModal(false)} + onAgree={() => { + onChange(prev => ({ ...prev, terms: true })); + setShowTermsModal(false); + }} + /> + )} + </div> + ); +} + +// ✅ 개별 정책 동의 섹션 컴포넌트 +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 ( + <div className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"> + {/* 체크박스와 기본 정보 */} + <div className="flex items-start space-x-3"> + <input + type="checkbox" + id={id} + checked={checked} + onChange={(e) => onChange(type, e.target.checked)} + className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + <div className="flex-1"> + <div className="flex items-center space-x-2 mb-1"> + {icon} + <label htmlFor={id} className="text-sm font-medium cursor-pointer"> + <span className={isRequired ? "text-red-500" : "text-blue-500"}> + [{isRequired ? "필수" : "선택"}] + </span> {title} (v{policy.version}) + </label> + </div> + + <p className="text-xs text-gray-600 mb-2">{description}</p> + + {/* ✅ 정책 미리보기 - HTML 내용 표시 */} + <div className="bg-gray-50 p-3 rounded text-xs text-gray-700 mb-2"> + {renderPolicyPreview(policy.content, expanded ? 1000 : 200)} + </div> + + {/* 액션 버튼들 */} + <div className="flex items-center space-x-3 text-xs"> + <button + type="button" + onClick={onToggleExpand} + className="flex items-center space-x-1 text-blue-600 hover:underline" + > + {expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} + <span>{expanded ? '간략히' : '더보기'}</span> + </button> + <span className="text-gray-400">|</span> + <button + type="button" + onClick={onShowModal} + className="text-blue-600 hover:underline" + > + 전문보기 + </button> + <span className="text-gray-400">|</span> + <span className="text-gray-500"> + 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} + </span> + </div> + </div> + </div> + </div> + ); +} + +// ✅ 정책 상세 모달 컴포넌트 +interface PolicyModalProps { + policy: PolicyData; + onClose: () => void; + onAgree: () => void; +} + +function PolicyModal({ policy, onClose, onAgree }: PolicyModalProps) { + const getPolicyTitle = (policyType: string): string => { + return policyType === 'privacy_policy' ? '개인정보 처리방침' : '이용약관'; + }; + + return ( + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> + <div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col"> + {/* 헤더 */} + <div className="flex items-center justify-between p-6 border-b"> + <div> + <h3 className="text-xl font-semibold"> + {getPolicyTitle(policy.policyType)} + </h3> + <p className="text-sm text-gray-600 mt-1"> + 버전 {policy.version} | 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} + </p> + </div> + <button + onClick={onClose} + className="text-gray-400 hover:text-gray-600 text-2xl leading-none" + > + × + </button> + </div> + + {/* ✅ 내용 - HTML 직접 렌더링 */} + <ScrollArea className="flex-1 p-6"> + <div + className="prose prose-sm max-w-none text-gray-700 leading-relaxed" + dangerouslySetInnerHTML={{ __html: policy.content }} + /> + </ScrollArea> + + {/* 푸터 */} + <div className="flex gap-3 p-6 border-t bg-gray-50"> + <Button + variant="outline" + onClick={onClose} + className="flex-1" + > + 닫기 + </Button> + <Button + onClick={onAgree} + className="flex-1" + > + 동의하고 닫기 + </Button> + </div> + </div> + </div> + ); +}
\ 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<VendorType[]>([]) - const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true) - - // Individual file states - const [businessRegistrationFiles, setBusinessRegistrationFiles] = React.useState<File[]>([]) - const [isoCertificationFiles, setIsoCertificationFiles] = React.useState<File[]>([]) - const [creditReportFiles, setCreditReportFiles] = React.useState<File[]>([]) - const [bankAccountFiles, setBankAccountFiles] = React.useState<File[]>([]) - - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // 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<CreateVendorSchema>({ - 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 ( + <div className="container max-w-6xl mx-auto py-8"> + {/* 진행률 표시 */} + <div className="mb-8"> + <div className="flex items-center justify-between mb-4"> + <h1 className="text-2xl font-bold">파트너 등록</h1> + <span className="text-sm text-muted-foreground"> + {currentStep} / {STEPS.length} + </span> + </div> + <Progress value={progress} className="mb-6" /> + + {/* 스텝 네비게이션 */} + <div className="flex items-center justify-between"> + {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 ( + <React.Fragment key={step.id}> + <div + className={cn( + "flex flex-col items-center cursor-pointer transition-all", + isAccessible ? "opacity-100" : "opacity-50 cursor-not-allowed" + )} + onClick={() => handleStepClick(step.id)} + > + <div className={cn( + "w-12 h-12 rounded-full flex items-center justify-center mb-2 border-2 transition-all", + isCompleted + ? "bg-green-500 border-green-500 text-white" + : isCurrent + ? "bg-blue-500 border-blue-500 text-white" + : "border-gray-300 text-gray-400" + )}> + {isCompleted ? ( + <Check className="w-6 h-6" /> + ) : ( + <Icon className="w-6 h-6" /> + )} + </div> + <div className="text-center"> + <div className={cn( + "text-sm font-medium", + isCurrent ? "text-blue-600" : isCompleted ? "text-green-600" : "text-gray-500" + )}> + {step.title} + </div> + <div className="text-xs text-gray-400 mt-1"> + {step.description} + </div> + </div> + </div> + + {index < STEPS.length - 1 && ( + <ChevronRight className="w-5 h-5 text-gray-300 mx-4" /> + )} + </React.Fragment> + ); + })} + </div> + </div> + + {/* 스텝 콘텐츠 */} + <div className="bg-white rounded-lg border shadow-sm p-6"> + {currentStep === 1 && ( + <ConsentStep + data={consentData} + onChange={setConsentData} + onNext={() => handleStepComplete(1)} + policyVersions={policyVersions} + /> + )} + + {currentStep === 2 && ( + <AccountStep + data={accountData} + onChange={setAccountData} + onNext={() => handleStepComplete(2)} + onBack={() => setCurrentStep(1)} + /> + )} + + {currentStep === 3 && ( + <VendorStep + data={vendorData} + onChange={setVendorData} + onBack={() => 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} + /> + )} + </div> + </div> + ); +} + + +// 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 ( + <div className="space-y-6"> + <div> + <h2 className="text-xl font-semibold mb-2">계정 정보 입력</h2> + <p className="text-gray-600 text-sm"> + 서비스 이용을 위한 개인 계정을 생성합니다. + </p> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label className="block text-sm font-medium mb-1"> + 이름 <span className="text-red-500">*</span> + </label> + <Input + type="text" + placeholder="이름을 입력하세요" + value={data.name} + onChange={(e) => handleInputChange('name', e.target.value)} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 이메일 <span className="text-red-500">*</span> + </label> + <Input + type="email" + placeholder="이메일을 입력하세요" + value={data.email} + onChange={(e) => handleInputChange('email', e.target.value)} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 비밀번호 <span className="text-red-500">*</span> + </label> + <Input + type="password" + placeholder="비밀번호를 입력하세요" + value={data.password} + onChange={(e) => handleInputChange('password', e.target.value)} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 비밀번호 확인 <span className="text-red-500">*</span> + </label> + <Input + type="password" + placeholder="비밀번호를 다시 입력하세요" + value={data.confirmPassword} + onChange={(e) => handleInputChange('confirmPassword', e.target.value)} + /> + {data.confirmPassword && data.password !== data.confirmPassword && ( + <p className="text-xs text-red-500 mt-1">비밀번호가 일치하지 않습니다.</p> + )} + </div> + + <div className="md:col-span-2"> + <label className="block text-sm font-medium mb-1"> + 전화번호 <span className="text-red-500">*</span> + </label> + <Input + type="tel" + placeholder="+82-10-1234-5678" + value={data.phone} + onChange={(e) => handleInputChange('phone', e.target.value)} + /> + <p className="text-xs text-gray-500 mt-1"> + SMS 인증에 사용됩니다. 국제번호 형식으로 입력해주세요. + </p> + </div> + </div> + + <div className="flex justify-between"> + <Button variant="outline" onClick={onBack}> + 이전 + </Button> + <Button onClick={handleNext} disabled={!isValid || isLoading}> + {isLoading ? '확인 중...' : '다음 단계'} + </Button> + </div> + </div> + ); +} + +// Step 3: 업체 등록 (기존 JoinForm 내용) +function VendorStep(props) { + return <CompleteVendorForm {...props} />; +} + + +// 완전한 업체 등록 폼 컴포넌트 (기존 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<React.SetStateAction<File[]>>, - currentFiles: File[] - ) => ({ - onDropAccepted: (acceptedFiles: File[]) => { - const newFiles = [...currentFiles, ...acceptedFiles] - setFiles(newFiles) - }, - onDropRejected: (fileRejections: any[]) => { - fileRejections.forEach((rej) => { - toast({ - variant: "destructive", - title: "File Error", - description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, - }) - }) - }, - 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 ( + <div className="space-y-8"> + <div> + <h2 className="text-xl font-semibold mb-2">업체 정보 등록</h2> + <p className="text-gray-600 text-sm"> + 업체 정보와 필요한 서류를 등록해주세요. 모든 정보는 관리자 검토 후 승인됩니다. + </p> + </div> - // File display component - const FileUploadSection = ({ - title, - description, - files, - onDropAccepted, - onDropRejected, - removeFile, - required = true - }: { - title: string; - description: string; - files: File[]; - onDropAccepted: (files: File[]) => void; - onDropRejected: (rejections: any[]) => void; - removeFile: (index: number) => void; - required?: boolean; - }) => ( + {/* 기본 정보 */} + <div className="rounded-md border p-6 space-y-4"> + <h4 className="text-md font-semibold">기본 정보</h4> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 업체 유형 */} + <div> + <label className="block text-sm font-medium mb-1"> + 업체유형 <span className="text-red-500">*</span> + </label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !data.vendorTypeId && "text-muted-foreground" + )} + disabled={isSubmitting || isLoadingVendorTypes} + > + {isLoadingVendorTypes + ? "Loading..." + : vendorTypes.find(type => type.id === data.vendorTypeId)?.[lng === "ko" ? "nameKo" : "nameEn"] || "업체유형 선택"} + <ChevronsUpDown className="ml-2 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="업체유형 검색..." /> + <CommandList> + <CommandEmpty>No vendor type found.</CommandEmpty> + <CommandGroup> + {vendorTypes.map((type) => ( + <CommandItem + key={type.id} + value={lng === "ko" ? type.nameKo : type.nameEn} + onSelect={() => handleInputChange('vendorTypeId', type.id)} + > + <Check + className={cn( + "mr-2", + type.id === data.vendorTypeId + ? "opacity-100" + : "opacity-0" + )} + /> + {lng === "ko" ? type.nameKo : type.nameEn} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + + {/* 업체명 */} + <div> + <label className="block text-sm font-medium mb-1"> + 업체명 <span className="text-red-500">*</span> + </label> + <Input + value={data.vendorName} + onChange={(e) => handleInputChange('vendorName', e.target.value)} + disabled={isSubmitting} + /> + <p className="text-xs text-gray-500 mt-1"> + {data.country === "KR" + ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." + : "해외 업체의 경우 영문 회사명을 입력하세요."} + </p> + </div> + + {/* 공급품목 */} + <div> + <label className="block text-sm font-medium mb-1"> + 공급품목 <span className="text-red-500">*</span> + </label> + <Input + value={data.items} + onChange={(e) => handleInputChange('items', e.target.value)} + disabled={isSubmitting} + /> + <p className="text-xs text-gray-500 mt-1"> + 공급 가능한 제품/서비스를 입력하세요 + </p> + </div> + + {/* 사업자등록번호 */} + <div> + <label className="block text-sm font-medium mb-1"> + 사업자등록번호 <span className="text-red-500">*</span> + </label> + <Input + value={data.taxId} + onChange={(e) => handleInputChange('taxId', e.target.value)} + disabled={isSubmitting} + placeholder="123-45-67890" + /> + </div> + + {/* 주소 */} + <div> + <label className="block text-sm font-medium mb-1">주소</label> + <Input + value={data.address} + onChange={(e) => handleInputChange('address', e.target.value)} + disabled={isSubmitting} + /> + </div> + + {/* 국가 */} + <div> + <label className="block text-sm font-medium mb-1"> + 국가 <span className="text-red-500">*</span> + </label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !data.country && "text-muted-foreground" + )} + disabled={isSubmitting} + > + {enhancedCountryArray.find(c => c.code === data.country)?.label || "국가 선택"} + <ChevronsUpDown className="ml-2 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="국가 검색..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {enhancedCountryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => handleInputChange('country', country.code)} + > + <Check + className={cn( + "mr-2", + country.code === data.country + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + + {/* 대표 전화 */} + <div> + <label className="block text-sm font-medium mb-1"> + 대표 전화 <span className="text-red-500">*</span> + </label> + <Input + value={data.phone} + onChange={(e) => handleInputChange('phone', e.target.value)} + placeholder={getPhonePlaceholder(data.country)} + disabled={isSubmitting} + /> + <p className="text-xs text-gray-500 mt-1"> + {getPhoneDescription(data.country)} + </p> + </div> + + {/* 대표 이메일 */} + <div> + <label className="block text-sm font-medium mb-1"> + 대표 이메일 <span className="text-red-500">*</span> + </label> + <Input + value={data.email} + onChange={(e) => handleInputChange('email', e.target.value)} + disabled={isSubmitting} + placeholder={accountData.email} + /> + <p className="text-xs text-gray-500 mt-1"> + 비워두면 계정 이메일({accountData.email})을 사용합니다. + </p> + </div> + + {/* 웹사이트 */} + <div> + <label className="block text-sm font-medium mb-1">웹사이트</label> + <Input + value={data.website} + onChange={(e) => handleInputChange('website', e.target.value)} + disabled={isSubmitting} + /> + </div> + </div> + </div> + + {/* 담당자 정보 */} + <div className="rounded-md border p-6 space-y-4"> + <div className="flex items-center justify-between"> + <h4 className="text-md font-semibold">담당자 정보 (최소 1명)</h4> + <Button + type="button" + variant="outline" + onClick={addContact} + disabled={isSubmitting} + > + <Plus className="mr-1 h-4 w-4" /> + 담당자 추가 + </Button> + </div> + + <div className="space-y-4"> + {data.contacts.map((contact, index) => ( + <div + key={index} + className="bg-muted/10 rounded-md p-4 space-y-4" + > + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <div> + <label className="block text-sm font-medium mb-1"> + 담당자명 <span className="text-red-500">*</span> + </label> + <Input + value={contact.contactName} + onChange={(e) => updateContact(index, 'contactName', e.target.value)} + disabled={isSubmitting} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 직급 <span className="text-red-500">*</span> + </label> + <Input + value={contact.contactPosition} + onChange={(e) => updateContact(index, 'contactPosition', e.target.value)} + disabled={isSubmitting} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 부서 <span className="text-red-500">*</span> + </label> + <Input + value={contact.contactDepartment} + onChange={(e) => updateContact(index, 'contactDepartment', e.target.value)} + disabled={isSubmitting} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 담당업무 <span className="text-red-500">*</span> + </label> + <Select + value={contact.contactTask} + onValueChange={(value) => updateContact(index, 'contactTask', value)} + disabled={isSubmitting} + > + <SelectTrigger> + <SelectValue placeholder="담당업무를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {contactTaskOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 이메일 <span className="text-red-500">*</span> + </label> + <Input + value={contact.contactEmail} + onChange={(e) => updateContact(index, 'contactEmail', e.target.value)} + disabled={isSubmitting} + /> + </div> + + <div> + <label className="block text-sm font-medium mb-1"> + 전화번호 <span className="text-red-500">*</span> + </label> + <Input + value={contact.contactPhone} + onChange={(e) => updateContact(index, 'contactPhone', e.target.value)} + placeholder={getPhonePlaceholder(data.country)} + disabled={isSubmitting} + /> + </div> + </div> + + {data.contacts.length > 1 && ( + <div className="flex justify-end"> + <Button + variant="destructive" + onClick={() => removeContact(index)} + disabled={isSubmitting} + > + <X className="mr-1 h-4 w-4" /> + 삭제 + </Button> + </div> + )} + </div> + ))} + </div> + </div> + + {/* 한국 사업자 정보 */} + {data.country === "KR" && ( + <div className="rounded-md border p-6 space-y-4"> + <h4 className="text-md font-semibold">한국 사업자 정보</h4> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label className="block text-sm font-medium mb-1"> + 대표자 이름 <span className="text-red-500">*</span> + </label> + <Input + value={data.representativeName} + onChange={(e) => handleInputChange('representativeName', e.target.value)} + disabled={isSubmitting} + /> + </div> + <div> + <label className="block text-sm font-medium mb-1"> + 대표자 생년월일 <span className="text-red-500">*</span> + </label> + <Input + placeholder="YYYY-MM-DD" + value={data.representativeBirth} + onChange={(e) => handleInputChange('representativeBirth', e.target.value)} + disabled={isSubmitting} + /> + </div> + <div> + <label className="block text-sm font-medium mb-1"> + 대표자 이메일 <span className="text-red-500">*</span> + </label> + <Input + value={data.representativeEmail} + onChange={(e) => handleInputChange('representativeEmail', e.target.value)} + disabled={isSubmitting} + /> + </div> + <div> + <label className="block text-sm font-medium mb-1"> + 대표자 전화번호 <span className="text-red-500">*</span> + </label> + <Input + value={data.representativePhone} + onChange={(e) => handleInputChange('representativePhone', e.target.value)} + disabled={isSubmitting} + /> + </div> + <div> + <label className="block text-sm font-medium mb-1"> + 법인등록번호 <span className="text-red-500">*</span> + </label> + <Input + value={data.corporateRegistrationNumber} + onChange={(e) => handleInputChange('corporateRegistrationNumber', e.target.value)} + disabled={isSubmitting} + /> + </div> + <div className="flex items-center space-x-2"> + <input + type="checkbox" + id="work-experience" + checked={data.representativeWorkExpirence} + onChange={(e) => handleInputChange('representativeWorkExpirence', e.target.checked)} + disabled={isSubmitting} + className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + <label htmlFor="work-experience" className="text-sm"> + 대표자 삼성중공업 근무이력 + </label> + </div> + </div> + </div> + )} + + {/* 필수 첨부 서류 */} + <div className="rounded-md border p-6 space-y-6"> + <h4 className="text-md font-semibold">필수 첨부 서류</h4> + + <FileUploadSection + title="사업자등록증" + description="사업자등록증 스캔본 또는 사진을 업로드해주세요." + files={businessRegistrationFiles} + onDropAccepted={businessRegistrationHandler.onDropAccepted} + onDropRejected={businessRegistrationHandler.onDropRejected} + removeFile={businessRegistrationHandler.removeFile} + isSubmitting={isSubmitting} + /> + + <FileUploadSection + title="ISO 인증서" + description="ISO 9001, ISO 14001 등 품질/환경 관리 인증서를 업로드해주세요." + files={isoCertificationFiles} + onDropAccepted={isoCertificationHandler.onDropAccepted} + onDropRejected={isoCertificationHandler.onDropRejected} + removeFile={isoCertificationHandler.removeFile} + isSubmitting={isSubmitting} + /> + + <FileUploadSection + title="신용평가보고서" + description="신용평가기관에서 발급한 발행 1년 이내의 신용평가보고서를 업로드해주세요." + files={creditReportFiles} + onDropAccepted={creditReportHandler.onDropAccepted} + onDropRejected={creditReportHandler.onDropRejected} + removeFile={creditReportHandler.removeFile} + isSubmitting={isSubmitting} + /> + + {data.country !== "KR" && ( + <FileUploadSection + title="대금지급 통장사본" + description="대금 지급용 은행 계좌의 통장 사본 또는 계좌증명서를 업로드해주세요." + files={bankAccountFiles} + onDropAccepted={bankAccountHandler.onDropAccepted} + onDropRejected={bankAccountHandler.onDropRejected} + removeFile={bankAccountHandler.removeFile} + isSubmitting={isSubmitting} + /> + )} + </div> + + <div className="flex justify-between"> + <Button variant="outline" onClick={onBack}> + 이전 + </Button> + <Button onClick={handleSubmit} disabled={!isFormValid || isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 등록 중... + </> + ) : ( + "등록 완료" + )} + </Button> + </div> + </div> + ); +} + +// 파일 업로드 섹션 컴포넌트 +function FileUploadSection({ + title, + description, + files, + onDropAccepted, + onDropRejected, + removeFile, + isSubmitting, + required = true +}) { + return ( <div className="space-y-4"> <div> <h5 className="text-sm font-medium"> @@ -517,654 +1285,5 @@ export function JoinForm() { </div> )} </div> - ) - - // Render - return ( - <div className="container py-6"> - <section className="overflow-hidden rounded-md border bg-background shadow-sm"> - <div className="p-6 md:p-10 space-y-6"> - <div className="space-y-2"> - <h3 className="text-xl font-semibold"> - {defaultTaxId}{" "} - {t("joinForm.title", { - defaultValue: "Vendor Administrator Creation", - })} - </h3> - <p className="text-sm text-muted-foreground"> - {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.", - })} - </p> - </div> - - <Separator /> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> - {/* ───────────────────────────────────────── - Basic Info - ───────────────────────────────────────── */} - <div className="rounded-md border p-4 space-y-4"> - <h4 className="text-md font-semibold">기본 정보</h4> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {/* Vendor Type */} - <FormField - control={form.control} - name="vendorTypeId" - render={({ field }) => { - const selectedType = vendorTypes.find(type => type.id === field.value); - const displayName = lng === "ko" ? - (selectedType?.nameKo || "") : - (selectedType?.nameEn || ""); - - return ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 업체유형 - </FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - disabled={isSubmitting || isLoadingVendorTypes} - > - {isLoadingVendorTypes - ? "Loading..." - : displayName || "업체유형 선택"} - <ChevronsUpDown className="ml-2 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0"> - <Command> - <CommandInput placeholder="업체유형 검색..." /> - <CommandList> - <CommandEmpty>No vendor type found.</CommandEmpty> - <CommandGroup> - {vendorTypes.map((type) => ( - <CommandItem - key={type.id} - value={lng === "ko" ? type.nameKo : type.nameEn} - onSelect={() => field.onChange(type.id)} - > - <Check - className={cn( - "mr-2", - type.id === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {lng === "ko" ? type.nameKo : type.nameEn} - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - ); - }} - /> - - {/* vendorName */} - <FormField - control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 업체명 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormDescription> - {form.watch("country") === "KR" - ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." - : "해외 업체의 경우 영문 회사명을 입력하세요."} - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* Items */} - <FormField - control={form.control} - name="items" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 공급품목 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormDescription> - 공급 가능한 제품/서비스를 입력하세요 - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* Address */} - <FormField - control={form.control} - name="address" - render={({ field }) => ( - <FormItem> - <FormLabel>주소</FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Country */} - <FormField - control={form.control} - name="country" - render={({ field }) => { - const selectedCountry = enhancedCountryArray.find( - (c) => c.code === field.value - ) - return ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 국가 - </FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - disabled={isSubmitting} - > - {selectedCountry - ? selectedCountry.label - : "국가 선택"} - <ChevronsUpDown className="ml-2 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0"> - <Command> - <CommandInput placeholder="국가 검색..." /> - <CommandList> - <CommandEmpty>No country found.</CommandEmpty> - <CommandGroup> - {enhancedCountryArray.map((country) => ( - <CommandItem - key={country.code} - value={country.label} - onSelect={() => - field.onChange(country.code) - } - > - <Check - className={cn( - "mr-2", - country.code === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {country.label} - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - ) - }} - /> - {/* Phone */} - <FormField - control={form.control} - name="phone" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표 전화 - </FormLabel> - <FormControl> - <Input - {...field} - placeholder={getPhonePlaceholder(form.watch("country"))} - disabled={isSubmitting} - className={cn( - form.formState.errors.phone && "border-red-500" - )} - /> - </FormControl> - <FormDescription className="text-xs text-muted-foreground"> - {getPhoneDescription(form.watch("country"))} - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* Email */} - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표 이메일 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormDescription> - 회사 도메인 이메일을 사용하세요. (naver.com, gmail.com, daum.net 등의 개인 이메일은 지양해주세요) - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* Website */} - <FormField - control={form.control} - name="website" - render={({ field }) => ( - <FormItem> - <FormLabel>웹사이트</FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* ───────────────────────────────────────── - 담당자 정보 (contacts) - ───────────────────────────────────────── */} - <div className="rounded-md border p-4 space-y-4"> - <div className="flex items-center justify-between"> - <h4 className="text-md font-semibold">담당자 정보 (최소 1명)</h4> - <Button - type="button" - variant="outline" - onClick={() => - addContact({ - contactName: "", - contactPosition: "", - contactDepartment: "", - contactTask: "", - contactEmail: "", - contactPhone: "", - }) - } - disabled={isSubmitting} - > - <Plus className="mr-1 h-4 w-4" /> - Add Contact - </Button> - </div> - - <div className="space-y-2"> - {contactFields.map((contact, index) => ( - <div - key={contact.id} - className="bg-muted/10 rounded-md p-4 space-y-4" - > - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> - {/* contactName */} - <FormField - control={form.control} - name={`contacts.${index}.contactName`} - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 담당자명 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* contactPosition */} - <FormField - control={form.control} - name={`contacts.${index}.contactPosition`} - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 직급 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* contactDepartment */} - <FormField - control={form.control} - name={`contacts.${index}.contactDepartment`} - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 부서 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* contactTask - Dropdown */} - <FormField - control={form.control} - name={`contacts.${index}.contactTask`} - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 담당업무 - </FormLabel> - <Select onValueChange={field.onChange} value={field.value} disabled={isSubmitting}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="담당업무를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {contactTaskOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* contactEmail */} - <FormField - control={form.control} - name={`contacts.${index}.contactEmail`} - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 이메일 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* contactPhone */} - <FormField - control={form.control} - name={`contacts.${index}.contactPhone`} - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 전화번호 - </FormLabel> - <FormControl> - <Input - {...field} - placeholder={getPhonePlaceholder(form.watch("country"))} - disabled={isSubmitting} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* Remove contact button row */} - {contactFields.length > 1 && ( - <div className="flex justify-end"> - <Button - variant="destructive" - onClick={() => removeContact(index)} - disabled={isSubmitting} - > - <X className="mr-1 h-4 w-4" /> - Remove - </Button> - </div> - )} - </div> - ))} - </div> - </div> - - {/* ───────────────────────────────────────── - 한국 사업자 (country === "KR") - ───────────────────────────────────────── */} - {form.watch("country") === "KR" && ( - <div className="rounded-md border p-4 space-y-4"> - <h4 className="text-md font-semibold">한국 사업자 정보</h4> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <FormField - control={form.control} - name="representativeName" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표자 이름 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="representativeBirth" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표자 생년월일 - </FormLabel> - <FormControl> - <Input - placeholder="YYYY-MM-DD" - {...field} - disabled={isSubmitting} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="representativeEmail" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표자 이메일 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="representativePhone" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표자 전화번호 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="corporateRegistrationNumber" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 법인등록번호 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="representativeWorkExpirence" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - disabled={isSubmitting} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel> - 대표자 삼성중공업 근무이력 - </FormLabel> - <FormDescription> - 대표자가 삼성중공업에서 근무한 경험이 있는 경우 체크해주세요. - </FormDescription> - </div> - </FormItem> - )} - /> - - </div> - </div> - )} - - {/* ───────────────────────────────────────── - Required Document Uploads - ───────────────────────────────────────── */} - <div className="rounded-md border p-4 space-y-6"> - <h4 className="text-md font-semibold">필수 첨부 서류</h4> - - {/* Business Registration */} - <FileUploadSection - title="사업자등록증" - description="사업자등록증 스캔본 또는 사진을 업로드해주세요. 모든 내용이 선명하게 보여야 합니다." - files={businessRegistrationFiles} - onDropAccepted={businessRegistrationHandler.onDropAccepted} - onDropRejected={businessRegistrationHandler.onDropRejected} - removeFile={businessRegistrationHandler.removeFile} - /> - - <Separator /> - - {/* ISO Certification */} - <FileUploadSection - title="ISO 인증서" - description="ISO 9001, ISO 14001 등 품질/환경 관리 인증서를 업로드해주세요. 유효기간이 확인 가능해야 합니다." - files={isoCertificationFiles} - onDropAccepted={isoCertificationHandler.onDropAccepted} - onDropRejected={isoCertificationHandler.onDropRejected} - removeFile={isoCertificationHandler.removeFile} - /> - - <Separator /> - - {/* Credit Report */} - <FileUploadSection - title="신용평가보고서" - description="신용평가기관(KIS, NICE 등)에서 발급한 발행 1년 이내의 신용평가보고서를 업로드해주세요. 전년도 재무제표 필수표시. 신규업체, 영세업체로 재무제표 및 신용평가 결과가 없을 경우는 국세, 지방세 납입 증명으로 신용평가를 갈음할 수 있음" - files={creditReportFiles} - onDropAccepted={creditReportHandler.onDropAccepted} - onDropRejected={creditReportHandler.onDropRejected} - removeFile={creditReportHandler.removeFile} - /> - - {/* Bank Account Copy - Only for non-Korean companies */} - {form.watch("country") !== "KR" && ( - <> - <Separator /> - <FileUploadSection - title="대금지급 통장사본" - description="대금 지급용 은행 계좌의 통장 사본 또는 계좌증명서를 업로드해주세요. 계좌번호와 예금주명이 명확히 보여야 합니다." - files={bankAccountFiles} - onDropAccepted={bankAccountHandler.onDropAccepted} - onDropRejected={bankAccountHandler.onDropRejected} - removeFile={bankAccountHandler.removeFile} - /> - </> - )} - </div> - - {/* ───────────────────────────────────────── - Submit - ───────────────────────────────────────── */} - <div className="flex justify-end"> - <Button type="submit" disabled={!isFormValid || isSubmitting}> - {isSubmitting ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 등록 중... - </> - ) : ( - "Submit" - )} - </Button> - </div> - </form> - </Form> - </div> - </section> - </div> - ) -}
\ No newline at end of file + ); +} |
