From fb276ed3db86fe4fc0c0fcd870fd3d085b034be0 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 17 Jul 2025 10:50:28 +0000 Subject: (대표님) 벤더데이터 S-EDP 변경사항 대응(seperator), 정기평가 점수오류, dim 준비 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/signup/tech-vendor-join-form.tsx | 907 ++++++++++++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 components/signup/tech-vendor-join-form.tsx (limited to 'components/signup') diff --git a/components/signup/tech-vendor-join-form.tsx b/components/signup/tech-vendor-join-form.tsx new file mode 100644 index 00000000..db81b88c --- /dev/null +++ b/components/signup/tech-vendor-join-form.tsx @@ -0,0 +1,907 @@ +"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" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react" +import { cn } from "@/lib/utils" + +import { createTechVendorFromSignup } from "@/lib/tech-vendors/service" +import { createTechVendorSchema, CreateTechVendorSchema } from "@/lib/tech-vendors/validations" +import { VENDOR_TYPES } from "@/db/schema/techVendors" +import { verifyTechVendorInvitationToken } from "@/lib/tech-vendor-invitation-token" + +import { + Dropzone, + DropzoneZone, + DropzoneInput, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, +} from "@/components/ui/dropzone" +import { + FileList, + FileListItem, + FileListHeader, + FileListIcon, + FileListInfo, + 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" + +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) => { + if (a.code === "KR") return -1; + if (b.code === "KR") return 1; + return a.label.localeCompare(b.label); +}); + +// Add English names for Korean locale +const enhancedCountryArray = sortedCountryArray.map(country => ({ + ...country, + label: locale === "ko" && country.code === "KR" + ? "대한민국 (South Korea)" + : country.label +})); + +// Comprehensive list of country dial codes +const countryDialCodes: { [key: string]: string } = { + 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", + BY: "+375", BE: "+32", BZ: "+501", BJ: "+229", BM: "+1-441", BT: "+975", + BO: "+591", BA: "+387", BW: "+267", BR: "+55", BN: "+673", BG: "+359", + BF: "+226", BI: "+257", KH: "+855", CM: "+237", CA: "+1", CV: "+238", + KY: "+1-345", CF: "+236", TD: "+235", CL: "+56", CN: "+86", CO: "+57", + KM: "+269", CG: "+242", CD: "+243", CR: "+506", CI: "+225", HR: "+385", + CU: "+53", CY: "+357", CZ: "+420", DK: "+45", DJ: "+253", DM: "+1-767", + DO: "+1-809", EC: "+593", EG: "+20", SV: "+503", GQ: "+240", ER: "+291", + EE: "+372", ET: "+251", FJ: "+679", FI: "+358", FR: "+33", GA: "+241", + GM: "+220", GE: "+995", DE: "+49", GH: "+233", GR: "+30", GD: "+1-473", + GT: "+502", GN: "+224", GW: "+245", GY: "+592", HT: "+509", HN: "+504", + HK: "+852", HU: "+36", IS: "+354", IN: "+91", ID: "+62", IR: "+98", + IQ: "+964", IE: "+353", IL: "+972", IT: "+39", JM: "+1-876", JP: "+81", + JO: "+962", KZ: "+7", KE: "+254", KI: "+686", KR: "+82", KW: "+965", + KG: "+996", LA: "+856", LV: "+371", LB: "+961", LS: "+266", LR: "+231", + LY: "+218", LI: "+423", LT: "+370", LU: "+352", MK: "+389", MG: "+261", + MW: "+265", MY: "+60", MV: "+960", ML: "+223", MT: "+356", MH: "+692", + MR: "+222", MU: "+230", MX: "+52", FM: "+691", MD: "+373", MC: "+377", + MN: "+976", ME: "+382", MA: "+212", MZ: "+258", MM: "+95", NA: "+264", + NR: "+674", NP: "+977", NL: "+31", NZ: "+64", NI: "+505", NE: "+227", + NG: "+234", NU: "+683", KP: "+850", NO: "+47", OM: "+968", PK: "+92", + PW: "+680", PS: "+970", PA: "+507", PG: "+675", PY: "+595", PE: "+51", + PH: "+63", PL: "+48", PT: "+351", PR: "+1-787", QA: "+974", RO: "+40", + RU: "+7", RW: "+250", KN: "+1-869", LC: "+1-758", VC: "+1-784", WS: "+685", + SM: "+378", ST: "+239", SA: "+966", SN: "+221", RS: "+381", SC: "+248", + SL: "+232", SG: "+65", SK: "+421", SI: "+386", SB: "+677", SO: "+252", + ZA: "+27", SS: "+211", ES: "+34", LK: "+94", SD: "+249", SR: "+597", + SZ: "+268", SE: "+46", CH: "+41", SY: "+963", TW: "+886", TJ: "+992", + TZ: "+255", TH: "+66", TL: "+670", TG: "+228", TK: "+690", TO: "+676", + TT: "+1-868", TN: "+216", TR: "+90", TM: "+993", TV: "+688", UG: "+256", + UA: "+380", AE: "+971", GB: "+44", US: "+1", UY: "+598", UZ: "+998", + VU: "+678", VA: "+39-06", VE: "+58", VN: "+84", YE: "+967", ZM: "+260", + ZW: "+263" +}; + +const MAX_FILE_SIZE = 3e9 + +export function TechVendorJoinForm() { + const params = useParams() || {}; + const lng = params.lng ? String(params.lng) : "ko"; + + const router = useRouter() + const searchParams = useSearchParams() || new URLSearchParams(); + const defaultTaxId = searchParams.get("taxID") ?? "" + const invitationToken = searchParams.get("token") ?? "" + + // States + const [selectedFiles, setSelectedFiles] = React.useState([]) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [hasValidToken, setHasValidToken] = React.useState(null) + + // React Hook Form (항상 최상위에서 호출) + const form = useForm({ + resolver: zodResolver(createTechVendorSchema), + defaultValues: { + vendorName: "", + vendorCode: "", + items: "", + taxId: defaultTaxId, + address: "", + email: "", + phone: "", + country: "", + website: "", + techVendorType: ["조선"], + representativeName: "", + representativeBirth: "", + representativeEmail: "", + representativePhone: "", + files: undefined, + contacts: [ + { + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + }, + ], + }, + mode: "onChange", + }) + + // Field array for contacts (항상 최상위에서 호출) + const { fields: contactFields, append: addContact, remove: removeContact } = + useFieldArray({ + control: form.control, + name: "contacts", + }) + + // 토큰 검증 로직 + React.useEffect(() => { + // 토큰이 없으면 유효하지 않음 + if (!invitationToken) { + setHasValidToken(false); + return; + } + + // 토큰이 있으면 검증 수행 + const validateToken = async () => { + setIsLoading(true); + + try { + const tokenPayload = await verifyTechVendorInvitationToken(invitationToken); + + if (tokenPayload) { + setHasValidToken(true); + // 토큰에서 가져온 정보로 폼 미리 채우기 + form.setValue("vendorName", tokenPayload.vendorName); + form.setValue("email", tokenPayload.email); + + // 연락처 정보도 미리 채우기 + form.setValue("contacts.0.contactName", tokenPayload.vendorName); + form.setValue("contacts.0.contactEmail", tokenPayload.email); + + toast({ + title: "초대 정보 로드 완료", + description: "기존 정보가 자동으로 입력되었습니다. 추가 정보를 입력해주세요.", + }); + } else { + setHasValidToken(false); + toast({ + variant: "destructive", + title: "유효하지 않은 초대 링크", + description: "초대 링크가 만료되었거나 유효하지 않습니다.", + }); + } + } catch (error) { + console.error("Token verification error:", error); + setHasValidToken(false); + toast({ + variant: "destructive", + title: "오류 발생", + description: "초대 정보를 불러오는 중 오류가 발생했습니다.", + }); + } finally { + setIsLoading(false); + } + }; + + validateToken(); + }, [invitationToken, form, router]); + + // 토큰이 유효하지 않으면 에러 페이지 표시 + if (hasValidToken === false) { + return ( +
+
+
+
+
+

