From 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 25 Mar 2025 15:55:45 +0900 Subject: initial commit --- components/signup/join-form.tsx | 1010 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1010 insertions(+) create mode 100644 components/signup/join-form.tsx (limited to 'components/signup/join-form.tsx') diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx new file mode 100644 index 00000000..06aee3b5 --- /dev/null +++ b/components/signup/join-form.tsx @@ -0,0 +1,1010 @@ +"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, + CommandList, + CommandInput, + 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 { createVendor } from "@/lib/vendors/service" +import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +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, +})) + +// Example agencies + rating scales +const creditAgencies = [ + { value: "NICE", label: "NICE평가정보" }, + { value: "KIS", label: "KIS (한국신용평가)" }, + { value: "KED", label: "KED (한국기업데이터)" }, + { value: "SCI", label: "SCI평가정보" }, +] +const creditRatingScaleMap: Record = { + NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"], + KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"], + KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"], + SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], +} + +const MAX_FILE_SIZE = 3e9 + +export function JoinForm() { + const params = useParams() + const lng = (params.lng as string) || "ko" + const { t } = useTranslation(lng, "translation") + + const router = useRouter() + const searchParams = useSearchParams() + const defaultTaxId = searchParams.get("taxID") ?? "" + + // File states + const [selectedFiles, setSelectedFiles] = React.useState([]) + const [creditRatingFile, setCreditRatingFile] = React.useState([]) + const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState([]) + + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // React Hook Form + const form = useForm({ + resolver: zodResolver(createVendorSchema), + defaultValues: { + vendorName: "", + taxId: defaultTaxId, + address: "", + email: "", + phone: "", + country: "", + representativeName: "", + representativeBirth: "", + representativeEmail: "", + representativePhone: "", + corporateRegistrationNumber: "", + creditAgency: "", + creditRating: "", + cashFlowRating: "", + attachedFiles: undefined, + creditRatingAttachment: undefined, + cashFlowRatingAttachment: undefined, + // contacts (no isPrimary) + contacts: [ + { + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + }, + ], + }, + mode: "onChange", + }) + const isFormValid = form.formState.isValid + + + + // Field array for contacts + const { fields: contactFields, append: addContact, remove: removeContact } = + useFieldArray({ + control: form.control, + name: "contacts", + }) + + // Dropzone handlers (same as before)... + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue("attachedFiles", newFiles, { shouldValidate: true }) + } + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + const removeFile = (index: number) => { + const updated = [...selectedFiles] + updated.splice(index, 1) + setSelectedFiles(updated) + form.setValue("attachedFiles", updated, { shouldValidate: true }) + } + + const handleCreditDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...creditRatingFile, ...acceptedFiles] + setCreditRatingFile(newFiles) + form.setValue("creditRatingAttachment", newFiles, { shouldValidate: true }) + } + const handleCreditDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + const removeCreditFile = (index: number) => { + const updated = [...creditRatingFile] + updated.splice(index, 1) + setCreditRatingFile(updated) + form.setValue("creditRatingAttachment", updated, { shouldValidate: true }) + } + + const handleCashFlowDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...cashFlowRatingFile, ...acceptedFiles] + setCashFlowRatingFile(newFiles) + form.setValue("cashFlowRatingAttachment", newFiles, { shouldValidate: true }) + } + const handleCashFlowDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + const removeCashFlowFile = (index: number) => { + const updated = [...cashFlowRatingFile] + updated.splice(index, 1) + setCashFlowRatingFile(updated) + form.setValue("cashFlowRatingAttachment", updated, { shouldValidate: true }) + } + + // Submit + async function onSubmit(values: CreateVendorSchema) { + setIsSubmitting(true) + try { + const mainFiles = values.attachedFiles + ? Array.from(values.attachedFiles as FileList) + : [] + const creditRatingFiles = values.creditRatingAttachment + ? Array.from(values.creditRatingAttachment as FileList) + : [] + const cashFlowRatingFiles = values.cashFlowRatingAttachment + ? Array.from(values.cashFlowRatingAttachment as FileList) + : [] + + const vendorData = { + vendorName: values.vendorName, + 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 || "", + creditAgency: values.creditAgency || "", + creditRating: values.creditRating || "", + cashFlowRating: values.cashFlowRating || "", + } + + const result = await createVendor({ + vendorData, + files: mainFiles, + creditRatingFiles, + cashFlowRatingFiles, + contacts: values.contacts, + }) + + if (!result.error) { + toast({ + title: "등록 완료", + description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)", + }) + router.push("/") + } else { + toast({ + variant: "destructive", + title: "오류", + description: result.error || "등록에 실패했습니다.", + }) + } + } catch (error: any) { + console.error(error) + toast({ + variant: "destructive", + title: "서버 에러", + description: error.message || "에러가 발생했습니다.", + }) + } finally { + setIsSubmitting(false) + } + } + + // Render + return ( +
+
+
+
+

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

+

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

+
+ + + +
+ + {/* ───────────────────────────────────────── + Basic Info + ───────────────────────────────────────── */} +
+

기본 정보

+
+ {/* vendorName is required in the schema → show * */} + ( + + + 업체명 + + + + + + + )} + /> + + {/* Address (optional, no * here) */} + ( + + 주소 + + + + + + )} + /> + + ( + + 대표 전화 + + + + + + )} + /> + + {/* email is required → show * */} + ( + + + 대표 이메일 + + + + + + 회사 대표 이메일(관리자 로그인에 사용될 수 있음) + + + + )} + /> + + {/* website optional */} + ( + + 웹사이트 + + + + + + )} + /> + + { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + + + Country + + + + + + + + + + + + No country found. + + {countryArray.map((country) => ( + + field.onChange(country.code) + } + > + + {country.label} + + ))} + + + + + + + + ) + }} + /> +
+
+ + {/* ───────────────────────────────────────── + 담당자 정보 (contacts) + ───────────────────────────────────────── */} +
+
+

