summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/additional-info/join-form.tsx1344
-rw-r--r--components/client-data-table/data-table.tsx84
-rw-r--r--components/pq/pq-input-tabs.tsx136
-rw-r--r--components/pq/pq-review-detail.tsx452
-rw-r--r--components/pq/project-select-wrapper.tsx35
-rw-r--r--components/pq/project-select.tsx173
-rw-r--r--components/signup/join-form.tsx329
-rw-r--r--components/vendor-data/vendor-data-container.tsx14
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"