diff options
Diffstat (limited to 'components/signup')
| -rw-r--r-- | components/signup/conset-step.tsx | 415 | ||||
| -rw-r--r-- | components/signup/join-form.tsx | 2021 |
2 files changed, 1485 insertions, 951 deletions
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 + ); +} |