담당자 정보 (최소 1명)

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

한국 사업자 정보

+ + {/* 대표자 등... all optional or whichever you want * for */} +
+ ( + + + 대표자 이름 + + + + + + + )} + /> + ( + + + 대표자 생년월일 + + + + + + + )} + /> + ( + + + 대표자 이메일 + + + + + + + )} + /> + ( + + + 대표자 전화번호 + + + + + + + )} + /> + ( + + + 법인등록번호 + + + + + + + )} + /> +
+ + + + {/* 신용/현금 흐름 */} +
+ { + const agencyValue = field.value + return ( + + + 평가사 + + + + 신용평가 및 현금흐름등급에 사용할 평가사 + + + + ) + }} + /> + {form.watch("creditAgency") && ( +
+ {/* 신용평가등급 */} + { + const selectedAgency = form.watch("creditAgency") + const ratingScale = + creditRatingScaleMap[ + selectedAgency as keyof typeof creditRatingScaleMap + ] || [] + return ( + + + 신용평가등급 + + + + + ) + }} + /> + {/* 현금흐름등급 */} + { + const selectedAgency = form.watch("creditAgency") + const ratingScale = + creditRatingScaleMap[ + selectedAgency as keyof typeof creditRatingScaleMap + ] || [] + return ( + + + 현금흐름등급 + + + + + ) + }} + /> +
+ )} +
+ + {/* Credit/CashFlow Attachments */} + {form.watch("creditAgency") && ( +
+ ( + + + 신용평가등급 첨부 + + {({ maxSize }) => ( + + +
+ +
+ 드래그 또는 클릭 + + 최대: {maxSize ? prettyBytes(maxSize) : "무제한"} + +
+
+
+ )} +
+ {creditRatingFile.length > 0 && ( +
+ + + {creditRatingFile.map((file, i) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeCreditFile(i)} + > + + + + + ))} + + +
+ )} +
+ )} + /> + {/* Cash Flow Attachment */} + ( + + + 현금흐름등급 첨부 + + {({ maxSize }) => ( + + +
+ +
+ 드래그 또는 클릭 + + 최대: {maxSize ? prettyBytes(maxSize) : "무제한"} + +
+
+
+ )} +
+ {cashFlowRatingFile.length > 0 && ( +
+ + + {cashFlowRatingFile.map((file, i) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeCashFlowFile(i)} + > + + + + + ))} + + +
+ )} +
+ )} + /> +
+ )} +
+ )} + + {/* ───────────────────────────────────────── + 첨부파일 (사업자등록증 등) + ───────────────────────────────────────── */} +
+

기타 첨부파일

+ ( + + + 첨부 파일 + + + {({ maxSize }) => ( + + +
+ +
+ 파일 업로드 + + 드래그 또는 클릭 + {maxSize + ? ` (최대: ${prettyBytes(maxSize)})` + : null} + +
+
+
+ )} +
+ {selectedFiles.length > 0 && ( +
+ + + {selectedFiles.map((file, i) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(i)}> + + + + + ))} + + +
+ )} +
+ )} + /> +
+ + {/* ───────────────────────────────────────── + Submit + ───────────────────────────────────────── */} +
+ +
+
+ +
+
+
+ ) +} \ No newline at end of file -- cgit v1.2.3