diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/additional-info/join-form.tsx | 1344 | ||||
| -rw-r--r-- | components/client-data-table/data-table.tsx | 84 | ||||
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 136 | ||||
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 452 | ||||
| -rw-r--r-- | components/pq/project-select-wrapper.tsx | 35 | ||||
| -rw-r--r-- | components/pq/project-select.tsx | 173 | ||||
| -rw-r--r-- | components/signup/join-form.tsx | 329 | ||||
| -rw-r--r-- | components/vendor-data/vendor-data-container.tsx | 14 |
8 files changed, 2010 insertions, 557 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 diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx index ff10bfe4..9336db62 100644 --- a/components/client-data-table/data-table.tsx +++ b/components/client-data-table/data-table.tsx @@ -63,24 +63,6 @@ export function ClientDataTable<TData, TValue>({ const [sorting, setSorting] = React.useState<SortingState>([]) const [grouping, setGrouping] = React.useState<string[]>([]) const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({}) - - // 실제 리사이징 상태만 추적 - const [isResizing, setIsResizing] = React.useState(false) - - // 리사이징 상태를 추적하기 위한 ref - const isResizingRef = React.useRef(false) - - // 리사이징 이벤트 핸들러 - const handleResizeStart = React.useCallback(() => { - isResizingRef.current = true - setIsResizing(true) - }, []) - - const handleResizeEnd = React.useCallback(() => { - isResizingRef.current = false - setIsResizing(false) - }, []) - const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: ["update"], @@ -115,41 +97,12 @@ export function ClientDataTable<TData, TValue>({ getGroupedRowModel: getGroupedRowModel(), autoResetPageIndex: false, getExpandedRowModel: getExpandedRowModel(), - enableColumnPinning:true, - onColumnPinningChange:setColumnPinning - + enableColumnPinning: true, + onColumnPinningChange: setColumnPinning }) useAutoSizeColumns(table, autoSizeColumns) - // 컴포넌트 마운트 시 강제로 리사이징 상태 초기화 - React.useEffect(() => { - // 강제로 초기 상태는 리사이징 비활성화 - setIsResizing(false) - isResizingRef.current = false - - // 전역 마우스 이벤트 핸들러 - const handleMouseUp = () => { - if (isResizingRef.current) { - handleResizeEnd() - } - } - - // 이벤트 리스너 등록 - window.addEventListener('mouseup', handleMouseUp) - window.addEventListener('touchend', handleMouseUp) - - return () => { - // 이벤트 리스너 정리 - window.removeEventListener('mouseup', handleMouseUp) - window.removeEventListener('touchend', handleMouseUp) - - // 컴포넌트 언마운트 시 정리 - setIsResizing(false) - isResizingRef.current = false - } - }, [handleResizeEnd]) - React.useEffect(() => { if (!onSelectedRowsChange) return const selectedRows = table @@ -188,27 +141,25 @@ export function ClientDataTable<TData, TValue>({ <TableHead key={header.id} colSpan={header.colSpan} - className="relative" + data-column-id={header.column.id} style={{ ...getCommonPinningStyles({ column: header.column }), width: header.getSize() }} > - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() + <div style={{ position: "relative" }}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {/* 리사이즈 핸들 - 헤더에만 추가 */} + {header.column.getCanResize() && ( + <DataTableResizer header={header} /> )} - - {/* 리사이즈 핸들 - 헤더에만 추가 */} - {header.column.getCanResize() && ( - <DataTableResizer - header={header} - onResizeStart={handleResizeStart} - onResizeEnd={handleResizeEnd} - /> - )} + </div> </TableHead> ) })} @@ -322,11 +273,6 @@ export function ClientDataTable<TData, TValue>({ )} </TableBody> </UiTable> - - {/* 리사이징 시에만 캡처 레이어 활성화 */} - {isResizing && ( - <div className="fixed inset-0 cursor-col-resize select-none z-50" /> - )} </div> </div> diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx index 743e1729..b84d9167 100644 --- a/components/pq/pq-input-tabs.tsx +++ b/components/pq/pq-input-tabs.tsx @@ -54,7 +54,7 @@ import { FileListName, } from "@/components/ui/file-list" -// Dialog components from shadcn/ui +// Dialog components import { Dialog, DialogContent, @@ -65,13 +65,15 @@ import { } from "@/components/ui/dialog" // Additional UI -import { Separator } from "../ui/separator" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" -// Server actions (adjust to your actual code) +// Server actions import { uploadFileAction, savePQAnswersAction, submitPQAction, + ProjectPQ, } from "@/lib/pq/service" import { PQGroupData } from "@/lib/pq/service" @@ -132,9 +134,13 @@ type PQFormValues = z.infer<typeof pqFormSchema> export function PQInputTabs({ data, vendorId, + projectId, + projectData, }: { data: PQGroupData[] vendorId: number + projectId?: number + projectData?: ProjectPQ | null }) { const [isSaving, setIsSaving] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -152,7 +158,7 @@ export function PQInputTabs({ data.forEach((group) => { group.items.forEach((item) => { - // Check if the server item is already “complete” + // Check if the server item is already "complete" const hasExistingAnswer = item.answer && item.answer.trim().length > 0 const hasExistingAttachments = item.attachments && item.attachments.length > 0 @@ -190,7 +196,7 @@ export function PQInputTabs({ // ---------------------------------------------------------------------- React.useEffect(() => { const values = form.getValues() - // We consider items “saved” if `saved===true` AND they have an answer or attachments + // We consider items "saved" if `saved===true` AND they have an answer or attachments const allItemsSaved = values.answers.every( (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) ) @@ -299,6 +305,7 @@ export function PQInputTabs({ const updatedAnswer = form.getValues(`answers.${answerIndex}`) const saveResult = await savePQAnswersAction({ vendorId, + projectId, // 프로젝트 ID 전달 answers: [ { criteriaId: updatedAnswer.criteriaId, @@ -396,13 +403,18 @@ export function PQInputTabs({ setIsSubmitting(true) setShowConfirmDialog(false) - const result = await submitPQAction(vendorId) + const result = await submitPQAction({ + vendorId, + projectId, // 프로젝트 ID 전달 + }) + if (result.ok) { toast({ title: "PQ Submitted", description: "Your PQ information has been submitted successfully", }) - // Optionally redirect + // 제출 후 페이지 새로고침 또는 리디렉션 처리 + window.location.reload() } else { toast({ title: "Submit Error", @@ -421,6 +433,72 @@ export function PQInputTabs({ setIsSubmitting(false) } } + + // 프로젝트 정보 표시 섹션 + const renderProjectInfo = () => { + if (!projectData) return null; + + return ( + <div className="mb-6 bg-muted p-4 rounded-md"> + <div className="flex items-center justify-between mb-2"> + <h3 className="text-lg font-semibold">프로젝트 정보</h3> + <Badge variant={getStatusVariant(projectData.status)}> + {getStatusLabel(projectData.status)} + </Badge> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p> + <p>{projectData.projectCode}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground">프로젝트명</p> + <p>{projectData.projectName}</p> + </div> + {projectData.submittedAt && ( + <div className="col-span-1 md:col-span-2"> + <p className="text-sm font-medium text-muted-foreground">제출일</p> + <p>{formatDate(projectData.submittedAt)}</p> + </div> + )} + </div> + </div> + ); + }; + + // 상태 표시용 함수 + const getStatusLabel = (status: string) => { + switch (status) { + case "REQUESTED": return "요청됨"; + case "IN_PROGRESS": return "진행중"; + case "SUBMITTED": return "제출됨"; + case "APPROVED": return "승인됨"; + case "REJECTED": return "반려됨"; + default: return status; + } + }; + + const getStatusVariant = (status: string) => { + switch (status) { + case "REQUESTED": return "secondary"; + case "IN_PROGRESS": return "default"; + case "SUBMITTED": return "outline"; + case "APPROVED": return "outline"; + case "REJECTED": return "destructive"; + default: return "secondary"; + } + }; + + // 날짜 형식화 함수 + const formatDate = (date: Date) => { + if (!date) return "-"; + return new Date(date).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; // ---------------------------------------------------------------------- // H) Render @@ -428,6 +506,9 @@ export function PQInputTabs({ return ( <Form {...form}> <form> + {/* 프로젝트 정보 섹션 */} + {renderProjectInfo()} + <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> {/* Top Controls */} <div className="flex justify-between items-center mb-4"> @@ -485,7 +566,7 @@ export function PQInputTabs({ {/* 2-column grid */} <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"> {group.items.map((item) => { - const { criteriaId, code, checkPoint, description } = item + const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item const answerIndex = getAnswerIndex(criteriaId) if (answerIndex === -1) return null @@ -498,7 +579,7 @@ export function PQInputTabs({ const hasNewUploads = newUploads.length > 0 const canSave = isItemDirty || hasNewUploads - // For “Not Saved” vs. “Saved” status label + // For "Not Saved" vs. "Saved" status label const hasUploads = form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || newUploads.length > 0 @@ -556,13 +637,32 @@ export function PQInputTabs({ </CardHeader> <CollapsibleContent> - {/* Answer Field */} - <CardHeader className="pt-0 pb-3"> + <CardContent className="pt-3 space-y-3"> + {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} + {projectId && contractInfo && ( + <div className="space-y-1"> + <FormLabel className="text-sm font-medium">계약 정보</FormLabel> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {contractInfo} + </div> + </div> + )} + + {projectId && additionalRequirement && ( + <div className="space-y-1"> + <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {additionalRequirement} + </div> + </div> + )} + + {/* Answer Field */} <FormField control={form.control} name={`answers.${answerIndex}.answer`} render={({ field }) => ( - <FormItem className="mt-3"> + <FormItem className="mt-2"> <FormLabel>Answer</FormLabel> <FormControl> <Textarea @@ -583,11 +683,10 @@ export function PQInputTabs({ </FormItem> )} /> - </CardHeader> + - {/* Attachments / Dropzone */} - <CardContent> - <div className="grid gap-2"> + {/* Attachments / Dropzone */} + <div className="grid gap-2 mt-3"> <FormLabel>Attachments</FormLabel> <Dropzone maxSize={6e8} // 600MB @@ -708,7 +807,10 @@ export function PQInputTabs({ <DialogHeader> <DialogTitle>Confirm Submission</DialogTitle> <DialogDescription> - Review your answers before final submission. + {projectId + ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?` + : "일반 PQ 응답을 제출하시겠습니까?" + } 제출 후에는 수정이 불가능합니다. </DialogDescription> </DialogHeader> diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx index e5cd080e..18af02ed 100644 --- a/components/pq/pq-review-detail.tsx +++ b/components/pq/pq-review-detail.tsx @@ -5,9 +5,16 @@ import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { useToast } from "@/hooks/use-toast" -import { PQGroupData, requestPqChangesAction, updateVendorStatusAction, getItemReviewLogsAction } from "@/lib/pq/service" +import { + PQGroupData, + requestPqChangesAction, + updateVendorStatusAction, + updateProjectPQStatusAction, + getItemReviewLogsAction +} from "@/lib/pq/service" import { Vendor } from "@/db/schema/vendors" import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react" import { Collapsible, @@ -42,38 +49,80 @@ interface ReviewLog { createdAt: Date } +// Updated props interface to support both general and project PQs +interface VendorPQAdminReviewProps { + data: PQGroupData[] + vendor: Vendor + projectId?: number + projectName?: string + projectStatus?: string + loadData: () => Promise<PQGroupData[]> + pqType: 'general' | 'project' +} + export default function VendorPQAdminReview({ data, vendor, -}: { - data: PQGroupData[] - vendor: Vendor -}) { + projectId, + projectName, + projectStatus, + loadData, + pqType +}: VendorPQAdminReviewProps) { const { toast } = useToast() - + + // State for dynamically loaded data + const [pqData, setPqData] = React.useState<PQGroupData[]>(data) + const [isDataLoading, setIsDataLoading] = React.useState(false) + + // Load data if not provided initially (for tab switching) + React.useEffect(() => { + if (data.length === 0) { + const fetchData = async () => { + setIsDataLoading(true) + try { + const freshData = await loadData() + setPqData(freshData) + } catch (error) { + console.error("Error loading PQ data:", error) + toast({ + title: "Error", + description: "Failed to load PQ data", + variant: "destructive" + }) + } finally { + setIsDataLoading(false) + } + } + fetchData() + } else { + setPqData(data) + } + }, [data, loadData, toast]) + // 다이얼로그 상태들 const [showRequestDialog, setShowRequestDialog] = React.useState(false) const [showApproveDialog, setShowApproveDialog] = React.useState(false) const [showRejectDialog, setShowRejectDialog] = React.useState(false) - + // 코멘트 상태들 const [requestComment, setRequestComment] = React.useState("") const [approveComment, setApproveComment] = React.useState("") const [rejectComment, setRejectComment] = React.useState("") const [isLoading, setIsLoading] = React.useState(false) - + // 항목별 코멘트 상태 추적 (메모리에만 저장) const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([]) - + // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장 const handleCommentAdded = (newComment: PendingComment) => { setPendingComments(prev => [...prev, newComment]); - toast({ - title: "Comment Added", - description: `Comment added for ${newComment.code}. Please "Request Changes" to save.` + toast({ + title: "Comment Added", + description: `Comment added for ${newComment.code}. Please "Request Changes" to save.` }); } - + // 코멘트 삭제 핸들러 const handleRemoveComment = (index: number) => { setPendingComments(prev => prev.filter((_, i) => i !== index)); @@ -90,19 +139,40 @@ export default function VendorPQAdminReview({ setShowApproveDialog(true) } - // 실제 승인 처리 + // 실제 승인 처리 - 일반 PQ와 프로젝트 PQ 분리 const handleSubmitApprove = async () => { try { setIsLoading(true) setShowApproveDialog(false) - - const res = await updateVendorStatusAction(vendor.id, "APPROVED") - if (res.ok) { - toast({ title: "Approved", description: "Vendor PQ has been approved." }) + + let res; + + if (pqType === 'general') { + // 일반 PQ 승인 + res = await updateVendorStatusAction(vendor.id, "PQ_APPROVED") + } else if (projectId) { + // 프로젝트 PQ 승인 + res = await updateProjectPQStatusAction({ + vendorId: vendor.id, + projectId, + status: "APPROVED", + comment: approveComment.trim() || undefined + }) + } + + if (res?.ok) { + toast({ + title: "Approved", + description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been approved.` + }) // 코멘트 초기화 setPendingComments([]); } else { - toast({ title: "Error", description: res.error, variant: "destructive" }) + toast({ + title: "Error", + description: res?.error || "An error occurred", + variant: "destructive" + }) } } catch (error) { toast({ title: "Error", description: String(error), variant: "destructive" }) @@ -123,19 +193,49 @@ export default function VendorPQAdminReview({ setShowRejectDialog(true) } - // 실제 거부 처리 + // 실제 거부 처리 - 일반 PQ와 프로젝트 PQ 분리 const handleSubmitReject = async () => { try { setIsLoading(true) setShowRejectDialog(false) - - const res = await updateVendorStatusAction(vendor.id, "REJECTED") - if (res.ok) { - toast({ title: "Rejected", description: "Vendor PQ has been rejected." }) + + if (!rejectComment.trim()) { + toast({ + title: "Error", + description: "Please provide a reason for rejection", + variant: "destructive" + }) + return; + } + + let res; + + if (pqType === 'general') { + // 일반 PQ 거부 + res = await updateVendorStatusAction(vendor.id, "REJECTED") + } else if (projectId) { + // 프로젝트 PQ 거부 + res = await updateProjectPQStatusAction({ + vendorId: vendor.id, + projectId, + status: "REJECTED", + comment: rejectComment + }) + } + + if (res?.ok) { + toast({ + title: "Rejected", + description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been rejected.` + }) // 코멘트 초기화 setPendingComments([]); } else { - toast({ title: "Error", description: res.error, variant: "destructive" }) + toast({ + title: "Error", + description: res?.error || "An error occurred", + variant: "destructive" + }) } } catch (error) { toast({ title: "Error", description: String(error), variant: "destructive" }) @@ -150,103 +250,169 @@ export default function VendorPQAdminReview({ setShowRequestDialog(true) } - // 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장 -// 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장 -const handleSubmitRequestChanges = async () => { - try { - setIsLoading(true); - setShowRequestDialog(false); - - // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송 - const itemComments = pendingComments.map(pc => ({ - answerId: pc.answerId, - checkPoint: pc.checkPoint, // 추가: 체크포인트 정보 전송 - code: pc.code, // 추가: 코드 정보 전송 - comment: pc.comment - })); - - // 서버 액션 호출 - const res = await requestPqChangesAction({ - vendorId: vendor.id, - comment: itemComments, - generalComment: requestComment || undefined - }); - - if (res.ok) { + // 4) 변경 요청 처리 - 이제 프로젝트 ID 포함 + const handleSubmitRequestChanges = async () => { + try { + setIsLoading(true); + setShowRequestDialog(false); + + // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송 + const itemComments = pendingComments.map(pc => ({ + answerId: pc.answerId, + checkPoint: pc.checkPoint, + code: pc.code, + comment: pc.comment + })); + + // 서버 액션 호출 (프로젝트 ID 추가) + const res = await requestPqChangesAction({ + vendorId: vendor.id, + projectId: pqType === 'project' ? projectId : undefined, + comment: itemComments, + generalComment: requestComment || undefined + }); + + if (res.ok) { + toast({ + title: "Changes Requested", + description: `${pqType === 'general' ? 'Vendor' : 'Project'} was notified of your comments.`, + }); + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ + title: "Error", + description: res.error, + variant: "destructive" + }); + } + } catch (error) { toast({ - title: "Changes Requested", - description: "Vendor was notified of your comments.", + title: "Error", + description: String(error), + variant: "destructive" }); - // 코멘트 초기화 - setPendingComments([]); - } else { - toast({ title: "Error", description: res.error, variant: "destructive" }); + } finally { + setIsLoading(false); + setRequestComment(""); } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - setRequestComment(""); - } -}; + }; + + // 현재 상태에 따른 액션 버튼 비활성화 여부 판단 + const getDisabledState = () => { + if (pqType === 'general') { + // 일반 PQ는 vendor 상태에 따라 결정 + return vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED'; + } else if (pqType === 'project' && projectStatus) { + // 프로젝트 PQ는 project 상태에 따라 결정 + return projectStatus === 'APPROVED' || projectStatus === 'REJECTED'; + } + return false; + }; + + const areActionsDisabled = getDisabledState(); return ( <div className="space-y-4"> - {/* Top header */} - <div className="flex items-center justify-between"> - <h2 className="text-2xl font-bold"> - {vendor.vendorCode} - {vendor.vendorName} PQ Review - </h2> - <div className="flex gap-2"> - <Button - variant="outline" - disabled={isLoading} - onClick={handleReject} - > - Reject - </Button> - <Button - variant={pendingComments.length > 0 ? "default" : "outline"} - disabled={isLoading} - onClick={handleRequestChanges} - > - Request Changes - {pendingComments.length > 0 && ( - <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1"> - {pendingComments.length} - </span> + {/* PQ Type indicators and status */} + {pqType === 'project' && projectName && ( + <div className="flex flex-col space-y-1 mb-4"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{projectName}</Badge> + {projectStatus && ( + <Badge className={ + projectStatus === 'APPROVED' ? 'bg-green-100 text-green-800' : + projectStatus === 'REJECTED' ? 'bg-red-100 text-red-800' : + 'bg-blue-100 text-blue-800' + }> + {projectStatus} + </Badge> )} - </Button> - <Button - disabled={isLoading} - onClick={handleApprove} - > - Approve - </Button> + </div> + {areActionsDisabled && ( + <p className="text-sm text-muted-foreground"> + This PQ has already been { + pqType !== 'project' + ? (vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED' ? 'approved' : 'rejected') + : (projectStatus === 'APPROVED' ? 'approved' : 'rejected') + }. No further actions can be taken. + </p> + )} </div> - </div> - - <p className="text-sm text-muted-foreground"> - Review the submitted PQ items below, then approve, reject, or request more info. - </p> - - {/* 코멘트가 있을 때 알림 표시 */} - {pendingComments.length > 0 && ( - <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800"> - <p className="text-sm font-medium flex items-center"> - <span className="mr-2">⚠️</span> - You have {pendingComments.length} pending comments. Click "Request Changes" to save them. - </p> + )} + + {/* Loading indicator */} + {isDataLoading && ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> </div> )} - <Separator /> + {!isDataLoading && ( + <> + {/* Top header */} + <div className="flex items-center justify-between"> + <h2 className="text-2xl font-bold"> + {vendor.vendorCode} - {vendor.vendorName} {pqType === 'project' ? 'Project' : 'General'} PQ Review + </h2> + <div className="flex gap-2"> + <Button + variant="outline" + disabled={isLoading || areActionsDisabled} + onClick={handleReject} + > + Reject + </Button> + <Button + variant={pendingComments.length > 0 ? "default" : "outline"} + disabled={isLoading || areActionsDisabled} + onClick={handleRequestChanges} + > + Request Changes + {pendingComments.length > 0 && ( + <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1"> + {pendingComments.length} + </span> + )} + </Button> + <Button + disabled={isLoading || areActionsDisabled} + onClick={handleApprove} + > + Approve + </Button> + </div> + </div> + + <p className="text-sm text-muted-foreground"> + Review the submitted PQ items below, then approve, reject, or request more info. + </p> + + {/* 코멘트가 있을 때 알림 표시 */} + {pendingComments.length > 0 && ( + <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800"> + <p className="text-sm font-medium flex items-center"> + <span className="mr-2">⚠️</span> + You have {pendingComments.length} pending comments. Click "Request Changes" to save them. + </p> + </div> + )} - {/* VendorPQReviewPage 컴포넌트 대신 직접 구현 */} - <VendorPQReviewPageIntegrated - data={data} - onCommentAdded={handleCommentAdded} - /> + <Separator /> + + {/* PQ 데이터 표시 */} + {pqData.length > 0 ? ( + <VendorPQReviewPageIntegrated + data={pqData} + onCommentAdded={handleCommentAdded} + /> + ) : ( + <div className="text-center py-10"> + <p className="text-muted-foreground">No PQ data available for review.</p> + </div> + )} + </> + )} {/* 변경 요청 다이얼로그 */} <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}> @@ -274,9 +440,9 @@ const handleSubmitRequestChanges = async () => { {formatDate(comment.createdAt)} </p> </div> - <Button - variant="ghost" - size="sm" + <Button + variant="ghost" + size="sm" className="p-0 h-8 w-8" onClick={() => handleRemoveComment(index)} > @@ -290,15 +456,15 @@ const handleSubmitRequestChanges = async () => { {/* 추가 코멘트 입력 */} <div className="space-y-2 mt-2"> <label className="text-sm font-medium"> - {pendingComments.length > 0 - ? "Additional comments (optional):" + {pendingComments.length > 0 + ? "Additional comments (optional):" : "Enter details about what should be modified:"} </label> <Textarea value={requestComment} onChange={(e) => setRequestComment(e.target.value)} - placeholder={pendingComments.length > 0 - ? "Add any additional notes..." + placeholder={pendingComments.length > 0 + ? "Add any additional notes..." : "Please correct item #1, etc..."} className="min-h-[100px]" /> @@ -312,8 +478,8 @@ const handleSubmitRequestChanges = async () => { > Cancel </Button> - <Button - onClick={handleSubmitRequestChanges} + <Button + onClick={handleSubmitRequestChanges} disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())} > Submit Changes @@ -328,7 +494,7 @@ const handleSubmitRequestChanges = async () => { <DialogHeader> <DialogTitle>Confirm Approval</DialogTitle> <DialogDescription> - Are you sure you want to approve this vendor PQ? You can add a comment if needed. + Are you sure you want to approve this {pqType === 'project' ? 'project' : 'vendor'} PQ? You can add a comment if needed. </DialogDescription> </DialogHeader> @@ -349,8 +515,8 @@ const handleSubmitRequestChanges = async () => { > Cancel </Button> - <Button - onClick={handleSubmitApprove} + <Button + onClick={handleSubmitApprove} disabled={isLoading} > Confirm Approval @@ -365,7 +531,7 @@ const handleSubmitRequestChanges = async () => { <DialogHeader> <DialogTitle>Confirm Rejection</DialogTitle> <DialogDescription> - Are you sure you want to reject this vendor PQ? Please provide a reason. + Are you sure you want to reject this {pqType === 'project' ? 'project' : 'vendor'} PQ? Please provide a reason. </DialogDescription> </DialogHeader> @@ -386,7 +552,7 @@ const handleSubmitRequestChanges = async () => { > Cancel </Button> - <Button + <Button onClick={handleSubmitReject} disabled={isLoading || !rejectComment.trim()} variant="destructive" @@ -417,46 +583,46 @@ function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPa title: "Download Started", description: `Preparing ${fileName} for download...`, }); - + // 서버 액션 호출 const result = await downloadFileAction(filePath); - + if (!result.ok || !result.data) { throw new Error(result.error || 'Failed to download file'); } - + // Base64 디코딩하여 Blob 생성 const binaryString = atob(result.data.content); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } - + // Blob 생성 및 다운로드 const blob = new Blob([bytes.buffer], { type: result.data.mimeType }); const url = URL.createObjectURL(blob); - + // 다운로드 링크 생성 및 클릭 const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); - + // 정리 URL.revokeObjectURL(url); document.body.removeChild(a); - + toast({ title: "Download Complete", description: `${fileName} downloaded successfully`, }); } catch (error) { console.error('Download error:', error); - toast({ - title: "Download Error", + toast({ + title: "Download Error", description: error instanceof Error ? error.message : "Failed to download file", - variant: "destructive" + variant: "destructive" }); } }; @@ -524,7 +690,7 @@ function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPa </TableCell> <TableCell className="text-center"> - <ItemCommentButton + <ItemCommentButton item={item} onCommentAdded={onCommentAdded} /> @@ -566,7 +732,7 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { try { setIsLoading(true); const res = await getItemReviewLogsAction({ answerId: item.answerId }); - + if (res.ok && res.data) { setLogs(res.data); // 코멘트 존재 여부 설정 @@ -595,7 +761,7 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { console.error("Error checking comments:", error); } }; - + checkComments(); }, [item.answerId]); @@ -619,9 +785,9 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { // 코멘트 추가 처리 (메모리에만 저장) const handleAddComment = React.useCallback(() => { if (!newComment.trim()) return; - + setIsLoading(true); - + // 새 코멘트 생성 const pendingComment: PendingComment = { answerId: item.answerId, @@ -630,10 +796,10 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { comment: newComment.trim(), createdAt: new Date() }; - + // 부모 컴포넌트에 전달 onCommentAdded(pendingComment); - + // 상태 초기화 setNewComment(""); setOpen(false); @@ -643,8 +809,8 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { return ( <> <Button variant="ghost" size="sm" onClick={handleButtonClick}> - <MessagesSquare - className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} + <MessagesSquare + className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} /> </Button> diff --git a/components/pq/project-select-wrapper.tsx b/components/pq/project-select-wrapper.tsx new file mode 100644 index 00000000..1405ab02 --- /dev/null +++ b/components/pq/project-select-wrapper.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { type Project } from "@/lib/rfqs/service" +import { ProjectSelector } from "./project-select" + +interface ProjectSelectorWrapperProps { + selectedProjectId?: number | null +} + +export function ProjectSelectorWrapper({ selectedProjectId }: ProjectSelectorWrapperProps) { + const router = useRouter() + + const handleProjectSelect = (project: Project | null) => { + if (project && project.id) { + router.push(`/evcp/pq-criteria/${project.id}`) + } else { + // 프로젝트가 null인 경우 (선택 해제) + router.push(`/evcp/pq-criteria`) + } + } + + return ( + <div className="w-[400px]"> + <ProjectSelector + selectedProjectId={selectedProjectId} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요" + showClearOption={true} + clearOptionText="일반 PQ 보기" + /> + </div> + ) +}
\ No newline at end of file diff --git a/components/pq/project-select.tsx b/components/pq/project-select.tsx new file mode 100644 index 00000000..0d6e6445 --- /dev/null +++ b/components/pq/project-select.tsx @@ -0,0 +1,173 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { getProjects, type Project } from "@/lib/rfqs/service" + +interface ProjectSelectorProps { + selectedProjectId?: number | null; + onProjectSelect: (project: Project | null) => void; + placeholder?: string; + showClearOption?: boolean; + clearOptionText?: string; +} + +export function ProjectSelector({ + selectedProjectId, + onProjectSelect, + placeholder = "프로젝트 선택...", + showClearOption = true, + clearOptionText = "일반 PQ 보기" +}: ProjectSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [projects, setProjects] = React.useState<Project[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + + // 모든 프로젝트 데이터 로드 (한 번만) + React.useEffect(() => { + async function loadAllProjects() { + setIsLoading(true); + try { + const allProjects = await getProjects(); + setProjects(allProjects); + + // 초기 선택된 프로젝트가 있으면 설정 + if (selectedProjectId) { + const selected = allProjects.find(p => p.id === selectedProjectId); + if (selected) { + setSelectedProject(selected); + } + } + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error); + } finally { + setIsLoading(false); + } + } + + loadAllProjects(); + }, [selectedProjectId]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredProjects = React.useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowerSearch = searchTerm.toLowerCase(); + return projects.filter( + project => + project.projectCode.toLowerCase().includes(lowerSearch) || + project.projectName.toLowerCase().includes(lowerSearch) + ); + }, [projects, searchTerm]); + + // 프로젝트 선택 처리 + const handleSelectProject = (project: Project) => { + setSelectedProject(project); + onProjectSelect(project); + setOpen(false); + }; + + // 선택 해제 처리 + const handleClearSelection = () => { + setSelectedProject(null); + onProjectSelect(null); + setOpen(false); + }; + + return ( + <div className="space-y-1"> + {/* 선택된 프로젝트 정보 표시 (선택된 경우에만) */} + {selectedProject && ( + <div className="flex items-center justify-between px-2"> + <div className="flex flex-col"> + <div className="text-sm font-medium">{selectedProject.projectCode}</div> + <div className="text-xs text-muted-foreground truncate max-w-[300px]"> + {selectedProject.projectName} + </div> + </div> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive" + onClick={handleClearSelection} + > + <X className="h-4 w-4" /> + <span className="sr-only">선택 해제</span> + </Button> + </div> + )} + + {/* 셀렉터 컴포넌트 */} + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedProject ? "프로젝트 변경..." : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="프로젝트 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + + {showClearOption && selectedProject && ( + <> + <CommandGroup> + <CommandItem + onSelect={handleClearSelection} + className="text-blue-600 font-medium" + > + {clearOptionText} + </CommandItem> + </CommandGroup> + <CommandSeparator /> + </> + )} + + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredProjects.map((project) => ( + <CommandItem + key={project.id} + value={`${project.projectCode} ${project.projectName}`} + onSelect={() => handleSelectProject(project)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{project.projectCode}</span> + <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ); +}
\ No newline at end of file diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 06aee3b5..6f9ad891 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -81,35 +81,19 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ 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 export function JoinForm() { - const params = useParams() - const lng = (params.lng as string) || "ko" + const params = useParams() || {}; + const lng = params.lng ? String(params.lng) : "ko"; const { t } = useTranslation(lng, "translation") const router = useRouter() - const searchParams = useSearchParams() + const searchParams = useSearchParams() || new URLSearchParams(); const defaultTaxId = searchParams.get("taxID") ?? "" // File states const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) - const [creditRatingFile, setCreditRatingFile] = React.useState<File[]>([]) - const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState<File[]>([]) const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -128,12 +112,7 @@ export function JoinForm() { representativeEmail: "", representativePhone: "", corporateRegistrationNumber: "", - creditAgency: "", - creditRating: "", - cashFlowRating: "", attachedFiles: undefined, - creditRatingAttachment: undefined, - cashFlowRatingAttachment: undefined, // contacts (no isPrimary) contacts: [ { @@ -157,7 +136,7 @@ export function JoinForm() { name: "contacts", }) - // Dropzone handlers (same as before)... + // Dropzone handlers const handleDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] setSelectedFiles(newFiles) @@ -179,48 +158,6 @@ export function JoinForm() { 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) @@ -228,12 +165,6 @@ export function JoinForm() { 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, @@ -249,17 +180,12 @@ export function JoinForm() { representativeBirth: values.representativeBirth || "", representativeEmail: values.representativeEmail || "", representativePhone: values.representativePhone || "", - corporateRegistrationNumber: values.corporateRegistrationNumber || "", - creditAgency: values.creditAgency || "", - creditRating: values.creditRating || "", - cashFlowRating: values.cashFlowRating || "", + corporateRegistrationNumber: values.corporateRegistrationNumber || "" } const result = await createVendor({ vendorData, files: mainFiles, - creditRatingFiles, - cashFlowRatingFiles, contacts: values.contacts, }) @@ -671,251 +597,6 @@ export function JoinForm() { )} /> </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} - defaultValue={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} - defaultValue={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} - defaultValue={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 className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 신용평가등급 첨부</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 className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 현금흐름등급 첨부</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> )} diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index 69c22b79..11aa6f9d 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -6,7 +6,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { cn } from "@/lib/utils" import { ProjectSwitcher } from "./project-swicher" import { Sidebar } from "./sidebar" -import { useParams, usePathname, useRouter } from "next/navigation" +import { usePathname, useRouter } from "next/navigation" import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" import { Separator } from "@/components/ui/separator" @@ -36,7 +36,9 @@ interface VendorDataContainerProps { children: React.ReactNode } -function getTagIdFromPathname(path: string): number | null { +function getTagIdFromPathname(path: string | null): number | null { + if (!path) return null; + // 태그 패턴 검사 (/tag/123) const tagMatch = path.match(/\/tag\/(\d+)/) if (tagMatch) return parseInt(tagMatch[1], 10) @@ -47,6 +49,7 @@ function getTagIdFromPathname(path: string): number | null { return null } + export function VendorDataContainer({ projects, defaultLayout = [20, 80], @@ -55,9 +58,10 @@ export function VendorDataContainer({ children }: VendorDataContainerProps) { const pathname = usePathname() - const router = useRouter() + const tagIdNumber = getTagIdFromPathname(pathname) + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) // 폼 로드 요청 추적 const lastRequestIdRef = React.useRef(0) @@ -70,16 +74,18 @@ export function VendorDataContainer({ // URL에서 들어온 tagIdNumber를 우선으로 설정하기 위해 초기에 null로 두고, 뒤에서 useEffect로 세팅 const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) + const [formList, setFormList] = React.useState<FormInfo[]>([]) const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) const [isLoadingForms, setIsLoadingForms] = React.useState(false) + // 현재 선택된 프로젝트/계약/패키지 const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) ?? currentProject?.contracts[0] - const isTagOrFormRoute = pathname.includes("/tag/") || pathname.includes("/form/") + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false const currentPackageName = isTagOrFormRoute ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" : "None" |