유효하지 않은 접근

+

+ 기술영업 협력업체 등록은 초대를 통해서만 가능합니다.
+ 올바른 초대 링크를 통해 접근해주세요. +

+ +
+
+
+
+
+ ); + } + + const isFormValid = form.formState.isValid + console.log("Form errors:", form.formState.errors); + console.log("Form values:", form.getValues()); + console.log("Form valid:", form.formState.isValid); + + // Dropzone handlers + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue("files", newFiles, { shouldValidate: true }) + } + + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + + const removeFile = (index: number) => { + const updated = [...selectedFiles] + updated.splice(index, 1) + setSelectedFiles(updated) + form.setValue("files", updated, { shouldValidate: true }) + } + + // Submit + async function onSubmit(values: CreateTechVendorSchema) { + setIsSubmitting(true) + try { + const mainFiles = values.files + ? Array.from(values.files as FileList) + : [] + + const techVendorData = { + vendorName: values.vendorName, + vendorCode: values.vendorCode, + items: values.items, + website: values.website, + taxId: values.taxId, + address: values.address, + email: values.email, + phone: values.phone, + country: values.country, + techVendorType: Array.isArray(values.techVendorType) ? values.techVendorType[0] : values.techVendorType, + representativeName: values.representativeName || "", + representativeBirth: values.representativeBirth || "", + representativeEmail: values.representativeEmail || "", + representativePhone: values.representativePhone || "", + contacts: values.contacts, + files: mainFiles, + } + + const result = await createTechVendorFromSignup({ + vendorData: techVendorData, + files: mainFiles, + contacts: values.contacts, + invitationToken: invitationToken || undefined, + }) + + if (!result.error) { + toast({ + title: "등록 완료", + description: invitationToken + ? "기술영업 업체 정보가 성공적으로 업데이트되었습니다." + : "기술영업 업체 등록이 완료되었습니다.", + }) + router.push("/ko/partners") + } else { + toast({ + variant: "destructive", + title: "오류", + description: result.error || "등록에 실패했습니다.", + }) + } + } catch (error: any) { + 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 "전화번호"; + return `${countryDialCodes[countryCode]} 전화번호`; + }; + + if (isLoading) { + return ( +
+
+ + 기존 정보를 불러오는 중... +
+
+ ); + } + + return ( +
+
+
+
+

+ {invitationToken ? "기술영업 업체 정보 업데이트" : "기술영업 업체 등록"} +

+

+ {invitationToken + ? "기술영업 업체 정보를 업데이트하고 필요한 서류를 첨부해주세요." + : "기술영업 업체 기본 정보를 입력하고 필요한 서류를 첨부해주세요. 검토 후 빠른 시일 내에 승인처리 해드리겠습니다." + } +

+
+ + + +
+ + {/* ───────────────────────────────────────── + 기본 정보 + ───────────────────────────────────────── */} +
+

기본 정보

+
+ {/* 업체 유형 */} + ( + + + 업체 유형 + +
+ {VENDOR_TYPES.map((type) => ( +
+ { + const currentValues = field.value || []; + if (e.target.checked) { + field.onChange([...currentValues, type]); + } else { + field.onChange(currentValues.filter((v: string) => v !== type)); + } + }} + className="rounded border-input" + /> + +
+ ))} +
+ +
+ )} + /> + + {/* 업체명 */} + ( + + + 업체명 + + + + + + + )} + /> + + {/* 업체 코드 */} + ( + + 업체 코드 + + + + + + )} + /> + + {/* 사업자등록번호 */} + ( + + + 사업자등록번호 + + + + + + + )} + /> + + {/* 국가 */} + ( + + + 국가 + + + + + + + + + + + 국가를 찾을 수 없습니다. + + + {Object.entries(i18nIsoCountries.getNames("ko")).map(([code, name]) => ( + { + form.setValue("country", code); + }} + > + + {name} + + ))} + + + + + + + + )} + /> + + {/* 주소 */} + ( + + 주소 + + + + + + )} + /> + + {/* 이메일 */} + ( + + + 이메일 + + + + + + + )} + /> + + {/* 전화번호 */} + ( + + 전화번호 + + + + + + )} + /> + + {/* 웹사이트 */} + ( + + 웹사이트 + + + + + + )} + /> + + {/* 주요 품목 */} + ( + + + 주요 품목 + + + + + + 회사에서 주로 다루는 품목들을 쉼표로 구분하여 입력하세요. + + + + )} + /> +
+
+ + {/* ───────────────────────────────────────── + 대표자 정보 + ───────────────────────────────────────── */} +
+

