diff options
Diffstat (limited to 'components/additional-info')
| -rw-r--r-- | components/additional-info/join-form.tsx | 1344 |
1 files changed, 1344 insertions, 0 deletions
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx new file mode 100644 index 00000000..2cd385c3 --- /dev/null +++ b/components/additional-info/join-form.tsx @@ -0,0 +1,1344 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import { useRouter, useParams } from "next/navigation" +import { useSession } from "next-auth/react" + +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, Download, Loader2, Plus, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useTranslation } from "@/i18n/client" + +import { getVendorDetailById, downloadVendorAttachments, updateVendorInfo } from "@/lib/vendors/service" +import { updateVendorSchema, type UpdateVendorInfoSchema } 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" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +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<string, string[]> = { + 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 + +// 파일 타입 정의 +interface AttachmentFile { + id: number + fileName: string + filePath: string + attachmentType: string + fileSize?: number +} + +export function InfoForm() { + const params = useParams() || {} + const lng = params.lng ? String(params.lng) : "ko" + const { t } = useTranslation(lng, "translation") + const router = useRouter() + const { data: session } = useSession() + + const companyId = session?.user?.companyId || "17" + + // 벤더 데이터 상태 + const [vendor, setVendor] = React.useState<any>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 첨부파일 상태 + const [existingFiles, setExistingFiles] = React.useState<AttachmentFile[]>([]) + const [existingCreditFiles, setExistingCreditFiles] = React.useState<AttachmentFile[]>([]) + const [existingCashFlowFiles, setExistingCashFlowFiles] = React.useState<AttachmentFile[]>([]) + + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [creditRatingFile, setCreditRatingFile] = React.useState<File[]>([]) + const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState<File[]>([]) + + // React Hook Form + const form = useForm<UpdateVendorInfoSchema>({ + resolver: zodResolver(updateVendorSchema), + defaultValues: { + vendorName: "", + taxId: "", + address: "", + email: "", + phone: "", + country: "", + website: "", + representativeName: "", + representativeBirth: "", + representativeEmail: "", + representativePhone: "", + corporateRegistrationNumber: "", + creditAgency: "", + creditRating: "", + cashFlowRating: "", + attachedFiles: undefined, + creditRatingAttachment: undefined, + cashFlowRatingAttachment: undefined, + contacts: [ + { + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + }, + ], + }, + mode: "onChange", + }) + + const isFormValid = form.formState.isValid + + // Field array for contacts + const { fields: contactFields, append: addContact, remove: removeContact, replace: replaceContacts } = + useFieldArray({ + control: form.control, + name: "contacts", + }) + + // 벤더 정보 가져오기 + React.useEffect(() => { + async function fetchVendorData() { + if (!companyId) return + + try { + setIsLoading(true) + // 벤더 상세 정보 가져오기 (view 사용) + const vendorData = await getVendorDetailById(Number(companyId)) + + if (!vendorData) { + toast({ + variant: "destructive", + title: "오류", + description: "벤더 정보를 찾을 수 없습니다.", + }) + return + } + + setVendor(vendorData) + + // 첨부파일 정보 분류 (view에서 이미 파싱된 attachments 배열 사용) + if (vendorData.attachments && Array.isArray(vendorData.attachments)) { + const generalFiles = vendorData.attachments.filter( + (file: AttachmentFile) => file.attachmentType === "GENERAL" + ) + const creditFiles = vendorData.attachments.filter( + (file: AttachmentFile) => file.attachmentType === "CREDIT_RATING" + ) + const cashFlowFiles = vendorData.attachments.filter( + (file: AttachmentFile) => file.attachmentType === "CASH_FLOW_RATING" + ) + + setExistingFiles(generalFiles) + setExistingCreditFiles(creditFiles) + setExistingCashFlowFiles(cashFlowFiles) + } + + // 폼 기본값 설정 (연락처 포함) + const formValues = { + vendorName: vendorData.vendorName || "", + taxId: vendorData.taxId || "", + address: vendorData.address || "", + email: vendorData.email || "", + phone: vendorData.phone || "", + country: vendorData.country || "", + website: vendorData.website || "", + representativeName: vendorData.representativeName || "", + representativeBirth: vendorData.representativeBirth || "", + representativeEmail: vendorData.representativeEmail || "", + representativePhone: vendorData.representativePhone || "", + corporateRegistrationNumber: vendorData.corporateRegistrationNumber || "", + creditAgency: vendorData.creditAgency || "", + creditRating: vendorData.creditRating || "", + cashFlowRating: vendorData.cashFlowRating || "", + } + + form.reset(formValues) + + // 연락처 필드 업데이트 + if (vendorData.contacts && Array.isArray(vendorData.contacts) && vendorData.contacts.length > 0) { + const formattedContacts = vendorData.contacts.map((contact: any) => ({ + id: contact.id, + contactName: contact.contactName || "", + contactPosition: contact.contactPosition || "", + contactEmail: contact.contactEmail || "", + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary || false, + })) + + replaceContacts(formattedContacts) + } + } catch (error) { + console.error("Error fetching vendor data:", error) + toast({ + variant: "destructive", + title: "데이터 로드 오류", + description: "벤더 정보를 불러오는 중 오류가 발생했습니다.", + }) + } finally { + setIsLoading(false) + } + } + + fetchVendorData() + }, [companyId, form, replaceContacts]) + + // 파일 다운로드 처리 + const handleDownloadFile = async (fileId: number) => { + try { + const downloadInfo = await downloadVendorAttachments(Number(companyId), fileId) + + if (downloadInfo && downloadInfo.url) { + // 브라우저에서 다운로드 링크 열기 + window.open(downloadInfo.url, '_blank') + } + } catch (error) { + console.error("Error downloading file:", error) + toast({ + variant: "destructive", + title: "다운로드 오류", + description: "파일 다운로드 중 오류가 발생했습니다.", + }) + } + } + + // 모든 첨부파일 다운로드 + const handleDownloadAllFiles = async () => { + try { + const downloadInfo = await downloadVendorAttachments(Number(companyId)) + + if (downloadInfo && downloadInfo.url) { + window.open(downloadInfo.url, '_blank') + } + } catch (error) { + console.error("Error downloading files:", error) + toast({ + variant: "destructive", + title: "다운로드 오류", + description: "파일 다운로드 중 오류가 발생했습니다.", + }) + } + } + + // Dropzone handlers + 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 }) + } + + // 기존 파일 삭제 (ID 목록 관리) + const [filesToDelete, setFilesToDelete] = React.useState<number[]>([]) + + const handleDeleteExistingFile = (fileId: number) => { + // 삭제할 ID 목록에 추가 + setFilesToDelete([...filesToDelete, fileId]) + + // UI에서 제거 + setExistingFiles(existingFiles.filter(file => file.id !== fileId)) + setExistingCreditFiles(existingCreditFiles.filter(file => file.id !== fileId)) + setExistingCashFlowFiles(existingCashFlowFiles.filter(file => file.id !== fileId)) + + toast({ + title: "파일 삭제 표시됨", + description: "저장 시 파일이 영구적으로 삭제됩니다.", + }) + } + + // Submit + async function onSubmit(values: UpdateVendorInfoSchema) { + if (!companyId) { + toast({ + variant: "destructive", + title: "오류", + description: "회사 ID를 찾을 수 없습니다.", + }) + return + } + + 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 = { + id: Number(companyId), + vendorName: values.vendorName, + website: values.website, + address: values.address, + email: values.email, + phone: values.phone, + country: values.country, + representativeName: values.representativeName || "", + representativeBirth: values.representativeBirth || "", + representativeEmail: values.representativeEmail || "", + representativePhone: values.representativePhone || "", + corporateRegistrationNumber: values.corporateRegistrationNumber || "", + creditAgency: values.creditAgency || "", + creditRating: values.creditRating || "", + cashFlowRating: values.cashFlowRating || "", + } + + // 서버 액션 직접 호출 (기존 fetch API 호출 대신) + const result = await updateVendorInfo({ + vendorData, + files: mainFiles, + creditRatingFiles, + cashFlowRatingFiles, + contacts: values.contacts, + filesToDelete, // 삭제할 파일 ID 목록 + }) + + if (!result.error) { + toast({ + title: "업데이트 완료", + description: "회사 정보가 성공적으로 업데이트되었습니다.", + }) + // 삭제할 파일 목록 초기화 + setFilesToDelete([]) + // 페이지 새로고침하여 업데이트된 정보 표시 + router.refresh() + } else { + toast({ + variant: "destructive", + title: "오류", + description: result.error || "업데이트에 실패했습니다.", + }) + } + } catch (error: any) { + console.error(error) + toast({ + variant: "destructive", + title: "서버 에러", + description: error.message || "에러가 발생했습니다.", + }) + } finally { + setIsSubmitting(false) + } + } + + if (isLoading) { + return ( + <div className="container py-10 flex justify-center items-center"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> + <span className="ml-2">벤더 정보를 불러오는 중입니다...</span> + </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"> + {t("infoForm.title", { + defaultValue: "Update Vendor Information", + })} + </h3> + <p className="text-sm text-muted-foreground"> + {t("infoForm.description", { + defaultValue: + "Here you can view and update your company information and attachments.", + })} + </p> + + {vendor?.status && ( + <div className="mt-2"> + <Badge variant={ + vendor.status === "APPROVED" || vendor.status === "ACTIVE" + ? "secondary" + : (vendor.status === "PENDING_REVIEW" || vendor.status === "IN_REVIEW") + ? "destructive" + : "default" + }> + {vendor.status} + </Badge> + </div> + )} + </div> + + <Separator /> + + {/* 첨부파일 요약 카드 - 기존 파일 있는 경우만 표시 */} + {(existingFiles.length > 0 || existingCreditFiles.length > 0 || existingCashFlowFiles.length > 0) && ( + <Card> + <CardHeader> + <CardTitle>첨부파일 요약</CardTitle> + <CardDescription> + 현재 등록된 파일 목록입니다. 전체 다운로드하거나 개별 파일을 다운로드할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="grid gap-4"> + {existingFiles.length > 0 && ( + <div> + <h4 className="font-medium mb-2">일반 첨부파일</h4> + <ScrollArea className="h-32"> + <FileList className="gap-2"> + {existingFiles.map((file) => ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + <FileListDescription> + {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} + </FileListDescription> + </FileListInfo> + <div className="flex items-center space-x-2"> + <FileListAction onClick={() => handleDownloadFile(file.id)}> + <Download className="h-4 w-4" /> + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + + {existingCreditFiles.length > 0 && ( + <div> + <h4 className="font-medium mb-2">신용평가 첨부파일</h4> + <ScrollArea className="h-32"> + <FileList className="gap-2"> + {existingCreditFiles.map((file) => ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + <FileListDescription> + {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} + </FileListDescription> + </FileListInfo> + <div className="flex items-center space-x-2"> + <FileListAction onClick={() => handleDownloadFile(file.id)}> + <Download className="h-4 w-4" /> + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + + {existingCashFlowFiles.length > 0 && ( + <div> + <h4 className="font-medium mb-2">현금흐름 첨부파일</h4> + <ScrollArea className="h-32"> + <FileList className="gap-2"> + {existingCashFlowFiles.map((file) => ( + <FileListItem key={file.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + <FileListDescription> + {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} + </FileListDescription> + </FileListInfo> + <div className="flex items-center space-x-2"> + <FileListAction onClick={() => handleDownloadFile(file.id)}> + <Download className="h-4 w-4" /> + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </div> + </CardContent> + <CardFooter> + {(existingFiles.length + existingCreditFiles.length + existingCashFlowFiles.length) > 1 && ( + <Button variant="outline" onClick={handleDownloadAllFiles}> + <Download className="mr-2 h-4 w-4" /> + 전체 다운로드 + </Button> + )} + </CardFooter> + </Card> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> + + <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"> + {/* vendorName is required in the schema → show * */} + <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> + <FormMessage /> + </FormItem> + )} + /> + + {/* taxId - 읽기 전용으로 표시 */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>사업자등록번호</FormLabel> + <FormControl> + <Input {...field} disabled={true} readOnly /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Address */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>주소</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>대표 전화</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <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> + 회사 대표 이메일(관리자 로그인에 사용될 수 있음) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="country" + render={({ field }) => { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + Country + </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 + : "Select a country"} + <ChevronsUpDown className="ml-2 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {countryArray.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> + ) + }} + /> + </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: "", + contactEmail: "", + contactPhone: "", + }) + } + disabled={isSubmitting} + > + <Plus className="mr-1 h-4 w-4" /> + 담당자 추가 + </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-4 gap-4"> + {/* contactName → required */} + <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 → optional */} + <FormField + control={form.control} + name={`contacts.${index}.contactPosition`} + render={({ field }) => ( + <FormItem> + <FormLabel>직급 / 부서</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* contactEmail → required */} + <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 → optional */} + <FormField + control={form.control} + name={`contacts.${index}.contactPhone`} + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input {...field} 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" /> + 삭제 + </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> + + {/* 대표자 등... all optional or whichever you want * for */} + <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> + )} + /> + </div> + + <Separator /> + + {/* 신용/현금 흐름 */} + <div className="space-y-2"> + <FormField + control={form.control} + name="creditAgency" + render={({ field }) => { + const agencyValue = field.value + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 평가사 + </FormLabel> + <Select + onValueChange={field.onChange} + value={agencyValue} + > + <FormControl> + <SelectTrigger disabled={isSubmitting}> + <SelectValue placeholder="평가사 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {creditAgencies.map((agency) => ( + <SelectItem + key={agency.value} + value={agency.value} + > + {agency.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + 신용평가 및 현금흐름등급에 사용할 평가사 + </FormDescription> + <FormMessage /> + </FormItem> + ) + }} + /> + {form.watch("creditAgency") && ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 신용평가등급 */} + <FormField + control={form.control} + name="creditRating" + render={({ field }) => { + const selectedAgency = form.watch("creditAgency") + const ratingScale = + creditRatingScaleMap[ + selectedAgency as keyof typeof creditRatingScaleMap + ] || [] + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 신용평가등급 + </FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + > + <FormControl> + <SelectTrigger disabled={isSubmitting}> + <SelectValue placeholder="등급 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {ratingScale.map((r) => ( + <SelectItem key={r} value={r}> + {r} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + ) + }} + /> + {/* 현금흐름등급 */} + <FormField + control={form.control} + name="cashFlowRating" + render={({ field }) => { + const selectedAgency = form.watch("creditAgency") + const ratingScale = + creditRatingScaleMap[ + selectedAgency as keyof typeof creditRatingScaleMap + ] || [] + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 현금흐름등급 + </FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + > + <FormControl> + <SelectTrigger disabled={isSubmitting}> + <SelectValue placeholder="등급 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {ratingScale.map((r) => ( + <SelectItem key={r} value={r}> + {r} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + ) + }} + /> + </div> + )} + </div> + + {/* Credit/CashFlow Attachments */} + {form.watch("creditAgency") && ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="creditRatingAttachment" + render={() => ( + <FormItem> + <FormLabel> + 신용평가등급 첨부 (추가) + </FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={handleCreditDropAccepted} + onDropRejected={handleCreditDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>드래그 또는 클릭</DropzoneTitle> + <DropzoneDescription> + 최대: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + {creditRatingFile.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {creditRatingFile.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeCreditFile(i)} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </FormItem> + )} + /> + {/* Cash Flow Attachment */} + <FormField + control={form.control} + name="cashFlowRatingAttachment" + render={() => ( + <FormItem> + <FormLabel> + 현금흐름등급 첨부 (추가) + </FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={handleCashFlowDropAccepted} + onDropRejected={handleCashFlowDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>드래그 또는 클릭</DropzoneTitle> + <DropzoneDescription> + 최대: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + {cashFlowRatingFile.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {cashFlowRatingFile.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeCashFlowFile(i)} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </FormItem> + )} + /> + </div> + )} + </div> + )} + + {/* ───────────────────────────────────────── + 첨부파일 (사업자등록증 등) - 추가 파일 +───────────────────────────────────────── */} + <div className="rounded-md border p-4 space-y-4"> + <h4 className="text-md font-semibold">기타 첨부파일 추가</h4> + <FormField + control={form.control} + name="attachedFiles" + render={() => ( + <FormItem> + <FormLabel> + 첨부 파일 (추가) + </FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>파일 업로드</DropzoneTitle> + <DropzoneDescription> + 드래그 또는 클릭 + {maxSize + ? ` (최대: ${prettyBytes(maxSize)})` + : null} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + {selectedFiles.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {selectedFiles.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeFile(i)}> + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </FormItem> + )} + /> + </div> + {/* Submit 버튼 */} + <div className="flex justify-end"> + <Button type="submit" disabled={!isFormValid || isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업데이트 중... + </> + ) : ( + "정보 업데이트" + )} + </Button> + </div> + </form> + </Form> + </div> + </section> + </div> + ) +}
\ No newline at end of file |