대표자 정보

+
+ {/* 대표자명 */} + ( + + 대표자명 + + + + + + )} + /> + + {/* 대표자 생년월일 */} + ( + + 대표자 생년월일 + + + + + + )} + /> + + {/* 대표자 이메일 */} + ( + + 대표자 이메일 + + + + + + )} + /> + + {/* 대표자 전화번호 */} + ( + + 대표자 전화번호 + + + + + + )} + /> +
+
+ + {/* ───────────────────────────────────────── + 연락처 정보 + ───────────────────────────────────────── */} +
+
+

연락처 정보

+ +
+ + {contactFields.map((field, index) => ( +
+
+
연락처 {index + 1}
+ {contactFields.length > 1 && ( + + )} +
+ +
+ ( + + + 연락처명 + + + + + + + )} + /> + + ( + + 직책 + + + + + + )} + /> + + ( + + + 이메일 + + + + + + + )} + /> + + ( + + 전화번호 + + + + + + )} + /> +
+
+ ))} +
+ + {/* ───────────────────────────────────────── + 첨부파일 + ───────────────────────────────────────── */} +
+

첨부파일

+ ( + + + + + + + 파일을 드래그하거나 클릭하여 업로드 + + + PDF, Word, Excel, 이미지 파일 (최대 3GB) + + + + + + + + )} + /> + + {selectedFiles.length > 0 && ( +
+ + + + 업로드된 파일 + + {selectedFiles.length}개 파일 + + + + + {selectedFiles.map((file, index) => ( + + + + {file.name} + + {prettyBytes(file.size)} + + + + + + + ))} + +
+ )} +
+ +
+ +
+
+ +
+
+
+ ) +} \ No newline at end of file -- cgit v1.2.3