diff options
80 files changed, 10607 insertions, 1466 deletions
diff --git a/app/[lng]/evcp/equip-class/page.tsx b/app/[lng]/evcp/equip-class/page.tsx index fcda1c1d..375eb69e 100644 --- a/app/[lng]/evcp/equip-class/page.tsx +++ b/app/[lng]/evcp/equip-class/page.tsx @@ -38,7 +38,7 @@ export default async function IndexPage(props: IndexPageProps) { Object Class List from S-EDP </h2> <p className="text-muted-foreground"> - 벤더 데이터 입력을 위한 Form 리스트입니다.{" "} + Object Class List를 확인할 수 있습니다.{" "} {/* <span className="inline-flex items-center whitespace-nowrap"> <Ellipsis className="size-3" /> <span className="ml-1">버튼</span> diff --git a/app/[lng]/evcp/pq-criteria/[id]/page.tsx b/app/[lng]/evcp/pq-criteria/[id]/page.tsx new file mode 100644 index 00000000..f040a0ca --- /dev/null +++ b/app/[lng]/evcp/pq-criteria/[id]/page.tsx @@ -0,0 +1,81 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { searchParamsCache } from "@/lib/pq/validations" +import { getPQs } from "@/lib/pq/service" +import { PqsTable } from "@/lib/pq/table/pq-table" +import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper" +import { notFound } from "next/navigation" + +interface ProjectPageProps { + params: { id: string } + searchParams: Promise<SearchParams> +} + +export default async function ProjectPage(props: ProjectPageProps) { + const resolvedParams = await props.params + const id = resolvedParams.id + + const projectId = parseInt(id, 10) + + // 유효하지 않은 projectId 확인 + if (isNaN(projectId)) { + notFound() + } + + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + + // filters가 없는 경우를 처리 + const validFilters = getValidFilters(search.filters) + + // 프로젝트별 PQ 데이터 가져오기 + const promises = Promise.all([ + getPQs({ + ...search, + filters: validFilters, + }, projectId, false) + ]) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Pre-Qualification Check Sheet + </h2> + <p className="text-muted-foreground"> + 벤더 등록을 위한, 벤더가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다. + </p> + </div> + <ProjectSelectorWrapper selectedProjectId={projectId} /> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <PqsTable promises={promises} currentProjectId={projectId}/> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/pq-criteria/page.tsx b/app/[lng]/evcp/pq-criteria/page.tsx index d924890d..778baa93 100644 --- a/app/[lng]/evcp/pq-criteria/page.tsx +++ b/app/[lng]/evcp/pq-criteria/page.tsx @@ -1,14 +1,13 @@ import * as React from "react" import { type SearchParams } from "@/types/table" - import { getValidFilters } from "@/lib/data-table" import { Skeleton } from "@/components/ui/skeleton" import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" import { Shell } from "@/components/shell" - import { searchParamsCache } from "@/lib/pq/validations" import { getPQs } from "@/lib/pq/service" import { PqsTable } from "@/lib/pq/table/pq-table" +import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper" interface IndexPageProps { searchParams: Promise<SearchParams> @@ -17,34 +16,33 @@ interface IndexPageProps { export default async function IndexPage(props: IndexPageProps) { const searchParams = await props.searchParams const search = searchParamsCache.parse(searchParams) - + + // filters가 없는 경우를 처리 + const validFilters = getValidFilters(search.filters) + // onlyGeneral: true로 설정하여 일반 PQ 항목만 가져옴 const promises = Promise.all([ getPQs({ ...search, filters: validFilters, - }), + }, null, true) ]) return ( <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - </h2> - <p className="text-muted-foreground"> - 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다. - - </p> - </div> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Pre-Qualification Check Sheet + </h2> + <p className="text-muted-foreground"> + 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다. + </p> </div> + <ProjectSelectorWrapper /> </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> {/* <DateRangePicker triggerSize="sm" @@ -53,6 +51,7 @@ export default async function IndexPage(props: IndexPageProps) { shallow={false} /> */} </React.Suspense> + <React.Suspense fallback={ <DataTableSkeleton @@ -64,8 +63,8 @@ export default async function IndexPage(props: IndexPageProps) { /> } > - <PqsTable promises={promises} /> + <PqsTable promises={promises}/> </React.Suspense> </Shell> ) -} +}
\ No newline at end of file diff --git a/app/[lng]/evcp/pq/[vendorId]/page.tsx b/app/[lng]/evcp/pq/[vendorId]/page.tsx index cb4277f1..97c9a29a 100644 --- a/app/[lng]/evcp/pq/[vendorId]/page.tsx +++ b/app/[lng]/evcp/pq/[vendorId]/page.tsx @@ -1,38 +1,109 @@ import * as React from "react" import { Shell } from "@/components/shell" import { Skeleton } from "@/components/ui/skeleton" - import { type SearchParams } from "@/types/table" -import { getPQDataByVendorId } from "@/lib/pq/service" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { getPQDataByVendorId, getVendorPQsList } from "@/lib/pq/service" import { Vendor } from "@/db/schema/vendors" import { findVendorById } from "@/lib/vendors/service" -import VendorPQReviewPage from "@/components/pq/pq-review-detail" import VendorPQAdminReview from "@/components/pq/pq-review-detail" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" interface IndexPageProps { params: { - vendorId: string // Updated from 'id' to 'contractId' to match route parameter + vendorId: string } searchParams: Promise<SearchParams> } -export default async function DocumentListPage(props: IndexPageProps) { +export default async function PQReviewPage(props: IndexPageProps) { const resolvedParams = await props.params - const vendorId = resolvedParams.vendorId // Updated from 'id' to 'contractId' - - const idAsNumber = Number(vendorId) - - const data = await getPQDataByVendorId(idAsNumber) - - const vendor: Vendor | null = await findVendorById(idAsNumber) - - // 4) 렌더링 + const vendorId = Number(resolvedParams.vendorId) + + // Fetch the vendor data + const vendor: Vendor | null = await findVendorById(vendorId) + if (!vendor) return <div>Vendor not found</div> + + // Get list of all PQs (general + project-specific) for this vendor + const pqsList = await getVendorPQsList(vendorId) + + // Determine default active PQ to display + // If query param projectId exists, use that, otherwise use general PQ if available + const searchParams = await props.searchParams + const activeProjectId = searchParams.projectId ? Number(searchParams.projectId) : undefined + + // If no projectId query param, default to general PQ or first project PQ + const defaultTabId = activeProjectId ? + `project-${activeProjectId}` : + (pqsList.hasGeneralPq ? 'general' : `project-${pqsList.projectPQs[0]?.projectId}`) + + // Fetch PQ data for the active tab + let pqData; + if (activeProjectId) { + // Get project-specific PQ data + pqData = await getPQDataByVendorId(vendorId, activeProjectId) + } else { + // Get general PQ data + pqData = await getPQDataByVendorId(vendorId) + } + return ( <Shell className="gap-2"> - {vendor && - <VendorPQAdminReview data={data} vendor={vendor} /> - } + {pqsList.hasGeneralPq || pqsList.projectPQs.length > 0 ? ( + <Tabs defaultValue={defaultTabId} className="space-y-4"> + <div className="flex justify-between items-center"> + <h1 className="text-2xl font-bold"> + {vendor.vendorName} PQ Review + </h1> + + <TabsList> + {pqsList.hasGeneralPq && ( + <TabsTrigger value="general"> + General PQ <Badge variant="outline" className="ml-2">Standard</Badge> + </TabsTrigger> + )} + + {pqsList.projectPQs.map((project) => ( + <TabsTrigger key={project.projectId} value={`project-${project.projectId}`}> + {project.projectName} <Badge variant="outline" className="ml-2">{project.status}</Badge> + </TabsTrigger> + ))} + </TabsList> + </div> + + {/* Tab content for General PQ */} + {pqsList.hasGeneralPq && ( + <TabsContent value="general" className="mt-0"> + <VendorPQAdminReview + data={activeProjectId ? [] : pqData} + vendor={vendor} + projectId={undefined} + loadData={() => getPQDataByVendorId(vendorId)} + pqType="general" + /> + </TabsContent> + )} + + {/* Tab content for each Project PQ */} + {pqsList.projectPQs.map((project) => ( + <TabsContent key={project.projectId} value={`project-${project.projectId}`} className="mt-0"> + <VendorPQAdminReview + data={activeProjectId === project.projectId ? pqData : []} + vendor={vendor} + projectId={project.projectId} + projectName={project.projectName} + projectStatus={project.status} + loadData={() => getPQDataByVendorId(vendorId, project.projectId)} + pqType="project" + /> + </TabsContent> + ))} + </Tabs> + ) : ( + <div className="text-center py-10"> + <h2 className="text-xl font-medium">No PQ submissions found for this vendor</h2> + </div> + )} </Shell> ) -} +}
\ No newline at end of file diff --git a/app/[lng]/evcp/vendor-candidates/page.tsx b/app/[lng]/evcp/vendor-candidates/page.tsx new file mode 100644 index 00000000..668c0dc6 --- /dev/null +++ b/app/[lng]/evcp/vendor-candidates/page.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" + +import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/vendor-candidates/service" +import { searchParamsCandidateCache } from "@/lib/vendor-candidates/validations" +import { VendorCandidateTable } from "@/lib/vendor-candidates/table/candidates-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsCandidateCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getVendorCandidates({ + ...search, + filters: validFilters, + }), + getVendorCandidateCounts() + ]) + + return ( + <Shell className="gap-2"> + + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Vendor Candidates Management + </h2> + <p className="text-muted-foreground"> + 수집한 벤더 후보를 등록하고 초대 메일을 송부할 수 있습니다. + + </p> + </div> + </div> + </div> + + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <VendorCandidateTable promises={promises}/> + </React.Suspense> + </Shell> + ) +} diff --git a/app/[lng]/evcp/vendor-investigation/page.tsx b/app/[lng]/evcp/vendor-investigation/page.tsx new file mode 100644 index 00000000..c59de869 --- /dev/null +++ b/app/[lng]/evcp/vendor-investigation/page.tsx @@ -0,0 +1,65 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" + +import { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/investigation-table" +import { getVendorsInvestigation } from "@/lib/vendor-investigation/service" +import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsInvestigationCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getVendorsInvestigation({ + ...search, + filters: validFilters, + }), + ]) + + return ( + <Shell className="gap-2"> + + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Vendor Investigation Management + </h2> + <p className="text-muted-foreground"> + 요청된 Vendor 실사에 대한 스케줄 정보를 관리하고 결과를 입력할 수 있습니다. + + </p> + </div> + </div> + </div> + + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <VendorsInvestigationTable promises={promises}/> + </React.Suspense> + </Shell> + ) +} diff --git a/app/[lng]/partners/(partners)/info/page.tsx b/app/[lng]/partners/(partners)/info/page.tsx new file mode 100644 index 00000000..8215a451 --- /dev/null +++ b/app/[lng]/partners/(partners)/info/page.tsx @@ -0,0 +1,21 @@ +import { Suspense } from "react" +import { Metadata } from "next" +import { JoinFormSkeleton } from "@/components/signup/join-form-skeleton" +import { InfoForm } from "@/components/additional-info/join-form" + +// (Optional) If Next.js attempts to statically optimize this page and you need full runtime +// behavior for query params, you may also need: +// export const dynamic = "force-dynamic" + +export const metadata: Metadata = { + title: "Partner Portal", + description: "Authentication forms built using the components.", +} + +export default function IndexPage() { + return ( + <Suspense fallback={<JoinFormSkeleton/>}> + <InfoForm /> + </Suspense> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/pq/page.tsx b/app/[lng]/partners/pq/page.tsx index 8ad23f6e..42c88b21 100644 --- a/app/[lng]/partners/pq/page.tsx +++ b/app/[lng]/partners/pq/page.tsx @@ -3,11 +3,16 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route" import * as React from "react" import { Shell } from "@/components/shell" import { Skeleton } from "@/components/ui/skeleton" -import { getPQDataByVendorId } from "@/lib/pq/service" +import { getPQDataByVendorId, getPQProjectsByVendorId } from "@/lib/pq/service" import { PQInputTabs } from "@/components/pq/pq-input-tabs" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" -export default async function PQInputPage() { +export default async function PQInputPage({ + searchParams +}: { + searchParams: { projectId?: string } +}) { // 세션 const session = await getServerSession(authOptions) // 예: 세션에서 vendorId 가져오기 @@ -15,25 +20,66 @@ export default async function PQInputPage() { const vendorId = 17 // 임시 const idAsNumber = Number(vendorId) - // 1) 서버에서 PQ 데이터 조회 (groupName별로 묶인 구조) - const pqData = await getPQDataByVendorId(idAsNumber) + const projectId = searchParams.projectId ? parseInt(searchParams.projectId, 10) : undefined + + // 벤더에게 요청된 프로젝트 PQ 목록 가져오기 (탭 표시용) + const projectPQs = await getPQProjectsByVendorId(idAsNumber) + + // PQ 데이터 조회 + const pqData = await getPQDataByVendorId(idAsNumber, projectId) + + // 현재 프로젝트 정보 (있다면) + const currentProject = projectId + ? projectPQs.find(p => p.projectId === projectId) + : null return ( <Shell className="gap-2"> + {/* 헤더 - 프로젝트 정보 포함 */} <div className="space-y-2"> <h2 className="text-2xl font-bold tracking-tight"> Pre-Qualification Check Sheet + {currentProject && ( + <span className="ml-2 text-muted-foreground"> + - {currentProject.projectCode} + </span> + )} </h2> <p className="text-muted-foreground"> - PQ에 적절한 응답을 제출하시기 바랍니다. 진행 중 문의가 있으면 담당자에게 연락바랍니다. + PQ에 적절한 응답을 제출하시기 바랍니다. </p> </div> - {/* 클라이언트 탭 UI 로드 (Suspense는 여기서는 크게 필요치 않을 수도 있음) */} + {/* 일반/프로젝트 PQ 선택 탭 */} + {projectPQs.length > 0 && ( + <div className="border-b"> + <Tabs defaultValue={projectId ? `project-${projectId}` : "general"}> + <TabsList> + <TabsTrigger value="general" asChild> + <a href="/partners/pq">일반 PQ</a> + </TabsTrigger> + + {projectPQs.map(project => ( + <TabsTrigger key={project.projectId} value={`project-${project.projectId}`} asChild> + <a href={`/partners/pq?projectId=${project.projectId}`}> + {project.projectCode} + </a> + </TabsTrigger> + ))} + </TabsList> + </Tabs> + </div> + )} + + {/* PQ 입력 탭 */} <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <PQInputTabs data={pqData} vendorId={idAsNumber} /> + <PQInputTabs + data={pqData} + vendorId={idAsNumber} + projectId={projectId} + projectData={currentProject} + /> </React.Suspense> - </Shell> ) }
\ No newline at end of file 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" diff --git a/config/candidatesColumnsConfig.ts b/config/candidatesColumnsConfig.ts new file mode 100644 index 00000000..3eeb2d2a --- /dev/null +++ b/config/candidatesColumnsConfig.ts @@ -0,0 +1,64 @@ +import { VendorCandidates } from "@/db/schema/vendors" + +export interface CandidateColumnConfig { + id: keyof VendorCandidates + label: string + group?: string + excelHeader?: string + type?: string +} + +export const candidateColumnsConfig: CandidateColumnConfig[] = [ + // Basic Info + { + id: "companyName", + label: "Company Name", + excelHeader: "Company Name", + // group: "Basic Info", + }, + { + id: "contactEmail", + label: "Contact Email", + excelHeader: "Contact Email", + // group: "Basic Info", + }, + { + id: "contactPhone", + label: "Contact Phone", + excelHeader: "Contact Phone", + // group: "Basic Info", + }, + { + id: "country", + label: "Country", + excelHeader: "Country", + // group: "Basic Info", + }, + { + id: "source", + label: "Source", + excelHeader: "Source", + // group: "Basic Info", + }, + { + id: "status", + label: "Status", + excelHeader: "Status", + // group: "Basic Info", + }, + + + + { + id: "createdAt", + label: "Created At", + excelHeader: "Created At", + // group: "Metadata", + }, + { + id: "updatedAt", + label: "Updated At", + excelHeader: "Updated At", + // group: "Metadata", + }, +]
\ No newline at end of file diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 1ab2305e..9e054433 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -55,6 +55,11 @@ export const mainNav: MenuSection[] = [ title: "벤더 관리", items: [ { + title: "벤더 초청 관리", + href: "/evcp/vendor-candidates", + description: "수집활동을 통해 발굴한 벤더를 등록하고 관리하며 초청할 수 있음", + }, + { title: "벤더 리스트", href: "/evcp/vendors", description: "업체에 대한 요약 정보를 출력", @@ -79,11 +84,11 @@ export const mainNav: MenuSection[] = [ href: "/evcp/pq", description: "벤더의 제출 PQ를 확인하고 통과 여부를 결정", }, - { - title: "벤더 계약 정보", - href: "/evcp/vendorcontract", - description: "업체별 계약 정보를 출력", - }, + // { + // title: "벤더 계약 정보", + // href: "/evcp/vendorcontract", + // description: "업체별 계약 정보를 출력", + // }, ], }, @@ -271,4 +276,8 @@ export const additionalNavVendor: MenuItem[] = [ title: "시스템 설정", href: "/partners/system", }, + { + title: "Vendor Info", + href: "/partners/info", + }, ];
\ No newline at end of file diff --git a/config/rfqsColumnsConfig.ts b/config/rfqsColumnsConfig.ts index 3b713e96..59e35146 100644 --- a/config/rfqsColumnsConfig.ts +++ b/config/rfqsColumnsConfig.ts @@ -14,19 +14,19 @@ export const rfqsColumnsConfig: RfqColumnConfig[] = [ id: "projectName", label: "Project Name", excelHeader: "Project Name", - // group: "Basic Info", + group: "Basic Info", }, { id: "rfqCode", label: "RFQ Code", excelHeader: "RFQ Code", - // group: "Basic Info", + group: "Basic Info", }, { id: "description", label: "RFQ description", excelHeader: "RFQ description", - // group: "Basic Info", + group: "Basic Info", }, // { // id: "projectCode", @@ -39,26 +39,26 @@ export const rfqsColumnsConfig: RfqColumnConfig[] = [ id: "status", label: "Status", excelHeader: "Status", - // group: "Basic Info", + group: "Basic Info", }, { id: "createdByEmail", label: "Created By", excelHeader: "Created By", - // group: "Metadata", + group: "Metadata", }, { id: "createdAt", label: "Created At", excelHeader: "Created At", - // group: "Metadata", + group: "Metadata", }, { id: "updatedAt", label: "Updated At", excelHeader: "Updated At", - // group: "Metadata", + group: "Metadata", }, ]
\ No newline at end of file diff --git a/config/vendorInvestigationsColumnsConfig.ts b/config/vendorInvestigationsColumnsConfig.ts new file mode 100644 index 00000000..3d9a9825 --- /dev/null +++ b/config/vendorInvestigationsColumnsConfig.ts @@ -0,0 +1,124 @@ +import { vendorInvestigationsView } from "@/db/schema/vendors" + +/** + * Drizzle will infer `contacts` and `possibleItems` as a JSON string or `unknown`. + * We override those with arrays that have the structure we built in the view. + */ +export type ContactItem = { + id: number + contactName: string + contactEmail: string + contactPhone: string | null + contactPosition: string | null + isPrimary: boolean + createdAt: Date + updatedAt: Date +} + +export type PossibleItem = { + id: number + vendorId: number + itemCode: string + createdAt: Date + updatedAt: Date +} + +// Drizzle-based type for the rest of the columns +type VendorInvestigationsViewRaw = typeof vendorInvestigationsView.$inferSelect + +/** + * Combine the Drizzle-inferred type with typed arrays + */ +export interface VendorInvestigationsViewWithContacts + extends Omit< + VendorInvestigationsViewRaw, + "contacts" | "possibleItems" + > { + contacts: ContactItem[] + possibleItems: PossibleItem[] +} + +export interface VendorInvestigationsColumnConfig { + id: keyof VendorInvestigationsViewWithContacts + label: string + group?: string + excelHeader?: string + type?: string + } + + // Example column config for vendorInvestigationsView + export const vendorInvestigationsColumnsConfig: VendorInvestigationsColumnConfig[] = [ + { + id: "investigationId", + label: "Investigation ID", + excelHeader: "Investigation ID", + group: "Investigation", + }, + { + id: "investigationStatus", + label: "Status", + excelHeader: "Status", + group: "Investigation", + }, + { + id: "scheduledStartAt", + label: "Scheduled Start", + excelHeader: "Scheduled Start", + group: "Investigation", + }, + { + id: "scheduledEndAt", + label: "Scheduled End", + excelHeader: "Scheduled End", + group: "Investigation", + }, + { + id: "completedAt", + label: "Completed At", + excelHeader: "Completed At", + group: "Investigation", + }, + { + id: "investigationNotes", + label: "Notes", + excelHeader: "Investigation Notes", + group: "Investigation", + }, + { + id: "vendorName", + label: "Vendor Name", + excelHeader: "Vendor Name", + group: "Vendor Info", + }, + { + id: "vendorCode", + label: "Vendor Code", + excelHeader: "Vendor Code", + group: "Vendor Info", + }, + { + id: "vendorEmail", + label: "Email", + excelHeader: "Email", + group: "Vendor Info", + }, + { + id: "vendorPhone", + label: "Phone", + excelHeader: "Phone", + group: "Vendor Info", + }, + // ... add more as needed ... + { + id: "investigationCreatedAt", + label: "Created At", + excelHeader: "Created At", + // group: "Metadata", + }, + { + id: "investigationUpdatedAt", + label: "Updated At", + excelHeader: "Updated At", + // group: "Metadata", + }, + ]
\ No newline at end of file @@ -1,12 +1,13 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; +import * as schema from './schema'; const pool = new Pool({ // connectionString: process.env.DATABASE_URL as string, connectionString: "postgresql://dts:dujinDTS2@localhost:5432/evcp" }); -const db = drizzle(pool); +const db = drizzle(pool, { schema }); export default db;
\ No newline at end of file diff --git a/db/schema/index.ts b/db/schema/index.ts new file mode 100644 index 00000000..714f058b --- /dev/null +++ b/db/schema/index.ts @@ -0,0 +1,12 @@ +export * from './companies'; +export * from './contract'; +export * from './items'; +export * from './pq'; +export * from './projects'; +export * from './rfq'; +export * from './users'; + +export * from './vendorData'; +export * from './vendorDocu'; +export * from './vendors'; +export * from './tasks'; diff --git a/db/schema/pq.ts b/db/schema/pq.ts index 59ec8f07..76d15d5d 100644 --- a/db/schema/pq.ts +++ b/db/schema/pq.ts @@ -3,6 +3,7 @@ import { timestamp, uniqueIndex } from "drizzle-orm/pg-core"; import { vendors } from "./vendors"; +import { projects } from "./projects"; export const pqCriterias = pgTable("pq_criterias", { id: serial("id").primaryKey(), @@ -17,21 +18,34 @@ export const pqCriterias = pgTable("pq_criterias", { updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const vendorPqCriteriaAnswers = pgTable("vendor_pq_criteria_answers", { +export const pqCriteriasExtension = pgTable("pq_criterias_extension", { id: serial("id").primaryKey(), - vendorId: integer("vendor_id") - .notNull() - .references(() => vendors.id, { onDelete: "cascade" }), - - criteriaId: integer("criteria_id") - .notNull() - .references(() => pqCriterias.id, { onDelete: "cascade" }), - - answer: text("answer"), - + + // pq_criterias와 연결 (1:1 or 1:N 중 필요에 맞춰 사용) + pqCriteriaId: integer("pq_criteria_id") + .notNull() + .references(() => pqCriterias.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + // projects 테이블에 대한 FK + projectId: integer("project_id") + .notNull() + .references(() => projects.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + // 프로젝트별 PQ 시 필요한 추가 정보 + contractInfo: text("contract_info"), + additionalRequirement: text("additional_requirement"), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); + }); + + export const vendorCriteriaAttachments = pgTable("vendor_criteria_attachments", { id: serial("id").primaryKey(), @@ -65,3 +79,68 @@ export const vendorPqReviewLogs = pgTable("vendor_pq_review_logs", { createdAt: timestamp("created_at").defaultNow().notNull(), }) export type PqCriterias = typeof pqCriterias.$inferSelect + + +// 벤더와 프로젝트 PQ 요청 연결 테이블 +export const vendorProjectPQs = pgTable("vendor_project_pqs", { + id: serial("id").primaryKey(), + + // vendors 테이블 FK + vendorId: integer("vendor_id") + .notNull() + .references(() => vendors.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + // projects 테이블 FK + projectId: integer("project_id") + .notNull() + .references(() => projects.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + // 상태: REQUESTED(요청됨), IN_PROGRESS(진행중), SUBMITTED(제출됨), APPROVED(승인됨), REJECTED(거부됨) + status: varchar("status", { length: 20 }).notNull().default("REQUESTED"), + + // 메타데이터 + submittedAt: timestamp("submitted_at"), + approvedAt: timestamp("approved_at"), + rejectedAt: timestamp("rejected_at"), + rejectReason: text("reject_reason"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// 기존 vendorPqCriteriaAnswers 테이블에 projectId 필드 추가 +export const vendorPqCriteriaAnswers = pgTable("vendor_pq_criteria_answers", { + id: serial("id").primaryKey(), + + vendorId: integer("vendor_id") + .notNull() + .references(() => vendors.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + criteriaId: integer("criteria_id") + .notNull() + .references(() => pqCriterias.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + // 추가: 프로젝트 ID (null은 일반 PQ를 의미) + projectId: integer("project_id") + .references(() => projects.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + + answer: text("answer"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts index 92a92c8e..03248fb8 100644 --- a/db/schema/vendorData.ts +++ b/db/schema/vendorData.ts @@ -28,7 +28,7 @@ export const forms = pgTable("forms", { } }) -export const rfqAttachments = pgTable("form_templates", { +export const formTemplates = pgTable("form_templates", { id: serial("id").primaryKey(), formId: integer("form_id").references(() => forms.id), fileName: varchar("file_name", { length: 255 }).notNull(), diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts index 2e6ba5a1..ce498b94 100644 --- a/db/schema/vendorDocu.ts +++ b/db/schema/vendorDocu.ts @@ -1,5 +1,5 @@ -import { pgTable, integer, varchar, timestamp, date ,pgView,uniqueIndex ,jsonb} from "drizzle-orm/pg-core" -import { eq , sql} from "drizzle-orm"; +import { pgTable, integer, varchar, timestamp, date, pgView, uniqueIndex, jsonb } from "drizzle-orm/pg-core" +import { eq, sql } from "drizzle-orm"; import { projects } from "./projects"; import { vendors } from "./vendors"; import { contracts } from "./contract"; @@ -76,94 +76,75 @@ export const issueStages = pgTable( } ); - export const revisions = pgTable( - "revisions", - { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - issueStageId: integer("issue_stage_id").notNull(), - revision: varchar("revision", { length: 50 }).notNull(), - // 새로운 필드: 업로더 타입 (업체 또는 고객사) - uploaderType: varchar("uploader_type", { length: 20 }).notNull().default("vendor"), - // 선택적: 업로더 ID 또는 이름 - uploaderId: integer("uploader_id"), - uploaderName: varchar("uploader_name", { length: 100 }), - // 선택적: 추가 메타데이터 - comment: varchar("comment", { length: 500 }), - status: varchar("status", { length: 50 }), - approvedDate: date("approved_date"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - }, - (table) => { - return { - // "issue_stage_id + revision" 조합을 유니크로 묶음 (유지) - uniqueStageRev: uniqueIndex("unique_stage_rev").on( - table.issueStageId, - table.revision - ), - } - } - ) - export const documentAttachments = pgTable( - "document_attachments", - { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - revisionId: integer("revision_id") - .notNull() - .references(() => revisions.id, { onDelete: "cascade" }), - fileName: varchar("file_name", { length: 255 }).notNull(), - filePath: varchar("file_path", { length: 1024 }).notNull(), - fileType: varchar("file_type", { length: 50 }), - fileSize: integer("file_size"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), +export const revisions = pgTable( + "revisions", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + issueStageId: integer("issue_stage_id").notNull(), + revision: varchar("revision", { length: 50 }).notNull(), + // 새로운 필드: 업로더 타입 (업체 또는 고객사) + uploaderType: varchar("uploader_type", { length: 20 }).notNull().default("vendor"), + // 선택적: 업로더 ID 또는 이름 + uploaderId: integer("uploader_id"), + uploaderName: varchar("uploader_name", { length: 100 }), + // 선택적: 추가 메타데이터 + comment: varchar("comment", { length: 500 }), + status: varchar("status", { length: 50 }), + approvedDate: date("approved_date"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => { + return { + // "issue_stage_id + revision" 조합을 유니크로 묶음 (유지) + uniqueStageRev: uniqueIndex("unique_stage_rev").on( + table.issueStageId, + table.revision + ), } - ) - + } +) +export const documentAttachments = pgTable( + "document_attachments", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + revisionId: integer("revision_id") + .notNull() + .references(() => revisions.id, { onDelete: "cascade" }), + fileName: varchar("file_name", { length: 255 }).notNull(), + filePath: varchar("file_path", { length: 1024 }).notNull(), + fileType: varchar("file_type", { length: 50 }), + fileSize: integer("file_size"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + } +) - // export const vendorDocumentsView = pgTable("vendor_documents", { - // vendorId: integer("vendor_id").notNull(), - // id: integer("id").notNull(), - // docNumber: varchar("doc_number", { length: 100 }).notNull(), - // title: varchar("title", { length: 255 }).notNull(), - - // // 새로 추가된 컬럼들 (contractId, contractNo, contractName, status) - // contractId: integer("contract_id").notNull(), - // contractNo: varchar("contract_no", { length: 100 }).notNull(), - // contractName: varchar("contract_name", { length: 255 }).notNull(), - // status: varchar("status", { length: 50 }).notNull(), - - // createdAt: timestamp("created_at", { withTimezone: true }), - // updatedAt: timestamp("updated_at", { withTimezone: true }), - - // }) +export const vendorDocumentsView = pgView("vendor_documents_view", { + // Match the columns in your SELECT statement + id: integer("id").notNull(), + docNumber: varchar("doc_number", { length: 100 }).notNull(), + title: varchar("title", { length: 255 }).notNull(), + status: varchar("status", { length: 50 }).notNull(), + issuedDate: date("issued_date"), + contractId: integer("contract_id").notNull(), - export const vendorDocumentsView = pgView("vendor_documents_view", { - // Match the columns in your SELECT statement - id: integer("id").notNull(), - docNumber: varchar("doc_number", { length: 100 }).notNull(), - title: varchar("title", { length: 255 }).notNull(), - status: varchar("status", { length: 50 }).notNull(), - issuedDate: date("issued_date"), - - contractId: integer("contract_id").notNull(), - - latestStageId: integer("latest_stage_id"), // possibly can be null - latestStageName: varchar("latest_stage_name", { length: 100 }), - latestStagePlanDate: date("latest_stage_plan_date"), - latestStageActualDate: date("latest_stage_actual_date"), - - latestRevisionId: integer("latest_revision_id"), - latestRevision: varchar("latest_revision", { length: 50 }), - latestRevisionUploaderType: varchar("latest_revision_uploader_type", { length: 20 }), - latestRevisionUploaderName: varchar("latest_revision_uploader_name", { length: 100 }), - - attachmentCount: integer("attachment_count"), - - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - }).as(sql` + latestStageId: integer("latest_stage_id"), // possibly can be null + latestStageName: varchar("latest_stage_name", { length: 100 }), + latestStagePlanDate: date("latest_stage_plan_date"), + latestStageActualDate: date("latest_stage_actual_date"), + + latestRevisionId: integer("latest_revision_id"), + latestRevision: varchar("latest_revision", { length: 50 }), + latestRevisionUploaderType: varchar("latest_revision_uploader_type", { length: 20 }), + latestRevisionUploaderName: varchar("latest_revision_uploader_name", { length: 100 }), + + attachmentCount: integer("attachment_count"), + + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), +}).as(sql` SELECT d.id, d.doc_number, @@ -232,8 +213,6 @@ export const issueStages = pgTable( JOIN contracts c ON d.contract_id = c.id `); - - // 문서 + 스테이지 리스트 뷰 export const documentStagesView = pgView("document_stages_view", { documentId: integer("document_id").notNull(), @@ -279,7 +258,5 @@ export const documentStagesView = pgView("document_stages_view", { FROM documents d `); - - export type VendorDocumentsView = typeof vendorDocumentsView.$inferSelect - export type DocumentStagesView = typeof documentStagesView.$inferSelect -
\ No newline at end of file +export type VendorDocumentsView = typeof vendorDocumentsView.$inferSelect +export type DocumentStagesView = typeof documentStagesView.$inferSelect diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts index b2005537..acdec3d2 100644 --- a/db/schema/vendors.ts +++ b/db/schema/vendors.ts @@ -1,7 +1,7 @@ // db/schema/vendors.ts import { pgTable, serial, varchar, text, timestamp, boolean, integer ,pgView} from "drizzle-orm/pg-core"; import { items } from "./items"; -import { eq} from "drizzle-orm"; +import { sql, eq} from "drizzle-orm"; export const vendors = pgTable("vendors", { id: serial("id").primaryKey(), @@ -20,9 +20,11 @@ export const vendors = pgTable("vendors", { "IN_REVIEW", // 심사 중 "REJECTED", // 심사 거부됨 "IN_PQ", // PQ 진행 중 - "PQ_SUBMITTED", // PQ 제출 + "PQ_SUBMITTED", // PQ 제출 "PQ_FAILED", // PQ 실패 + "PQ_APPROVED", // PQ 통과, 승인됨 "APPROVED", // PQ 통과, 승인됨 + "READY_TO_SEND", // PQ 통과, 승인됨 "ACTIVE", // 활성 상태 (실제 거래 중) "INACTIVE", // 비활성 상태 (일시적) "BLACKLISTED", // 거래 금지 상태 @@ -115,4 +117,246 @@ export type VendorWithAttachments = Vendor & { attachmentsList?: VendorAttach[]; } -export type VendorItemsView = typeof vendorItemsView.$inferSelect
\ No newline at end of file +export type VendorItemsView = typeof vendorItemsView.$inferSelect + +export const vendorInvestigations = pgTable("vendor_investigations", { + id: serial("id").primaryKey(), + + // 어떤 벤더에 대한 실사인지 참조 + vendorId: integer("vendor_id").notNull().references(() => vendors.id), + + // 실사 상태 + // 예: "PLANNED" | "IN_PROGRESS" | "COMPLETED" | "CANCELED" + investigationStatus: varchar("investigation_status", { + length: 50, + enum: [ + "PLANNED", + "IN_PROGRESS", + "COMPLETED", + "CANCELED", + ], + }) + .notNull() + .default("PLANNED"), + + // 실사 일정 시작일 / 종료일 + scheduledStartAt: timestamp("scheduled_start_at"), + scheduledEndAt: timestamp("scheduled_end_at"), + + // 실제 실사 완료일 + completedAt: timestamp("completed_at"), + + // 실사 내용이나 특이사항 + investigationNotes: text("investigation_notes"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// 타입 정의 +export type VendorInvestigation = typeof vendorInvestigations.$inferSelect; + +export const vendorInvestigationAttachments = pgTable( + "vendor_investigation_attachments", + { + id: serial("id").primaryKey(), + // 어떤 실사 (investigation)에 대한 첨부파일인지 + investigationId: integer("investigation_id") + .notNull() + .references(() => vendorInvestigations.id, { onDelete: "cascade" }), + + fileName: varchar("file_name", { length: 255 }).notNull(), + filePath: varchar("file_path", { length: 1024 }).notNull(), + + // 첨부파일 종류 (예: 보고서, 사진, 기타 등 구분) + attachmentType: varchar("attachment_type", { length: 50 }).default("REPORT"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + } +); + +// 첨부파일 테이블 타입 +export type VendorInvestigationAttachment = + typeof vendorInvestigationAttachments.$inferSelect; + +/** + * A view that joins vendor_investigations + vendors, + * and also embeds contacts & possibleItems as JSON arrays. + */ +export const vendorInvestigationsView = pgView( + "vendor_investigations_view" +).as((qb) => { + return qb + .select({ + // Investigation fields + investigationId: vendorInvestigations.id, + investigationStatus: vendorInvestigations.investigationStatus, + scheduledStartAt: vendorInvestigations.scheduledStartAt, + scheduledEndAt: vendorInvestigations.scheduledEndAt, + completedAt: vendorInvestigations.completedAt, + investigationNotes: vendorInvestigations.investigationNotes, + investigationCreatedAt: vendorInvestigations.createdAt, + investigationUpdatedAt: vendorInvestigations.updatedAt, + + // Vendor fields + vendorId: vendorInvestigations.vendorId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + vendorTaxId: vendors.taxId, + vendorStatus: vendors.status, + vendorCountry: vendors.country, + vendorEmail: vendors.email, + vendorPhone: vendors.phone, + vendorWebsite: vendors.website, + + // JSON-aggregated contacts + contacts: sql<string>`( + SELECT COALESCE( + json_agg(json_build_object( + 'contactName', c.contact_name, + 'contactEmail', c.contact_email, + 'contactPhone', c.contact_phone, + 'contactPosition', c.contact_position, + 'isPrimary', c.is_primary, + 'contactCreatedAt', c.created_at, + 'contactUpdatedAt', c.updated_at + )), + '[]'::json + ) + FROM vendor_contacts c + WHERE c.vendor_id = ${vendors.id} + )`.as("contacts"), + + // JSON-aggregated possible items with itemName from items table + possibleItems: sql<string>`( + SELECT COALESCE( + json_agg(json_build_object( + 'itemCode', pi.item_code, + 'itemName', i.item_name, + 'itemCreatedAt', pi.created_at, + 'itemUpdatedAt', pi.updated_at + )), + '[]'::json + ) + FROM vendor_possible_items pi + LEFT JOIN items i ON pi.item_code = i.item_code + WHERE pi.vendor_id = ${vendors.id} + )`.as("possibleItems"), + }) + .from(vendorInvestigations) + .leftJoin( + vendors, + eq(vendorInvestigations.vendorId, vendors.id) + ) +}) + + +export const vendorCandidates = pgTable("vendor_candidates", { + id: serial("id").primaryKey(), + companyName: varchar("company_name", { length: 255 }).notNull(), + contactEmail: varchar("contact_email", { length: 255 }).notNull(), + contactPhone: varchar("contact_phone", { length: 50 }), + country: varchar("country", { length: 100 }), + // 웹 크롤링 등으로 얻은 상태나 분류 + source: varchar("source", { length: 100 }), // 수집 출처 + status: varchar("status", { + length: 30, + enum: [ + "COLLECTED", // 단순 데이터 수집 완료 + "INVITED", // 초청 메일 발송 + "DISCARDED", // 불필요, 검토 후 배제됨 + ], + }) + .notNull() + .default("COLLECTED"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +export type VendorCandidates = typeof vendorCandidates.$inferSelect; + + +export const vendorDetailView = pgView("vendor_detail_view").as((qb) => { + return qb + .select({ + // 벤더 기본 정보 + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, + address: vendors.address, + country: vendors.country, + phone: vendors.phone, + email: vendors.email, + website: vendors.website, + status: vendors.status, + representativeName: vendors.representativeName, + representativeBirth: vendors.representativeBirth, + representativeEmail: vendors.representativeEmail, + representativePhone: vendors.representativePhone, + corporateRegistrationNumber: vendors.corporateRegistrationNumber, + creditAgency: vendors.creditAgency, + creditRating: vendors.creditRating, + cashFlowRating: vendors.cashFlowRating, + createdAt: vendors.createdAt, + updatedAt: vendors.updatedAt, + + // 연락처 정보 (수정된 버전) + contacts: sql<string>` + (SELECT COALESCE( + json_agg( + json_build_object( + 'id', c.id, + 'contactName', c.contact_name, + 'contactPosition', c.contact_position, + 'contactEmail', c.contact_email, + 'contactPhone', c.contact_phone, + 'isPrimary', c.is_primary + ) + ), + '[]'::json + ) + FROM vendor_contacts c + WHERE c.vendor_id = vendors.id) + `.as("contacts"), + + // 첨부파일 정보 (수정된 버전) + attachments: sql<string>` + (SELECT COALESCE( + json_agg( + json_build_object( + 'id', a.id, + 'fileName', a.file_name, + 'filePath', a.file_path, + 'attachmentType', a.attachment_type, + 'createdAt', a.created_at + ) + ORDER BY a.attachment_type, a.created_at DESC + ), + '[]'::json + ) + FROM vendor_attachments a + WHERE a.vendor_id = vendors.id) + `.as("attachments"), + + // 첨부파일 수 (수정된 버전) + attachmentCount: sql<number>` + (SELECT COUNT(*) + FROM vendor_attachments a + WHERE a.vendor_id = vendors.id) + `.as("attachment_count"), + + // 연락처 수 (수정된 버전) + contactCount: sql<number>` + (SELECT COUNT(*) + FROM vendor_contacts c + WHERE c.vendor_id = vendors.id) + `.as("contact_count") + }) + .from(vendors) +}); + +// 타입 정의 +export type VendorDetailView = typeof vendorDetailView.$inferSelect;
\ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index 98804a49..93b6e10d 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ out: "./db/migrations", // 마이그레이션 파일 생성 경로 - schema: "./db/schema/**/*.ts", // 스키마 파일 위치 + schema: "./db/schema/index.ts", dialect: 'postgresql', dbCredentials: { // url: process.env.DATABASE_URL!, diff --git a/i18n/locales/en/translation.json b/i18n/locales/en/translation.json index 99ac1acc..37cb2aa8 100644 --- a/i18n/locales/en/translation.json +++ b/i18n/locales/en/translation.json @@ -70,6 +70,36 @@ "viewButton": "View RFQ", "supportMsg": "If you have any questions, please contact us at your earliest convenience.", "footerDisclaimer": "This email was sent automatically. Please do not reply." + }, + + "email": { + "vendor": { + "invitation": { + "title": "Vendor Registration Invitation", + "greeting": "Dear", + "message": "We are pleased to invite your company to register as a vendor in our EVCP (Electric Vehicle Charging Platform) system. As a registered vendor, you will have access to our platform where you can manage your products, receive inquiries, and participate in our procurement processes.", + "details": "To complete your registration, please click the button below. This will take you to our secure registration portal where you can set up your account and provide the necessary information.", + "register_now": "Register Now", + "expire_notice": "This invitation link will expire in 14 days. If you encounter any issues during the registration process, please contact our support team at support@evcp.com.", + "footer": "We look forward to working with you and having your company as part of our vendor network.", + "signature": "Best regards,", + "team": "Team", + "copyright": "All rights reserved.", + "no_reply": "This is an automated email. Please do not reply to this message." + }, + "additionalInfo": { + "title": "EVCP - Additional Information Request", + "header": "Additional Information Request", + "greeting": "Hello, {{vendorName}},", + "messageP1": "We are pleased to inform you that your registration with the EVCP system has been approved.", + "messageP2": "To proceed to the next step, we need you to provide additional information such as credit ratings and cash flow information. This information will be used as important data for future business relationships.", + "messageP3": "Please click the button below to navigate to the additional information input page.", + "buttonText": "Enter Additional Information", + "messageP4": "If you have any questions about this request, please contact our support team.", + "closing": "Thank you,", + "footerText": "All rights reserved." + } + } } }
\ No newline at end of file diff --git a/i18n/locales/ko/translation.json b/i18n/locales/ko/translation.json index 1603d538..3842519d 100644 --- a/i18n/locales/ko/translation.json +++ b/i18n/locales/ko/translation.json @@ -68,7 +68,40 @@ "viewButton": "RFQ 보기", "supportMsg": "궁금하신 점이 있으면 언제든지 문의해 주세요.", "footerDisclaimer": "이 이메일은 자동 발송되었습니다. 회신하지 말아 주세요." + }, + + + "email": { + "vendor": { + "invitation": { + "title": "Vendor Registration Invitation", + "greeting": "Dear", + "message": "We are pleased to invite your company to register as a vendor in our EVCP (Electric Vehicle Charging Platform) system. As a registered vendor, you will have access to our platform where you can manage your products, receive inquiries, and participate in our procurement processes.", + "details": "To complete your registration, please click the button below. This will take you to our secure registration portal where you can set up your account and provide the necessary information.", + "register_now": "Register Now", + "expire_notice": "This invitation link will expire in 14 days. If you encounter any issues during the registration process, please contact our support team at support@evcp.com.", + "footer": "We look forward to working with you and having your company as part of our vendor network.", + "signature": "Best regards,", + "team": "Team", + "copyright": "All rights reserved.", + "no_reply": "This is an automated email. Please do not reply to this message." + } + }, + + "additionalInfo": { + "title": "EVCP - 추가 정보 요청", + "header": "추가 정보 요청", + "greeting": "안녕하세요, {{vendorName}} 님,", + "messageP1": "귀사의 EVCP 시스템 등록이 승인되었음을 알려드립니다.", + "messageP2": "다음 단계로 진행하기 위해, 귀사의 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력해 주시기 바랍니다. 이 정보는 향후 거래 관계를 위한 중요한 자료로 활용됩니다.", + "messageP3": "아래 버튼을 클릭하여 추가 정보 입력 페이지로 이동해 주세요.", + "buttonText": "추가 정보 입력하기", + "messageP4": "이 요청에 관해 질문이 있으시면 저희 지원팀에 문의해 주시기 바랍니다.", + "closing": "감사합니다.", + "footerText": "모든 권리 보유." + } + } }
\ No newline at end of file diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 22f10466..e3a8b2b2 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -29,6 +29,7 @@ export interface FormInfo { } export async function getFormsByContractItemId(contractItemId: number | null) { + // 유효성 검사 if (!contractItemId || contractItemId <= 0) { console.warn(`Invalid contractItemId: ${contractItemId}`); @@ -40,7 +41,10 @@ export async function getFormsByContractItemId(contractItemId: number | null) { try { return unstable_cache( + async () => { + console.log(contractItemId,"contractItemId") + console.log( `[Forms Service] Fetching forms for contractItemId: ${contractItemId}` ); diff --git a/lib/mail/templates/vendor-additional-info.hbs b/lib/mail/templates/vendor-additional-info.hbs new file mode 100644 index 00000000..9d93bb7b --- /dev/null +++ b/lib/mail/templates/vendor-additional-info.hbs @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{t "email.additionalInfo.title"}}</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + .header { + background-color: #0056b3; + color: white; + padding: 20px; + text-align: center; + border-radius: 5px 5px 0 0; + } + .content { + padding: 20px; + border: 1px solid #ddd; + border-top: none; + border-radius: 0 0 5px 5px; + } + .button { + background-color: #0056b3; + color: white; + padding: 12px 20px; + text-decoration: none; + border-radius: 5px; + display: inline-block; + margin-top: 15px; + font-weight: bold; + } + .footer { + margin-top: 30px; + text-align: center; + font-size: 0.8em; + color: #777; + } + </style> +</head> +<body> + <div class="header"> + <h1>{{t "email.additionalInfo.header"}}</h1> + </div> + + <div class="content"> + <p>{{t "email.additionalInfo.greeting" vendorName=vendorName}}</p> + + <p>{{t "email.additionalInfo.messageP1"}}</p> + + <p>{{t "email.additionalInfo.messageP2"}}</p> + + <p>{{t "email.additionalInfo.messageP3"}}</p> + + <div style="text-align: center;"> + <a href="{{vendorInfoUrl}}" class="button">{{t "email.additionalInfo.buttonText"}}</a> + </div> + + <p>{{t "email.additionalInfo.messageP4"}}</p> + + <p>{{t "email.additionalInfo.closing"}}</p> + + <p>EVCP Team</p> + </div> + + <div class="footer"> + <p>© {{currentYear}} EVCP. {{t "email.additionalInfo.footerText"}}</p> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/vendor-invitation.hbs b/lib/mail/templates/vendor-invitation.hbs new file mode 100644 index 00000000..d85067f4 --- /dev/null +++ b/lib/mail/templates/vendor-invitation.hbs @@ -0,0 +1,86 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Vendor Registration Invitation</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333333; + margin: 0; + padding: 0; + } + .container { + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + .header { + background-color: #2563EB; + padding: 20px; + text-align: center; + color: white; + } + .content { + padding: 20px; + background-color: #ffffff; + } + .footer { + padding: 20px; + text-align: center; + font-size: 12px; + color: #666666; + background-color: #f5f5f5; + } + .button { + display: inline-block; + background-color: #2563EB; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 4px; + margin: 20px 0; + font-weight: bold; + } + .highlight { + background-color: #f8f9fa; + padding: 15px; + border-left: 4px solid #2563EB; + margin: 20px 0; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>{{t "email.vendor.invitation.title"}}</h1> + </div> + <div class="content"> + <p>{{t "email.vendor.invitation.greeting"}} {{companyName}},</p> + + <p>{{t "email.vendor.invitation.message"}}</p> + + <div class="highlight"> + <p>{{t "email.vendor.invitation.details"}}</p> + </div> + + <div style="text-align: center;"> + <a href="{{registrationLink}}" class="button">{{t "email.vendor.invitation.register_now"}}</a> + </div> + + <p>{{t "email.vendor.invitation.expire_notice"}}</p> + + <p>{{t "email.vendor.invitation.footer"}}</p> + + <p>{{t "email.vendor.invitation.signature"}}<br> + EVCP {{t "email.vendor.invitation.team"}}</p> + </div> + <div class="footer"> + <p>© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p> + <p>{{t "email.vendor.invitation.no_reply"}}</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/pq/service.ts b/lib/pq/service.ts index a1373dae..6906ff52 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -5,10 +5,10 @@ import { GetPQSchema } from "./validations" import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL} from "drizzle-orm"; import { z } from "zod" import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache"; -import { pqCriterias, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs } from "@/db/schema/pq" +import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq" import { countPqs, selectPqs } from "./repository"; import { sendEmail } from "../mail/sendEmail"; import { vendorAttachments, vendors } from "@/db/schema/vendors"; @@ -18,63 +18,126 @@ import { randomUUID } from 'crypto'; import { writeFile, mkdir } from 'fs/promises'; import { GetVendorsSchema } from "../vendors/validations"; import { countVendors, selectVendors } from "../vendors/repository"; +import { projects } from "@/db/schema"; /** * PQ 목록 조회 */ -export async function getPQs(input: GetPQSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // advancedTable 모드면 filterColumns()로 where 절 구성 - const advancedWhere = filterColumns({ - table: pqCriterias, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or(ilike(pqCriterias.code, s), ilike(pqCriterias.groupName, s), ilike(pqCriterias.remarks, s), ilike(pqCriterias.checkPoint, s), ilike(pqCriterias.description, s) - ) - } - - const finalWhere = and(advancedWhere, globalWhere); - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(pqCriterias[item.id]) : asc(pqCriterias[item.id]) - ) - : [asc(pqCriterias.createdAt)]; - - const { data, total } = await db.transaction(async (tx) => { - const data = await selectPqs(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - const total = await countPqs(tx, finalWhere); - return { data, total }; - }); - - const pageCount = Math.ceil(total / input.perPage); - - - return { data, pageCount }; - } catch (err) { - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: [`pq`], - } - )(); +export async function getPQs( + input: GetPQSchema, + projectId?: number | null, + onlyGeneral?: boolean +) { + return unstable_cache( + async () => { + try { + // Common query building logic extracted to a helper function + const buildBaseQuery = (queryBuilder: any) => { + let query = queryBuilder.from(pqCriterias); + + // Handle join conditions based on parameters + if (projectId) { + query = query + .innerJoin( + pqCriteriasExtension, + eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId) + ) + .where(eq(pqCriteriasExtension.projectId, projectId)); + } else if (onlyGeneral) { + query = query + .leftJoin( + pqCriteriasExtension, + eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId) + ) + .where(isNull(pqCriteriasExtension.id)); + } + + // Apply filters + const advancedWhere = filterColumns({ + table: pqCriterias, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // Handle search + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(pqCriterias.code, s), + ilike(pqCriterias.groupName, s), + ilike(pqCriterias.remarks, s), + ilike(pqCriterias.checkPoint, s), + ilike(pqCriterias.description, s) + ); + } + + // Combine where clauses + const finalWhere = and(advancedWhere, globalWhere); + if (finalWhere) { + query = query.where(finalWhere); + } + + return { query, finalWhere }; + }; + + const offset = (input.page - 1) * input.perPage; + + // Build sort order configuration + const orderBy = input.sort?.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(pqCriterias[item.id]) + : asc(pqCriterias[item.id]) + ) + : [asc(pqCriterias.createdAt)]; + + // Execute in a transaction + const { data, total } = await db.transaction(async (tx) => { + // 변경: 쿼리 결과 형태를 변경하여 데이터가 평탄화되도록 수정 + // Data query + const { query: baseQuery } = buildBaseQuery(tx.select({ + id: pqCriterias.id, + code: pqCriterias.code, + checkPoint: pqCriterias.checkPoint, + description: pqCriterias.description, + remarks: pqCriterias.remarks, + groupName: pqCriterias.groupName, + createdAt: pqCriterias.createdAt, + updatedAt: pqCriterias.updatedAt, + // 필요한 경우 pqCriteriasExtension의 필드도 여기에 추가 + })); + + const data = await baseQuery + .orderBy(...orderBy) + .offset(offset) + .limit(input.perPage); + + // Count query - reusing the same base query logic + const { query: countQuery } = buildBaseQuery(tx.select({ count: count() })); + const countRes = await countQuery; + const total = countRes[0]?.count ?? 0; + + return { data, total }; + }); + + // Calculate page count + const pageCount = Math.ceil(total / input.perPage); + + // 이미 평탄화된 객체 배열 형태로 반환됨 + return { data, pageCount }; + } catch (err) { + console.log('Error in getPQs:', err); + console.error('Error in getPQs:', err); + throw new Error('Failed to fetch PQ criteria'); + } + }, + [JSON.stringify(input), projectId?.toString() ?? 'undefined', onlyGeneral?.toString() ?? 'undefined'], + { + revalidate: 3600, + tags: ["pq"], + } + )(); } // PQ 생성을 위한 입력 스키마 정의 @@ -86,19 +149,26 @@ const createPqSchema = z.object({ groupName: z.string().optional() }); -export type CreatePqInputType = z.infer<typeof createPqSchema>; +export interface CreatePqInputType extends z.infer<typeof createPqSchema> { + projectId?: number | null; + contractInfo?: string | null; + additionalRequirement?: string | null; +} /** * PQ 기준 생성 */ export async function createPq(input: CreatePqInputType) { try { - // 입력 유효성 검증 + // 기본 데이터 유효성 검증 const validatedData = createPqSchema.parse(input); - + + // 프로젝트 정보 및 확장 필드 확인 + const isProjectSpecific = !!input.projectId; + // 트랜잭션 사용하여 PQ 기준 생성 return await db.transaction(async (tx) => { - // PQ 기준 생성 + // 1. 기본 PQ 기준 생성 const [newPqCriteria] = await tx .insert(pqCriterias) .values({ @@ -109,12 +179,27 @@ export async function createPq(input: CreatePqInputType) { groupName: validatedData.groupName || null, }) .returning({ id: pqCriterias.id }); - + + // 2. 프로젝트별 PQ인 경우 확장 테이블에도 데이터 추가 + if (isProjectSpecific && input.projectId) { + await tx + .insert(pqCriteriasExtension) + .values({ + pqCriteriaId: newPqCriteria.id, + projectId: input.projectId, + contractInfo: input.contractInfo || null, + additionalRequirement: input.additionalRequirement || null, + }); + } + // 성공 결과 반환 - return { - success: true, + return { + success: true, pqId: newPqCriteria.id, - message: "PQ criteria created successfully" + isProjectSpecific, + message: isProjectSpecific + ? "Project-specific PQ criteria created successfully" + : "General PQ criteria created successfully" }; }); } catch (error) { @@ -122,21 +207,20 @@ export async function createPq(input: CreatePqInputType) { // Zod 유효성 검사 에러 처리 if (error instanceof z.ZodError) { - return { - success: false, - message: "Validation failed", - errors: error.errors + return { + success: false, + message: "Validation failed", + errors: error.errors }; } // 기타 에러 처리 - return { - success: false, - message: "Failed to create PQ criteria" + return { + success: false, + message: "Failed to create PQ criteria" }; } } - // PQ 캐시 무효화 함수 export async function invalidatePqCache() { revalidatePath(`/evcp/pq-criteria`); @@ -259,12 +343,16 @@ export interface PQAttachment { } export interface PQItem { - answerId: number | null; // null도 허용하도록 변경 + answerId: number | null criteriaId: number code: string checkPoint: string description: string | null - answer: string // or null + remarks?: string | null + // 프로젝트 PQ 전용 필드 + contractInfo?: string | null + additionalRequirement?: string | null + answer: string attachments: PQAttachment[] } @@ -273,89 +361,176 @@ export interface PQGroupData { items: PQItem[] } - -export async function getPQDataByVendorId(vendorId: number): Promise<PQGroupData[]> { - // 1) Query: pqCriterias - // LEFT JOIN vendorPqCriteriaAnswers (to get `answer`) - // LEFT JOIN vendorCriteriaAttachments (to get each attachment row) - const rows = await db +export interface ProjectPQ { + id: number; + projectId: number; + status: string; + submittedAt: Date | null; + projectCode: string; + projectName: string; +} + +export async function getPQProjectsByVendorId(vendorId: number): Promise<ProjectPQ[]> { + const result = await db .select({ + id: vendorProjectPQs.id, + projectId: vendorProjectPQs.projectId, + status: vendorProjectPQs.status, + submittedAt: vendorProjectPQs.submittedAt, + projectCode: projects.code, + projectName: projects.name, + }) + .from(vendorProjectPQs) + .innerJoin( + projects, + eq(vendorProjectPQs.projectId, projects.id) + ) + .where(eq(vendorProjectPQs.vendorId, vendorId)) + .orderBy(projects.code); + + return result; +} + +export async function getPQDataByVendorId( + vendorId: number, + projectId?: number +): Promise<PQGroupData[]> { + try { + // 기본 쿼리 구성 + const selectObj = { criteriaId: pqCriterias.id, groupName: pqCriterias.groupName, code: pqCriterias.code, checkPoint: pqCriterias.checkPoint, description: pqCriterias.description, - - // From vendorPqCriteriaAnswers - answer: vendorPqCriteriaAnswers.answer, // can be null if no row exists - answerId: vendorPqCriteriaAnswers.id, // internal PK if needed - - // From vendorCriteriaAttachments + remarks: pqCriterias.remarks, + + // 프로젝트 PQ 추가 필드 + contractInfo: pqCriteriasExtension.contractInfo, + additionalRequirement: pqCriteriasExtension.additionalRequirement, + + // 벤더 응답 필드 + answer: vendorPqCriteriaAnswers.answer, + answerId: vendorPqCriteriaAnswers.id, + + // 첨부 파일 필드 attachId: vendorCriteriaAttachments.id, fileName: vendorCriteriaAttachments.fileName, filePath: vendorCriteriaAttachments.filePath, fileSize: vendorCriteriaAttachments.fileSize, - }) - .from(pqCriterias) - .leftJoin( - vendorPqCriteriaAnswers, - and( - eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), - eq(vendorPqCriteriaAnswers.vendorId, vendorId) - ) - ) - .leftJoin( - vendorCriteriaAttachments, - eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) - ) - .orderBy(pqCriterias.groupName, pqCriterias.code) - - // 2) Group by groupName => each group has a map of criteriaId => PQItem - // so we can gather attachments properly. - const groupMap = new Map<string, Record<number, PQItem>>() - - for (const row of rows) { - const g = row.groupName || "Others" - - // Ensure we have an object for this group - if (!groupMap.has(g)) { - groupMap.set(g, {}) - } - - const groupItems = groupMap.get(g)! - // If we haven't seen this criteriaId yet, create a PQItem - if (!groupItems[row.criteriaId]) { - groupItems[row.criteriaId] = { - answerId: row.answerId, - criteriaId: row.criteriaId, - code: row.code, - checkPoint: row.checkPoint, - description: row.description, - answer: row.answer || "", // if row.answer is null, just empty string - attachments: [], - } - } + }; - // If there's an attachment row (attachId not null), push it onto `attachments` - if (row.attachId) { - groupItems[row.criteriaId].attachments.push({ - attachId: row.attachId, - fileName: row.fileName || "", - filePath: row.filePath || "", - fileSize: row.fileSize || undefined, - }) + // Create separate queries for each case instead of modifying the same query variable + if (projectId) { + // 프로젝트별 PQ 쿼리 + const rows = await db + .select(selectObj) + .from(pqCriterias) + .innerJoin( + pqCriteriasExtension, + and( + eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId), + eq(pqCriteriasExtension.projectId, projectId) + ) + ) + .leftJoin( + vendorPqCriteriaAnswers, + and( + eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + eq(vendorPqCriteriaAnswers.projectId, projectId) + ) + ) + .leftJoin( + vendorCriteriaAttachments, + eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) + ) + .orderBy(pqCriterias.groupName, pqCriterias.code); + + return processQueryResults(rows); + } else { + // 일반 PQ 쿼리 + const rows = await db + .select(selectObj) + .from(pqCriterias) + .leftJoin( + pqCriteriasExtension, + eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId) + ) + .where(isNull(pqCriteriasExtension.id)) + .leftJoin( + vendorPqCriteriaAnswers, + and( + eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + isNull(vendorPqCriteriaAnswers.projectId) + ) + ) + .leftJoin( + vendorCriteriaAttachments, + eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) + ) + .orderBy(pqCriterias.groupName, pqCriterias.code); + + return processQueryResults(rows); } + } catch (error) { + console.error("Error fetching PQ data:", error); + return []; } - // 3) Convert groupMap into an array of { groupName, items[] } - const data: PQGroupData[] = [] - for (const [groupName, itemsMap] of groupMap.entries()) { - // Convert the itemsMap (key=criteriaId => PQItem) into an array - const items = Object.values(itemsMap) - data.push({ groupName, items }) + // Helper function to process query results + function processQueryResults(rows: any[]) { + // 그룹별로 데이터 구성 + const groupMap = new Map<string, Record<number, PQItem>>(); + + for (const row of rows) { + const g = row.groupName || "Others"; + + // 그룹 확인 + if (!groupMap.has(g)) { + groupMap.set(g, {}); + } + + const groupItems = groupMap.get(g)!; + + // 아직 이 기준을 처리하지 않았으면 PQItem 생성 + if (!groupItems[row.criteriaId]) { + groupItems[row.criteriaId] = { + answerId: row.answerId, + criteriaId: row.criteriaId, + code: row.code, + checkPoint: row.checkPoint, + description: row.description, + remarks: row.remarks, + // 프로젝트 PQ 전용 필드 + contractInfo: row.contractInfo, + additionalRequirement: row.additionalRequirement, + answer: row.answer || "", + attachments: [], + }; + } + + // 첨부 파일이 있으면 추가 + if (row.attachId) { + groupItems[row.criteriaId].attachments.push({ + attachId: row.attachId, + fileName: row.fileName || "", + filePath: row.filePath || "", + fileSize: row.fileSize || undefined, + }); + } + } + + // 최종 데이터 구성 + const data: PQGroupData[] = []; + for (const [groupName, itemsMap] of groupMap.entries()) { + const items = Object.values(itemsMap); + data.push({ groupName, items }); + } + + return data; } - - return data } @@ -373,6 +548,7 @@ interface SavePQAnswer { interface SavePQInput { vendorId: number + projectId?: number answers: SavePQAnswer[] } @@ -380,20 +556,27 @@ interface SavePQInput { * 여러 항목을 한 번에 Upsert */ export async function savePQAnswersAction(input: SavePQInput) { - const { vendorId, answers } = input + const { vendorId, projectId, answers } = input try { for (const ans of answers) { - // 1) Check if a row already exists for (vendorId, criteriaId) + // 1) Check if a row already exists for (vendorId, criteriaId, projectId) + const queryConditions = [ + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId) + ]; + + // Add projectId condition when it exists + if (projectId !== undefined) { + queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId)); + } else { + queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId)); + } + const existing = await db .select() .from(vendorPqCriteriaAnswers) - .where( - and( - eq(vendorPqCriteriaAnswers.vendorId, vendorId), - eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId) - ) - ) + .where(and(...queryConditions)); let answerId: number @@ -405,11 +588,11 @@ export async function savePQAnswersAction(input: SavePQInput) { .values({ vendorId, criteriaId: ans.criteriaId, + projectId: projectId || null, // Include projectId when provided answer: ans.answer, - // no attachmentPaths column anymore }) .returning({ id: vendorPqCriteriaAnswers.id }) - + answerId = inserted[0].id } else { // Update existing @@ -425,8 +608,6 @@ export async function savePQAnswersAction(input: SavePQInput) { } // 3) Now manage attachments in vendorCriteriaAttachments - // We'll do a "diff": remove old ones not in the new list, insert new ones not in DB. - // 3a) Load old attachments from DB const oldAttachments = await db .select({ @@ -448,17 +629,16 @@ export async function savePQAnswersAction(input: SavePQInput) { .where(inArray(vendorCriteriaAttachments.id, removeIds)) } - // 3d) Insert new attachments that aren’t in DB + // 3d) Insert new attachments that aren't in DB const oldPaths = oldAttachments.map(o => o.filePath) const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url)) for (const attach of toAdd) { await db.insert(vendorCriteriaAttachments).values({ vendorCriteriaAnswerId: answerId, - fileName: attach.fileName, // original filename - filePath: attach.url, // random/UUID path on server + fileName: attach.fileName, + filePath: attach.url, fileSize: attach.size ?? null, - // fileType if you have it, etc. }) } } @@ -476,23 +656,40 @@ export async function savePQAnswersAction(input: SavePQInput) { * PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트 * @param vendorId 벤더 ID */ -export async function submitPQAction(vendorId: number) { +export async function submitPQAction({ + vendorId, + projectId +}: { + vendorId: number; + projectId?: number; +}) { unstable_noStore(); try { // 1. 모든 PQ 항목에 대한 응답이 있는지 검증 + const queryConditions = [ + eq(vendorPqCriteriaAnswers.vendorId, vendorId) + ]; + + // Add projectId condition when it exists + if (projectId !== undefined) { + queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId)); + } else { + queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId)); + } + const pqCriteriaCount = await db .select({ count: count() }) .from(vendorPqCriteriaAnswers) - .where(eq(vendorPqCriteriaAnswers.vendorId, vendorId)); - + .where(and(...queryConditions)); + const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0; - + // 응답 데이터 검증 if (totalPqCriteriaCount === 0) { return { ok: false, error: "No PQ answers found" }; } - + // 2. 벤더 정보 조회 const vendor = await db .select({ @@ -504,41 +701,118 @@ export async function submitPQAction(vendorId: number) { .from(vendors) .where(eq(vendors.id, vendorId)) .then(rows => rows[0]); - + if (!vendor) { return { ok: false, error: "Vendor not found" }; } - // 3. 벤더 상태가 제출 가능한 상태인지 확인 - const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; - if (!allowedStatuses.includes(vendor.status)) { - return { - ok: false, - error: `Cannot submit PQ in current status: ${vendor.status}` - }; + // Project 정보 조회 (projectId가 있는 경우) + let projectName = ''; + if (projectId) { + const projectData = await db + .select({ + projectName: projects.name + }) + .from(projects) + .where(eq(projects.id, projectId)) + .then(rows => rows[0]); + + projectName = projectData?.projectName || 'Unknown Project'; } - // 4. 벤더 상태 업데이트 - await db - .update(vendors) - .set({ - status: "PQ_SUBMITTED", - updatedAt: new Date(), - }) - .where(eq(vendors.id, vendorId)); + // 3. 상태 업데이트 + const currentDate = new Date(); - // 5. 관리자에게 이메일 알림 발송 + if (projectId) { + // 프로젝트별 PQ인 경우 vendorProjectPQs 테이블 업데이트 + const existingProjectPQ = await db + .select({ id: vendorProjectPQs.id, status: vendorProjectPQs.status }) + .from(vendorProjectPQs) + .where( + and( + eq(vendorProjectPQs.vendorId, vendorId), + eq(vendorProjectPQs.projectId, projectId) + ) + ) + .then(rows => rows[0]); + + if (existingProjectPQ) { + // 프로젝트 PQ 상태가 제출 가능한 상태인지 확인 + const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"]; + + if (!allowedStatuses.includes(existingProjectPQ.status)) { + return { + ok: false, + error: `Cannot submit Project PQ in current status: ${existingProjectPQ.status}` + }; + } + + // Update existing project PQ status + await db + .update(vendorProjectPQs) + .set({ + status: "SUBMITTED", + submittedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(vendorProjectPQs.id, existingProjectPQ.id)); + } else { + // Project PQ entry doesn't exist, create one + await db + .insert(vendorProjectPQs) + .values({ + vendorId, + projectId, + status: "SUBMITTED", + submittedAt: currentDate, + createdAt: currentDate, + updatedAt: currentDate, + }); + } + } else { + // 일반 PQ인 경우 벤더 상태 검증 및 업데이트 + const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; + + if (!allowedStatuses.includes(vendor.status)) { + return { + ok: false, + error: `Cannot submit PQ in current status: ${vendor.status}` + }; + } + + // Update vendor status + await db + .update(vendors) + .set({ + status: "PQ_SUBMITTED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + + // 4. 관리자에게 이메일 알림 발송 if (process.env.ADMIN_EMAIL) { try { + const emailSubject = projectId + ? `[eVCP] Project PQ Submitted: ${vendor.vendorName} for ${projectName}` + : `[eVCP] PQ Submitted: ${vendor.vendorName}`; + + const adminUrl = projectId + ? `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/projects/${projectId}/pq` + : `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`; + await sendEmail({ to: process.env.ADMIN_EMAIL, - subject: `[eVCP] PQ Submitted: ${vendor.vendorName}`, + subject: emailSubject, template: "pq-submitted-admin", context: { vendorName: vendor.vendorName, vendorId: vendor.id, - submittedDate: new Date().toLocaleString(), - adminUrl: `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`, + projectId: projectId, + projectName: projectName, + isProjectPQ: !!projectId, + submittedDate: currentDate.toLocaleString(), + adminUrl, } }); } catch (emailError) { @@ -546,18 +820,29 @@ export async function submitPQAction(vendorId: number) { // 이메일 실패는 전체 프로세스를 중단하지 않음 } } - - // 6. 벤더에게 확인 이메일 발송 + + // 5. 벤더에게 확인 이메일 발송 if (vendor.email) { try { + const emailSubject = projectId + ? `[eVCP] Project PQ Submission Confirmation for ${projectName}` + : "[eVCP] PQ Submission Confirmation"; + + const portalUrl = projectId + ? `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/projects/${projectId}` + : `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`; + await sendEmail({ to: vendor.email, - subject: "[eVCP] PQ Submission Confirmation", + subject: emailSubject, template: "pq-submitted-vendor", context: { vendorName: vendor.vendorName, - submittedDate: new Date().toLocaleString(), - portalUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, + projectId: projectId, + projectName: projectName, + isProjectPQ: !!projectId, + submittedDate: currentDate.toLocaleString(), + portalUrl, } }); } catch (emailError) { @@ -565,11 +850,17 @@ export async function submitPQAction(vendorId: number) { // 이메일 실패는 전체 프로세스를 중단하지 않음 } } - - // 7. 캐시 무효화 + + // 6. 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); + if (projectId) { + revalidateTag(`vendor-project-pqs-${vendorId}`); + revalidateTag(`project-vendors-${projectId}`); + revalidateTag(`project-pq-${projectId}`); + } + return { ok: true }; } catch (error) { console.error("PQ submit error:", error); @@ -697,7 +988,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { }); // 2) 글로벌 검색 - let globalWhere; + let globalWhere: SQL<unknown> | undefined = undefined; if (input.search) { const s = `%${input.search}%`; globalWhere = or( @@ -708,44 +999,80 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { ); } - // 최종 where 결합 - const finalWhere = and(advancedWhere, globalWhere, eq(vendors.status ,"PQ_SUBMITTED")); - - // 간단 검색 (advancedTable=false) 시 예시 - const simpleWhere = and( - input.vendorName - ? ilike(vendors.vendorName, `%${input.vendorName}%`) - : undefined, - input.status ? ilike(vendors.status, input.status) : undefined, - input.country - ? ilike(vendors.country, `%${input.country}%`) - : undefined - ); - - // 실제 사용될 where - const where = finalWhere; - - // 정렬 - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) - ) - : [asc(vendors.createdAt)]; - // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { - // 1) vendor 목록 조회 + // 벤더 ID 모음 (중복 제거용) + const vendorIds = new Set<number>(); + + // 1-A) 일반 PQ 답변이 있는 벤더 찾기 (status와 상관없이) + const generalPqVendors = await tx + .select({ + vendorId: vendorPqCriteriaAnswers.vendorId + }) + .from(vendorPqCriteriaAnswers) + .innerJoin( + vendors, + eq(vendorPqCriteriaAnswers.vendorId, vendors.id) + ) + .where( + and( + isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님) + advancedWhere, + globalWhere + ) + ) + .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트 + + generalPqVendors.forEach(v => vendorIds.add(v.vendorId)); + + // 1-B) 프로젝트 PQ 답변이 있는 벤더 ID 조회 (status와 상관없이) + const projectPqVendors = await tx + .select({ + vendorId: vendorProjectPQs.vendorId + }) + .from(vendorProjectPQs) + .innerJoin( + vendors, + eq(vendorProjectPQs.vendorId, vendors.id) + ) + .where( + and( + // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함 + not(eq(vendorProjectPQs.status, "REQUESTED")), // REQUESTED 상태는 제외 + advancedWhere, + globalWhere + ) + ); + + projectPqVendors.forEach(v => vendorIds.add(v.vendorId)); + + // 중복 제거된 벤더 ID 배열 + const uniqueVendorIds = Array.from(vendorIds); + + // 총 개수 (중복 제거 후) + const total = uniqueVendorIds.length; + + if (total === 0) { + return { data: [], total: 0 }; + } + + // 페이징 처리 (정렬 후 limit/offset 적용) + const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage); + + // 2) 페이징된 벤더 상세 정보 조회 const vendorsData = await selectVendors(tx, { - where, - orderBy, - offset, - limit: input.perPage, + where: inArray(vendors.id, paginatedIds), + orderBy: input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + ) + : [asc(vendors.createdAt)], }); - - // 2) 각 vendor의 attachments 조회 - const vendorsWithAttachments = await Promise.all( + + // 3) 각 벤더별 PQ 상태 정보 추가 + const vendorsWithPqInfo = await Promise.all( vendorsData.map(async (vendor) => { + // 3-A) 첨부 파일 조회 const attachments = await tx .select({ id: vendorAttachments.id, @@ -754,18 +1081,71 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); - + + // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지) + const generalPqAnswers = await tx + .select({ count: count() }) + .from(vendorPqCriteriaAnswers) + .where( + and( + eq(vendorPqCriteriaAnswers.vendorId, vendor.id), + isNull(vendorPqCriteriaAnswers.projectId) + ) + ); + + const hasGeneralPq = generalPqAnswers[0]?.count > 0; + + // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함) + const projectPqs = await tx + .select({ + projectId: vendorProjectPQs.projectId, + projectName: projects.name, + status: vendorProjectPQs.status, + submittedAt: vendorProjectPQs.submittedAt, + approvedAt: vendorProjectPQs.approvedAt, + rejectedAt: vendorProjectPQs.rejectedAt + }) + .from(vendorProjectPQs) + .innerJoin( + projects, + eq(vendorProjectPQs.projectId, projects.id) + ) + .where( + and( + eq(vendorProjectPQs.vendorId, vendor.id), + not(eq(vendorProjectPQs.status, "REQUESTED")) // REQUESTED 상태는 제외 + ) + ); + + const hasProjectPq = projectPqs.length > 0; + + // 프로젝트 PQ 상태별 카운트 + const projectPqStatusCounts = { + inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length, + submitted: projectPqs.filter(p => p.status === "SUBMITTED").length, + approved: projectPqs.filter(p => p.status === "APPROVED").length, + rejected: projectPqs.filter(p => p.status === "REJECTED").length, + total: projectPqs.length + }; + + // 3-D) PQ 상태 정보 추가 return { ...vendor, hasAttachments: attachments.length > 0, attachmentsList: attachments, + pqInfo: { + hasGeneralPq, + hasProjectPq, + projectPqs, + projectPqStatusCounts, + // 현재 PQ 상태 (UI에 표시 용도) + pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts) + } }; }) ); - - // 3) 전체 개수 - const total = await countVendors(tx, where); - return { data: vendorsWithAttachments, total }; + + return { data: vendorsWithPqInfo, total }; }); // 페이지 수 @@ -773,6 +1153,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { return { data, pageCount }; } catch (err) { + console.error("Error in getVendorsInPQ:", err); // 에러 발생 시 return { data: [], pageCount: 0 }; } @@ -780,11 +1161,65 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, - tags: ["vendors-in-pq"], // revalidateTag("vendors") 호출 시 무효화 + tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화 } )(); } +// PQ 상태 표시 함수 +function getPqStatusDisplay( + vendorStatus: string, + hasGeneralPq: boolean, + hasProjectPq: boolean, + projectPqCounts: { inProgress: number, submitted: number, approved: number, rejected: number, total: number } +): string { + // 프로젝트 PQ 상태 문자열 생성 + let projectPqStatus = ""; + if (hasProjectPq) { + const parts = []; + if (projectPqCounts.inProgress > 0) { + parts.push(`진행중: ${projectPqCounts.inProgress}`); + } + if (projectPqCounts.submitted > 0) { + parts.push(`제출: ${projectPqCounts.submitted}`); + } + if (projectPqCounts.approved > 0) { + parts.push(`승인: ${projectPqCounts.approved}`); + } + if (projectPqCounts.rejected > 0) { + parts.push(`거부: ${projectPqCounts.rejected}`); + } + projectPqStatus = parts.join(", "); + } + + // 일반 PQ + 프로젝트 PQ 조합 상태 + if (hasGeneralPq && hasProjectPq) { + return `일반 PQ (${getPqVendorStatusText(vendorStatus)}) + 프로젝트 PQ (${projectPqStatus})`; + } else if (hasGeneralPq) { + return `일반 PQ (${getPqVendorStatusText(vendorStatus)})`; + } else if (hasProjectPq) { + return `프로젝트 PQ (${projectPqStatus})`; + } + + return "PQ 정보 없음"; +} + +// 벤더 상태 텍스트 변환 +function getPqVendorStatusText(status: string): string { + switch (status) { + case "IN_PQ": return "진행중"; + case "PQ_SUBMITTED": return "제출됨"; + case "PQ_FAILED": return "실패"; + case "PQ_APPROVED": + case "APPROVED": return "승인됨"; + case "READY_TO_SEND": return "거래 준비"; + case "ACTIVE": return "활성"; + case "INACTIVE": return "비활성"; + case "BLACKLISTED": return "거래금지"; + default: return status; + } +} + export type VendorStatus = | "PENDING_REVIEW" @@ -797,6 +1232,7 @@ export type VendorStatus = | "ACTIVE" | "INACTIVE" | "BLACKLISTED" + | "PQ_APPROVED" export async function updateVendorStatusAction( vendorId: number, @@ -833,6 +1269,111 @@ export type VendorStatus = return { ok: false, error: String(error) } } } + + type ProjectPQStatus = "REQUESTED" | "IN_PROGRESS" | "SUBMITTED" | "APPROVED" | "REJECTED"; + +/** + * Update the status of a project-specific PQ for a vendor + */ +export async function updateProjectPQStatusAction({ + vendorId, + projectId, + status, + comment +}: { + vendorId: number; + projectId: number; + status: ProjectPQStatus; + comment?: string; +}) { + try { + const currentDate = new Date(); + + // 1) Prepare update data with appropriate timestamps + const updateData: any = { + status, + updatedAt: currentDate, + }; + + // Add status-specific fields + if (status === "APPROVED") { + updateData.approvedAt = currentDate; + } else if (status === "REJECTED") { + updateData.rejectedAt = currentDate; + updateData.rejectReason = comment || null; + } else if (status === "SUBMITTED") { + updateData.submittedAt = currentDate; + } + + // 2) Update the project PQ record + await db + .update(vendorProjectPQs) + .set(updateData) + .where( + and( + eq(vendorProjectPQs.vendorId, vendorId), + eq(vendorProjectPQs.projectId, projectId) + ) + ); + + // 3) Load vendor and project details for email + const vendor = await db + .select({ + id: vendors.id, + email: vendors.email, + vendorName: vendors.vendorName + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + const project = await db + .select({ + name: projects.name + }) + .from(projects) + .where(eq(projects.id, projectId)) + .then(rows => rows[0]); + + if (!project) { + return { ok: false, error: "Project not found" }; + } + + // 4) Send email notification + await sendEmail({ + to: vendor.email || "", + subject: `Your Project PQ for ${project.name} is now ${status}`, + template: "vendor-project-pq-status", // matches .hbs file (you might need to create this) + context: { + name: vendor.vendorName, + status, + projectName: project.name, + rejectionReason: status === "REJECTED" ? comment : undefined, + hasRejectionReason: status === "REJECTED" && !!comment, + loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq`, + approvalDate: status === "APPROVED" ? currentDate.toLocaleDateString() : undefined, + rejectionDate: status === "REJECTED" ? currentDate.toLocaleDateString() : undefined, + }, + }); + + // 5) Revalidate cache tags + revalidateTag("vendors"); + revalidateTag("vendors-in-pq"); + revalidateTag(`vendor-project-pqs-${vendorId}`); + revalidateTag(`project-pq-${projectId}`); + revalidateTag(`project-vendors-${projectId}`); + + return { ok: true }; + } catch (error) { + console.error("updateProjectPQStatusAction error:", error); + return { ok: false, error: String(error) }; + } +} + // 코멘트 타입 정의 interface ItemComment { answerId: number; @@ -850,24 +1391,60 @@ interface ItemComment { */ export async function requestPqChangesAction({ vendorId, + projectId, comment, generalComment, }: { vendorId: number; + projectId?: number; // Optional project ID for project-specific PQs comment: ItemComment[]; generalComment?: string; }) { try { - // 1) 벤더 상태 업데이트 - await db.update(vendors) - .set({ - status: "IN_PQ", // 변경 요청 상태로 설정 - updatedAt: new Date(), - }) - .where(eq(vendors.id, vendorId)); + // 1) 상태 업데이트 (PQ 타입에 따라 다르게 처리) + if (projectId) { + // 프로젝트 PQ인 경우 vendorProjectPQs 테이블 업데이트 + const projectPq = await db + .select() + .from(vendorProjectPQs) + .where( + and( + eq(vendorProjectPQs.vendorId, vendorId), + eq(vendorProjectPQs.projectId, projectId) + ) + ) + .then(rows => rows[0]); + + if (!projectPq) { + return { ok: false, error: "Project PQ record not found" }; + } + + await db + .update(vendorProjectPQs) + .set({ + status: "IN_PROGRESS", // 변경 요청 상태로 설정 + updatedAt: new Date(), + }) + .where( + and( + eq(vendorProjectPQs.vendorId, vendorId), + eq(vendorProjectPQs.projectId, projectId) + ) + ); + } else { + // 일반 PQ인 경우 vendors 테이블 업데이트 + await db + .update(vendors) + .set({ + status: "IN_PQ", // 변경 요청 상태로 설정 + updatedAt: new Date(), + }) + .where(eq(vendors.id, vendorId)); + } // 2) 벤더 정보 가져오기 - const vendor = await db.select() + const vendor = await db + .select() .from(vendors) .where(eq(vendors.id, vendorId)) .then(r => r[0]); @@ -876,6 +1453,20 @@ export async function requestPqChangesAction({ return { ok: false, error: "Vendor not found" }; } + // 프로젝트 정보 가져오기 (프로젝트 PQ인 경우) + let projectName = ""; + if (projectId) { + const project = await db + .select({ + name: projects.name + }) + .from(projects) + .where(eq(projects.id, projectId)) + .then(rows => rows[0]); + + projectName = project?.name || "Unknown Project"; + } + // 3) 각 항목별 코멘트 저장 const currentDate = new Date(); const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다) @@ -883,7 +1474,7 @@ export async function requestPqChangesAction({ // 병렬로 모든 코멘트 저장 if (comment && comment.length > 0) { - const insertPromises = comment.map(item => + const insertPromises = comment.map(item => db.insert(vendorPqReviewLogs) .values({ vendorPqCriteriaAnswerId: item.answerId, @@ -910,23 +1501,43 @@ export async function requestPqChangesAction({ text: item.comment })); + // PQ 유형에 따라 이메일 제목 및 내용 조정 + const emailSubject = projectId + ? `[IMPORTANT] Your Project PQ (${projectName}) requires changes` + : `[IMPORTANT] Your PQ submission requires changes`; + + // 로그인 URL - 프로젝트 PQ인 경우 다른 경로로 안내 + const loginUrl = projectId + ? `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq` + : `${process.env.NEXT_PUBLIC_URL}/partners/pq`; + await sendEmail({ to: vendor.email || "", - subject: `[IMPORTANT] Your PQ submission requires changes`, + subject: emailSubject, template: "vendor-pq-comment", // matches .hbs file context: { name: vendor.vendorName, vendorCode: vendor.vendorCode, - loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, + loginUrl, comments: commentItems, generalComment: generalComment || "", hasGeneralComment: !!generalComment, commentCount: commentItems.length, + projectId, + projectName, + isProjPQ: !!projectId, }, }); - revalidateTag("vendors") - revalidateTag("vendors-in-pq") + // 5) 캐시 무효화 - PQ 유형에 따라 적절한 태그 무효화 + revalidateTag("vendors"); + revalidateTag("vendors-in-pq"); + + if (projectId) { + revalidateTag(`vendor-project-pqs-${vendorId}`); + revalidateTag(`project-pq-${projectId}`); + revalidateTag(`project-vendors-${projectId}`); + } return { ok: true }; } catch (error) { @@ -934,6 +1545,7 @@ export async function requestPqChangesAction({ return { ok: false, error: String(error) }; } } + interface AddReviewCommentInput { answerId: number // vendorPqCriteriaAnswers.id comment: string @@ -984,4 +1596,80 @@ export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) { console.error("getItemReviewLogsAction error:", error); return { ok: false, error: String(error) }; } +} + +export interface VendorPQListItem { + projectId: number; + projectName: string; + status: string; + submittedAt?: Date | null; // Change to accept both undefined and null +} + +export interface VendorPQsList { + hasGeneralPq: boolean; + generalPqStatus?: string; // vendor.status for general PQ + projectPQs: VendorPQListItem[]; +} + +export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList> { + try { + // 1. Check if vendor has general PQ answers + const generalPqAnswers = await db + .select({ count: count() }) + .from(vendorPqCriteriaAnswers) + .where( + and( + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + isNull(vendorPqCriteriaAnswers.projectId) + ) + ); + + const hasGeneralPq = (generalPqAnswers[0]?.count || 0) > 0; + + // 2. Get vendor status for general PQ + let generalPqStatus; + if (hasGeneralPq) { + const vendor = await db + .select({ status: vendors.status }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + generalPqStatus = vendor?.status; + } + + // 3. Get project PQs + const projectPQs = await db + .select({ + projectId: vendorProjectPQs.projectId, + projectName: projects.name, + status: vendorProjectPQs.status, + submittedAt: vendorProjectPQs.submittedAt + }) + .from(vendorProjectPQs) + .innerJoin( + projects, + eq(vendorProjectPQs.projectId, projects.id) + ) + .where( + and( + eq(vendorProjectPQs.vendorId, vendorId), + not(eq(vendorProjectPQs.status, "REQUESTED")) // Exclude requests that haven't been started + ) + ) + .orderBy(vendorProjectPQs.updatedAt); + + return { + hasGeneralPq, + generalPqStatus, + projectPQs: projectPQs + }; + + } catch (error) { + console.error("Error fetching vendor PQs list:", error); + return { + hasGeneralPq: false, + projectPQs: [] + }; + } }
\ No newline at end of file diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx index 8164dbaf..1f374cd0 100644 --- a/lib/pq/table/add-pq-dialog.tsx +++ b/lib/pq/table/add-pq-dialog.tsx @@ -27,8 +27,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" import { useToast } from "@/hooks/use-toast" import { createPq, invalidatePqCache } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { ScrollArea } from "@/components/ui/scroll-area" // PQ 생성을 위한 Zod 스키마 정의 const createPqSchema = z.object({ @@ -36,10 +40,15 @@ const createPqSchema = z.object({ checkPoint: z.string().min(1, "Check point is required"), groupName: z.string().min(1, "Group is required"), description: z.string().optional(), - remarks: z.string().optional() + remarks: z.string().optional(), + // 프로젝트별 PQ 여부 체크박스 + isProjectSpecific: z.boolean().default(false), + // 프로젝트 관련 추가 필드는 isProjectSpecific가 true일 때만 필수 + contractInfo: z.string().optional(), + additionalRequirement: z.string().optional(), }); -type CreatePqInputType = z.infer<typeof createPqSchema>; +type CreatePqFormType = z.infer<typeof createPqSchema>; // 그룹 이름 옵션 const groupOptions = [ @@ -54,36 +63,71 @@ const descriptionExample = `Address : Tel. / Fax : e-mail :`; -export function AddPqDialog() { +interface AddPqDialogProps { + currentProjectId?: number | null; // 현재 선택된 프로젝트 ID (옵션) +} + +export function AddPqDialog({ currentProjectId }: AddPqDialogProps) { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) const router = useRouter() const { toast } = useToast() // react-hook-form 설정 - const form = useForm<CreatePqInputType>({ + const form = useForm<CreatePqFormType>({ resolver: zodResolver(createPqSchema), defaultValues: { code: "", checkPoint: "", groupName: groupOptions[0], description: "", - remarks: "" + remarks: "", + isProjectSpecific: !!currentProjectId, // 현재 프로젝트 ID가 있으면 기본값 true + contractInfo: "", + additionalRequirement: "", }, }) + // 프로젝트별 PQ 여부 상태 감시 + const isProjectSpecific = form.watch("isProjectSpecific") + + // 현재 프로젝트 ID가 있으면 선택된 프로젝트 설정 + React.useEffect(() => { + if (currentProjectId) { + form.setValue("isProjectSpecific", true) + } + }, [currentProjectId, form]) + // 예시 텍스트를 description 필드에 채우는 함수 const fillExampleText = () => { form.setValue("description", descriptionExample); }; - async function onSubmit(data: CreatePqInputType) { + async function onSubmit(data: CreatePqFormType) { try { setIsSubmitting(true) - + + // 서버 액션 호출용 데이터 준비 + const submitData = { + ...data, + projectId: data.isProjectSpecific ? selectedProject?.id || currentProjectId : null, + } + + // 프로젝트별 PQ인데 프로젝트가 선택되지 않은 경우 검증 + if (data.isProjectSpecific && !submitData.projectId) { + toast({ + title: "Error", + description: "Please select a project", + variant: "destructive", + }) + setIsSubmitting(false) + return + } + // 서버 액션 호출 - const result = await createPq(data) - + const result = await createPq(submitData) + if (!result.success) { toast({ title: "Error", @@ -94,20 +138,21 @@ export function AddPqDialog() { } await invalidatePqCache(); - + // 성공 시 처리 toast({ title: "Success", - description: "PQ criteria created successfully", + description: result.message || "PQ criteria created successfully", }) - + // 모달 닫고 폼 리셋 form.reset() + setSelectedProject(null) setOpen(false) - + // 페이지 새로고침 router.refresh() - + } catch (error) { console.error('Error creating PQ criteria:', error) toast({ @@ -123,10 +168,24 @@ export function AddPqDialog() { function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { form.reset() + setSelectedProject(null) } setOpen(nextOpen) } + // 프로젝트 선택 핸들러 + const handleProjectSelect = (project: Project | null) => { + // project가 null인 경우 선택 해제를 의미 + if (project === null) { + setSelectedProject(null); + // 필요한 경우 추가 처리 + return; + } + + // 기존 처리 - 프로젝트가 선택된 경우 + setSelectedProject(project); + } + return ( <Dialog open={open} onOpenChange={handleDialogOpenChange}> {/* 모달을 열기 위한 버튼 */} @@ -137,7 +196,7 @@ export function AddPqDialog() { </Button> </DialogTrigger> - <DialogContent className="sm:max-w-[550px]"> + <DialogContent className="sm:max-w-[600px]"> <DialogHeader> <DialogTitle>Create New PQ Criteria</DialogTitle> <DialogDescription> @@ -147,145 +206,241 @@ export function AddPqDialog() { {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> - {/* Code 필드 */} - <FormField - control={form.control} - name="code" - render={({ field }) => ( - <FormItem> - <FormLabel>Code <span className="text-destructive">*</span></FormLabel> - <FormControl> - <Input - placeholder="예: 1-1, A.2.3" - {...field} - /> - </FormControl> - <FormDescription> - PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3") - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* Check Point 필드 */} - <FormField - control={form.control} - name="checkPoint" - render={({ field }) => ( - <FormItem> - <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel> - <FormControl> - <Input - placeholder="검증 항목을 입력하세요" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Group Name 필드 (Select) */} - <FormField - control={form.control} - name="groupName" - render={({ field }) => ( - <FormItem> - <FormLabel>Group <span className="text-destructive">*</span></FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - value={field.value} - > + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2 flex flex-col"> + {/* 프로젝트별 PQ 여부 체크박스 */} + + <div className="flex-1 overflow-auto px-4 space-y-4"> + <FormField + control={form.control} + name="isProjectSpecific" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> <FormControl> - <SelectTrigger> - <SelectValue placeholder="그룹을 선택하세요" /> - </SelectTrigger> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> </FormControl> - <SelectContent> - {groupOptions.map((group) => ( - <SelectItem key={group} value={group}> - {group} - </SelectItem> - ))} - </SelectContent> - </Select> + <div className="space-y-1 leading-none"> + <FormLabel>프로젝트별 PQ 생성</FormLabel> + <FormDescription> + 특정 프로젝트에만 적용되는 PQ 항목을 생성합니다 + </FormDescription> + </div> + </FormItem> + )} + /> + + {/* 프로젝트 선택 필드 (프로젝트별 PQ 선택 시에만 표시) */} + {isProjectSpecific && ( + <div className="space-y-2"> + <FormLabel>Project <span className="text-destructive">*</span></FormLabel> + <ProjectSelector + selectedProjectId={currentProjectId || selectedProject?.id} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요" + /> <FormDescription> - PQ 항목의 분류 그룹을 선택하세요 + PQ 항목을 적용할 프로젝트를 선택하세요 </FormDescription> - <FormMessage /> - </FormItem> + </div> )} - /> - - {/* Description 필드 - 예시 템플릿 버튼 추가 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <div className="flex items-center justify-between"> - <FormLabel>Description</FormLabel> - <Button - type="button" - variant="outline" - size="sm" - onClick={fillExampleText} - > - 예시 채우기 - </Button> - </div> - <FormControl> - <Textarea - placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`} - className="min-h-[120px] font-mono" - {...field} - value={field.value || ""} + + <div className="flex-1 overflow-auto px-2 py-2 space-y-4" style={{maxHeight:420}}> + + + {/* Code 필드 */} + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>Code <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 1-1, A.2.3" + {...field} + /> + </FormControl> + <FormDescription> + PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3") + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Check Point 필드 */} + <FormField + control={form.control} + name="checkPoint" + render={({ field }) => ( + <FormItem> + <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="검증 항목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Group Name 필드 (Select) */} + <FormField + control={form.control} + name="groupName" + render={({ field }) => ( + <FormItem> + <FormLabel>Group <span className="text-destructive">*</span></FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="그룹을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {groupOptions.map((group) => ( + <SelectItem key={group} value={group}> + {group} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + PQ 항목의 분류 그룹을 선택하세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description 필드 - 예시 템플릿 버튼 추가 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <div className="flex items-center justify-between"> + <FormLabel>Description</FormLabel> + <Button + type="button" + variant="outline" + size="sm" + onClick={fillExampleText} + > + 예시 채우기 + </Button> + </div> + <FormControl> + <Textarea + placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`} + className="min-h-[120px] font-mono" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remarks 필드 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="비고 사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트별 PQ일 경우 추가 필드 */} + {isProjectSpecific && ( + <> + {/* 계약 정보 필드 */} + <FormField + control={form.control} + name="contractInfo" + render={({ field }) => ( + <FormItem> + <FormLabel>Contract Info</FormLabel> + <FormControl> + <Textarea + placeholder="계약 관련 정보를 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 해당 프로젝트의 계약 관련 특이사항 + </FormDescription> + <FormMessage /> + </FormItem> + )} /> - </FormControl> - <FormDescription> - 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* Remarks 필드 */} - <FormField - control={form.control} - name="remarks" - render={({ field }) => ( - <FormItem> - <FormLabel>Remarks</FormLabel> - <FormControl> - <Textarea - placeholder="비고 사항을 입력하세요" - className="min-h-[80px]" - {...field} - value={field.value || ""} + + {/* 추가 요구사항 필드 */} + <FormField + control={form.control} + name="additionalRequirement" + render={({ field }) => ( + <FormItem> + <FormLabel>Additional Requirements</FormLabel> + <FormControl> + <Textarea + placeholder="추가 요구사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 프로젝트별 추가 요구사항 + </FormDescription> + <FormMessage /> + </FormItem> + )} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + </> + )} + </div> + </div> <DialogFooter> <Button type="button" variant="outline" onClick={() => { - form.reset(); - setOpen(false); - }} + form.reset(); + setSelectedProject(null); + setOpen(false); + }} > Cancel </Button> - <Button - type="submit" + <Button + type="submit" disabled={isSubmitting} > {isSubmitting ? "Creating..." : "Create"} diff --git a/lib/pq/table/import-pq-button.tsx b/lib/pq/table/import-pq-button.tsx new file mode 100644 index 00000000..e4e0147f --- /dev/null +++ b/lib/pq/table/import-pq-button.tsx @@ -0,0 +1,258 @@ +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { processFileImport } from "./import-pq-handler" // 별도 파일로 분리 + +interface ImportPqButtonProps { + projectId?: number | null + onSuccess?: () => void +} + +export function ImportPqButton({ projectId, onSuccess }: ImportPqButtonProps) { + const [open, setOpen] = React.useState(false) + const [file, setFile] = React.useState<File | null>(null) + const [isUploading, setIsUploading] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const [error, setError] = React.useState<string | null>(null) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (!selectedFile) return + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.") + return + } + + setFile(selectedFile) + setError(null) + } + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요.") + return + } + + try { + setIsUploading(true) + setProgress(0) + setError(null) + + // 파일을 ArrayBuffer로 읽기 + const arrayBuffer = await file.arrayBuffer(); + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 번호 찾기 (보통 지침 행이 있으므로 헤더는 뒤에 위치) + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "Code" || v === "Check Point") && rowNumber > 1) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record<string, number> = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 + const requiredHeaders = ["Code", "Check Point", "Group Name"]; + const missingHeaders = requiredHeaders.filter(header => !(header in headerMapping)); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 (헤더 이후 행부터) + const dataRows: Record<string, any>[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record<string, any> = {}; + const values = row.values as (string | null | undefined)[]; + + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 실제 데이터 처리는 별도 함수에서 수행 + const result = await processFileImport( + dataRows, + projectId, + updateProgress + ); + + // 처리 완료 + toast.success(`${result.successCount}개의 PQ 항목이 성공적으로 가져와졌습니다.`); + + if (result.errorCount > 0) { + toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null) + setError(null) + setProgress(0) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + setOpen(newOpen) + } + + return ( + <> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setOpen(true)} + disabled={isUploading} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>PQ 항목 가져오기</DialogTitle> + <DialogDescription> + {projectId + ? "프로젝트별 PQ 항목을 Excel 파일에서 가져옵니다." + : "일반 PQ 항목을 Excel 파일에서 가져옵니다."} + <br /> + 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="flex items-center gap-4"> + <input + type="file" + ref={fileInputRef} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isUploading} + /> + </div> + + {file && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB) + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <Progress value={progress} /> + <p className="text-sm text-muted-foreground text-center"> + {progress}% 완료 + </p> + </div> + )} + + {error && ( + <div className="text-sm font-medium text-destructive"> + {error} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!file || isUploading} + > + {isUploading ? "처리 중..." : "가져오기"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/table/import-pq-handler.tsx b/lib/pq/table/import-pq-handler.tsx new file mode 100644 index 00000000..aa5e6c47 --- /dev/null +++ b/lib/pq/table/import-pq-handler.tsx @@ -0,0 +1,146 @@ +"use client" + +import { z } from "zod" +import { createPq } from "../service" // PQ 생성 서버 액션 + +// PQ 데이터 검증을 위한 Zod 스키마 +const pqItemSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional().nullable(), + remarks: z.string().optional().nullable(), + contractInfo: z.string().optional().nullable(), + additionalRequirement: z.string().optional().nullable(), +}); + +// 지원하는 그룹 이름 목록 +const validGroupNames = [ + "GENERAL", + "Quality Management System", + "Workshop & Environment", + "Warranty", +]; + +type ImportPqItem = z.infer<typeof pqItemSchema>; + +interface ProcessResult { + successCount: number; + errorCount: number; + errors?: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 PQ 데이터를 처리하는 함수 + */ +export async function processFileImport( + jsonData: any[], + projectId: number | null | undefined, + progressCallback?: (current: number, total: number) => void +): Promise<ProcessResult> { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 헤더 행과 지침 행 건너뛰기 + const dataRows = jsonData.filter(row => { + // 행이 문자열로만 구성된 경우 지침 행으로 간주 + if (Object.values(row).every(val => typeof val === 'string' && !val.includes(':'))) { + return false; + } + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0 }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 데이터 정제 + const cleanedRow: ImportPqItem = { + code: row.Code?.toString().trim() ?? "", + checkPoint: row["Check Point"]?.toString().trim() ?? "", + groupName: row["Group Name"]?.toString().trim() ?? "", + description: row.Description?.toString() ?? null, + remarks: row.Remarks?.toString() ?? null, + contractInfo: row["Contract Info"]?.toString() ?? null, + additionalRequirement: row["Additional Requirements"]?.toString() ?? null, + }; + + // 데이터 유효성 검사 + const validationResult = pqItemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ row: rowIndex, message: errorMessage }); + errorCount++; + continue; + } + + // 그룹 이름 유효성 검사 + if (!validGroupNames.includes(cleanedRow.groupName)) { + errors.push({ + row: rowIndex, + message: `Invalid group name: ${cleanedRow.groupName}. Must be one of: ${validGroupNames.join(', ')}` + }); + errorCount++; + continue; + } + + // PQ 생성 서버 액션 호출 + const createResult = await createPq({ + ...cleanedRow, + projectId: projectId, + isProjectSpecific: !!projectId, + }); + + if (createResult.success) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: createResult.message || "Unknown error" + }); + errorCount++; + } + } catch (error) { + console.error(`Row ${rowIndex} processing error:`, error); + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "Unknown error" + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors: errors.length > 0 ? errors : undefined + }; +}
\ No newline at end of file diff --git a/lib/pq/table/pq-excel-template.tsx b/lib/pq/table/pq-excel-template.tsx new file mode 100644 index 00000000..aa8c1b3a --- /dev/null +++ b/lib/pq/table/pq-excel-template.tsx @@ -0,0 +1,205 @@ +"use client" + +import * as ExcelJS from 'exceljs'; +import { saveAs } from 'file-saver'; +import { toast } from 'sonner'; + +/** + * PQ 기준 Excel 템플릿을 다운로드하는 함수 (exceljs 사용) + * @param isProjectSpecific 프로젝트별 PQ 템플릿 여부 + */ +export async function exportPqTemplate(isProjectSpecific: boolean = false) { + try { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + + // 워크시트 생성 + const sheetName = isProjectSpecific ? "Project PQ Template" : "General PQ Template"; + const worksheet = workbook.addWorksheet(sheetName); + + // 그룹 옵션 정의 - 드롭다운 목록에 사용 + const groupOptions = [ + "GENERAL", + "Quality Management System", + "Workshop & Environment", + "Warranty", + ]; + + // 일반 PQ 필드 (기본 필드) + const basicFields = [ + { header: "Code", key: "code", width: 90 }, + { header: "Check Point", key: "checkPoint", width: 180 }, + { header: "Group Name", key: "groupName", width: 150 }, + { header: "Description", key: "description", width: 240 }, + { header: "Remarks", key: "remarks", width: 180 }, + ]; + + // 프로젝트별 PQ 추가 필드 + const projectFields = isProjectSpecific + ? [ + { header: "Contract Info", key: "contractInfo", width: 180 }, + { header: "Additional Requirements", key: "additionalRequirement", width: 240 }, + ] + : []; + + // 모든 필드 합치기 + const fields = [...basicFields, ...projectFields]; + + // 지침 행 추가 + const instructionTitle = worksheet.addRow(["Instructions:"]); + instructionTitle.font = { bold: true, size: 12 }; + worksheet.mergeCells(1, 1, 1, fields.length); + + const instructions = [ + "1. 'Code' 필드는 고유해야 합니다 (예: 1-1, A.2.3).", + "2. 'Check Point'는 필수 항목입니다.", + "3. 'Group Name'은 드롭다운 목록에서 선택하세요: GENERAL, Quality Management System, Workshop & Environment, Warranty", + "4. 여러 줄 텍스트는 \\n으로 줄바꿈을 표시합니다.", + "5. 아래 회색 배경의 예시 행은 참고용입니다. 실제 데이터 입력 전에 이 행을 수정하거나 삭제해야 합니다.", + ]; + + // 프로젝트별 PQ일 경우 추가 지침 + if (isProjectSpecific) { + instructions.push( + "6. 'Contract Info'와 'Additional Requirements'는 프로젝트별 세부 정보를 위한 필드입니다." + ); + } + + // 지침 행 추가 + instructions.forEach((instruction, idx) => { + const row = worksheet.addRow([instruction]); + worksheet.mergeCells(idx + 2, 1, idx + 2, fields.length); + row.font = { color: { argb: '00808080' } }; + }); + + // 빈 행 추가 + worksheet.addRow([]); + + // 헤더 행 추가 + const headerRow = worksheet.addRow(fields.map(field => field.header)); + headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 예시 행 표시를 위한 첫 번째 열 값 수정 + const exampleData: Record<string, string> = { + code: "[예시 - 수정/삭제 필요] 1-1", + checkPoint: "Selling / 1 year Property", + groupName: "GENERAL", + description: "Address :\nTel. / Fax :\ne-mail :", + remarks: "Optional remarks", + }; + + // 프로젝트별 PQ인 경우 예시 데이터에 추가 필드 추가 + if (isProjectSpecific) { + exampleData.contractInfo = "Contract details for this project"; + exampleData.additionalRequirement = "Additional technical requirements"; + } + + const exampleRow = worksheet.addRow(fields.map(field => exampleData[field.key] || "")); + exampleRow.font = { italic: true }; + exampleRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFEDEDED' } + }; + // 예시 행 첫 번째 셀에 코멘트 추가 + const codeCell = worksheet.getCell(exampleRow.number, 1); + codeCell.note = '이 예시 행은 참고용입니다. 실제 데이터 입력 전에 수정하거나 삭제하세요.'; + + // Group Name 열 인덱스 찾기 (0-based) + const groupNameIndex = fields.findIndex(field => field.key === "groupName"); + + // 열 너비 설정 + fields.forEach((field, index) => { + const column = worksheet.getColumn(index + 1); + column.width = field.width / 6.5; // ExcelJS에서는 픽셀과 다른 단위 사용 + }); + + // 각 셀에 테두리 추가 + const headerRowNum = instructions.length + 3; + const exampleRowNum = headerRowNum + 1; + + for (let i = 1; i <= fields.length; i++) { + // 헤더 행에 테두리 추가 + worksheet.getCell(headerRowNum, i).border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + + // 예시 행에 테두리 추가 + worksheet.getCell(exampleRowNum, i).border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + } + + // 사용자 입력용 빈 행 추가 (10개) + for (let rowIdx = 0; rowIdx < 10; rowIdx++) { + // 빈 행 추가 + const emptyRow = worksheet.addRow(Array(fields.length).fill('')); + const currentRowNum = exampleRowNum + 1 + rowIdx; + + // 각 셀에 테두리 추가 + for (let colIdx = 1; colIdx <= fields.length; colIdx++) { + const cell = worksheet.getCell(currentRowNum, colIdx); + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + + // Group Name 열에 데이터 유효성 검사 (드롭다운) 추가 + if (colIdx === groupNameIndex + 1) { + cell.dataValidation = { + type: 'list', + allowBlank: true, + formulae: [`"${groupOptions.join(',')}"`], + showErrorMessage: true, + errorStyle: 'error', + error: '유효하지 않은 그룹입니다', + errorTitle: '입력 오류', + prompt: '목록에서 선택하세요', + promptTitle: '그룹 선택' + }; + } + } + } + + // 예시 행이 있는 열에도 Group Name 드롭다운 적용 + const exampleGroupCell = worksheet.getCell(exampleRowNum, groupNameIndex + 1); + exampleGroupCell.dataValidation = { + type: 'list', + allowBlank: true, + formulae: [`"${groupOptions.join(',')}"`], + showErrorMessage: true, + errorStyle: 'error', + error: '유효하지 않은 그룹입니다', + errorTitle: '입력 오류', + prompt: '목록에서 선택하세요', + promptTitle: '그룹 선택' + }; + + // 워크북을 Excel 파일로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + + // 파일명 설정 및 저장 + const fileName = isProjectSpecific ? "project-pq-template.xlsx" : "general-pq-template.xlsx"; + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, fileName); + + toast.success(`${isProjectSpecific ? '프로젝트별' : '일반'} PQ 템플릿이 다운로드되었습니다.`); + } catch (error) { + console.error("템플릿 다운로드 중 오류 발생:", error); + toast.error("템플릿 다운로드 중 오류가 발생했습니다."); + } +}
\ No newline at end of file diff --git a/lib/pq/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx index 1d151520..1790caf8 100644 --- a/lib/pq/table/pq-table-toolbar-actions.tsx +++ b/lib/pq/table/pq-table-toolbar-actions.tsx @@ -2,23 +2,41 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Send, Upload } from "lucide-react" +import { Download, FileDown, Upload } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + import { DeletePqsDialog } from "./delete-pqs-dialog" import { AddPqDialog } from "./add-pq-dialog" import { PqCriterias } from "@/db/schema/pq" +import { ImportPqButton } from "./import-pq-button" +import { exportPqTemplate } from "./pq-excel-template" - -interface DocTableToolbarActionsProps { +interface PqTableToolbarActionsProps { table: Table<PqCriterias> + currentProjectId?: number } -export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) { - - +export function PqTableToolbarActions({ + table, + currentProjectId +}: PqTableToolbarActionsProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + const isProjectSpecific = !!currentProjectId + + // Import 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } + return ( <div className="flex items-center gap-2"> {table.getFilteredSelectedRowModel().rows.length > 0 ? ( @@ -29,27 +47,41 @@ export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) { onSuccess={() => table.toggleAllRowsSelected(false)} /> ) : null} - - - <AddPqDialog /> - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "Document-list", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - - - + + <AddPqDialog currentProjectId={currentProjectId} /> + + {/* Import 버튼 */} + <ImportPqButton + projectId={currentProjectId} + onSuccess={handleImportSuccess} + /> + + {/* Export 드롭다운 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: isProjectSpecific ? `project-${currentProjectId}-pq-criteria` : "general-pq-criteria", + excludeColumns: ["select", "actions"], + }) + } + > + <FileDown className="mr-2 h-4 w-4" /> + <span>현재 데이터 내보내기</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => exportPqTemplate(isProjectSpecific)}> + <FileDown className="mr-2 h-4 w-4" /> + <span>{isProjectSpecific ? '프로젝트용' : '일반'} 템플릿 다운로드</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file diff --git a/lib/pq/table/pq-table.tsx b/lib/pq/table/pq-table.tsx index 73876c72..99365ad5 100644 --- a/lib/pq/table/pq-table.tsx +++ b/lib/pq/table/pq-table.tsx @@ -19,10 +19,12 @@ import { UpdatePqSheet } from "./update-pq-sheet" interface DocumentListTableProps { promises: Promise<[Awaited<ReturnType<typeof getPQs>>]> + currentProjectId?: number } export function PqsTable({ promises, + currentProjectId }: DocumentListTableProps) { // 1) 데이터를 가져옴 (server component -> use(...) pattern) const [{ data, pageCount }] = React.use(promises) @@ -103,7 +105,7 @@ export function PqsTable({ filterFields={advancedFilterFields} shallow={false} > - <PqTableToolbarActions table={table} /> + <PqTableToolbarActions table={table} currentProjectId={currentProjectId}/> </DataTableAdvancedToolbar> </DataTable> diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx index f1dbf90e..3d822499 100644 --- a/lib/rfqs/table/ItemsDialog.tsx +++ b/lib/rfqs/table/ItemsDialog.tsx @@ -96,16 +96,16 @@ export function RfqsItemsDialog({ rfqType }: RfqsItemsDialogProps) { const rfqId = rfq?.rfqId ?? 0; - + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 const isEditable = rfq?.status === "DRAFT"; - + // 초기 아이템 ID 목록을 추적하기 위한 상태 추가 const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]); - + // 삭제된 아이템 ID를 저장하는 상태 추가 const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]); - + // 1) form const form = useForm<ItemsFormSchema>({ resolver: zodResolver(itemsFormSchema), @@ -125,24 +125,24 @@ export function RfqsItemsDialog({ // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장 React.useEffect(() => { if (open) { - const initialItems = defaultItems.length > 0 + const initialItems = defaultItems.length > 0 ? defaultItems.map((it) => ({ - id: it.id, - quantity: it.quantity ?? 1, - uom: it.uom ?? "each", - itemCode: it.itemCode ?? "", - description: it.description ?? "", - })) + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }]; - + form.reset({ rfqId, items: initialItems, }); - + // 초기 아이템 ID 목록 저장 setInitialItemIds(defaultItems.map(item => item.id)); - + // 삭제된 아이템 목록 초기화 setDeletedItemIds([]); setHasUnsavedChanges(false); @@ -158,7 +158,7 @@ export function RfqsItemsDialog({ // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지 React.useEffect(() => { if (!isEditable) return; - + const subscription = form.watch(() => { setHasUnsavedChanges(true); }); @@ -177,16 +177,16 @@ export function RfqsItemsDialog({ // 4) Add item row with auto-focus function handleAddItem() { if (!isEditable) return; - + // 명시적으로 숫자 타입으로 지정 - append({ - itemCode: "", - description: "", - quantity: 1, - uom: "each" + append({ + itemCode: "", + description: "", + quantity: 1, + uom: "each" }); setHasUnsavedChanges(true); - + // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스 setTimeout(() => { const newIndex = fields.length; @@ -200,17 +200,17 @@ export function RfqsItemsDialog({ // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가 const handleRemoveItem = (index: number) => { if (!isEditable) return; - + const itemToRemove = form.getValues().items[index]; - + // 기존 ID가 있는 아이템이라면 삭제 목록에 추가 if (itemToRemove.id !== undefined) { setDeletedItemIds(prev => [...prev, itemToRemove.id as number]); } - + remove(index); setHasUnsavedChanges(true); - + // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로 setTimeout(() => { const nextIndex = Math.min(index, fields.length - 1); @@ -232,7 +232,7 @@ export function RfqsItemsDialog({ // 필드 포커스 유틸리티 함수 const focusField = (selector: string) => { if (!isEditable) return; - + setTimeout(() => { const element = document.querySelector(selector) as HTMLInputElement | null; if (element) { @@ -244,28 +244,28 @@ export function RfqsItemsDialog({ // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리) async function onSubmit(data: ItemsFormSchema) { if (!isEditable) return; - + try { setIsSubmitting(true); - + // 각 아이템이 유효한지 확인 const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1); - + if (anyInvalidItems) { toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요."); setIsSubmitting(false); return; } - + // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청 - const deletePromises = deletedItemIds.map(id => + const deletePromises = deletedItemIds.map(id => deleteRfqItem({ id: id, rfqId: rfqId, rfqType: rfqType ?? RfqType.PURCHASE }) ); - + // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 const upsertPromises = data.items.map((item) => createRfqItem({ @@ -273,13 +273,13 @@ export function RfqsItemsDialog({ itemCode: item.itemCode, description: item.description, // 명시적으로 숫자로 변환 - quantity: Number(item.quantity), + quantity: Number(item.quantity), uom: item.uom, rfqType: rfqType ?? RfqType.PURCHASE, id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성 }) ); - + // 모든 요청 병렬 처리 await Promise.all([...deletePromises, ...upsertPromises]); @@ -296,7 +296,7 @@ export function RfqsItemsDialog({ // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화 React.useEffect(() => { if (!isEditable) return; - + const handleKeyDown = (e: KeyboardEvent) => { // Alt+N: 새 항목 추가 if (e.altKey && e.key === 'n') { @@ -336,8 +336,8 @@ export function RfqsItemsDialog({ </Badge> )} {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} className="ml-1" > {rfq.status} @@ -345,8 +345,8 @@ export function RfqsItemsDialog({ )} </DialogTitle> <DialogDescription> - {isEditable - ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') + {isEditable + ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'} </DialogDescription> </DialogHeader> @@ -393,6 +393,7 @@ export function RfqsItemsDialog({ <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors"> {/* -- itemCode + Popover(Select) -- */} {isEditable ? ( + // 전체 FormField 컴포넌트와 아이템 선택 로직 개선 <FormField control={form.control} name={`items.${index}.itemCode`} @@ -401,7 +402,7 @@ export function RfqsItemsDialog({ const selected = filteredItems.find(it => it.code === field.value); return ( - <FormItem className="flex items-center gap-2 w-[250px]"> + <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}> <FormControl> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> <PopoverTrigger asChild> @@ -413,12 +414,17 @@ export function RfqsItemsDialog({ variant="outline" role="combobox" aria-expanded={popoverOpen} - className="w-full justify-between" + className="flex items-center" data-error={!!form.formState.errors.items?.[index]?.itemCode} data-state={selected ? "filled" : "empty"} + style={{width:250}} > - {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + <div className="flex-1 overflow-hidden mr-2 text-left"> + <span className="block truncate" style={{width:200}}> + {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."} + </span> + </div> + <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-[400px] p-0"> @@ -440,7 +446,9 @@ export function RfqsItemsDialog({ focusField(`input[name="items.${index}.description"]`); }} > - {label} + <div className="flex-1 overflow-hidden"> + <span className="block truncate">{label}</span> + </div> <Check className={ "ml-auto h-4 w-4" + @@ -486,9 +494,9 @@ export function RfqsItemsDialog({ render={({ field }) => ( <FormItem className="w-[400px]"> <FormControl> - <Input - className="w-full" - placeholder="아이템 상세 정보" + <Input + className="w-full" + placeholder="아이템 상세 정보" {...field} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -650,7 +658,7 @@ export function RfqsItemsDialog({ </span> )} </div> - + {isEditable && ( <div className="text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1 mr-2"> @@ -680,12 +688,12 @@ export function RfqsItemsDialog({ <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent> </Tooltip> </TooltipProvider> - + <TooltipProvider> <Tooltip> <TooltipTrigger asChild> - <Button - type="submit" + <Button + type="submit" disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid} > {isSubmitting ? ( diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx index 1d824bc0..45390cd0 100644 --- a/lib/rfqs/table/add-rfq-dialog.tsx +++ b/lib/rfqs/table/add-rfq-dialog.tsx @@ -128,7 +128,11 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) }, [budgetaryRfqs, budgetarySearchTerm]); // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project) => { + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } + form.setValue("projectId", project.id); }; diff --git a/lib/tags/service.ts b/lib/tags/service.ts index efba2fd5..034c106f 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -7,7 +7,7 @@ import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type import { revalidateTag, unstable_noStore } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne ,count,isNull} from "drizzle-orm"; import { countTags, deleteTagById, deleteTagsByIds, insertTag, selectTags } from "./repository"; import { getErrorMessage } from "../handle-error"; import { getFormMappingsByTagType } from './form-mapping-service'; @@ -158,6 +158,7 @@ export async function createTag( const createdOrExistingForms: CreatedOrExistingForm[] = [] if (formMappings && formMappings.length > 0) { + console.log(selectedPackageId, formMappings) for (const formMapping of formMappings) { // 4-1) 이미 존재하는 폼인지 확인 const existingForm = await tx @@ -236,6 +237,8 @@ export async function createTag( } }) } catch (err: any) { + console.log("createTag error:", err) + console.error("createTag error:", err) return { error: getErrorMessage(err) } } @@ -540,12 +543,12 @@ function removeTagFromDataJson( export async function removeTags(input: RemoveTagsInput) { unstable_noStore() // React 서버 액션 무상태 함수 - + const { ids, selectedPackageId } = input - + try { await db.transaction(async (tx) => { - // 1) 삭제 대상 tag들을 미리 조회 (tagNo, tagType, class 등을 얻기 위함) + // 1) 삭제 대상 tag들을 미리 조회 const tagsToDelete = await tx .select({ id: tags.id, @@ -555,72 +558,112 @@ export async function removeTags(input: RemoveTagsInput) { }) .from(tags) .where(inArray(tags.id, ids)) - - // 2) 각 tag마다 관련된 formCode를 찾고, forms & formEntries 처리를 수행 - for (const tagInfo of tagsToDelete) { - const { tagNo, tagType, class: tagClass } = tagInfo - - // 2-1) tagTypeClassFormMappings(혹은 대응되는 로직)에서 formCode 목록 가져오기 - const formMappings = await getFormMappingsByTagType(tagType, tagClass) - if (!formMappings) continue - - // 2-2) 얻어온 formCode 리스트를 순회하면서, forms 테이블과 formEntries 테이블 처리 - for (const fm of formMappings) { - // (A) forms 테이블 삭제 - // - 조건: contractItemId=selectedPackageId, formCode=fm.formCode - await tx - .delete(forms) - .where( - and( - eq(forms.contractItemId, selectedPackageId), - eq(forms.formCode, fm.formCode) - ) + + // 2) 태그 타입과 클래스의 고유 조합 추출 + const uniqueTypeClassCombinations = [...new Set( + tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`) + )].map(combo => { + const [tagType, classValue] = combo.split('|'); + return { tagType, class: classValue || undefined }; + }); + + // 3) 각 태그 타입/클래스 조합에 대해 처리 + for (const { tagType, class: classValue } of uniqueTypeClassCombinations) { + // 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인 + const otherTagsWithSameTypeClass = await tx + .select({ count: count() }) + .from(tags) + .where( + and( + eq(tags.tagType, tagType), + classValue ? eq(tags.class, classValue) : isNull(tags.class), + not(inArray(tags.id, ids)), // 현재 삭제 중인 태그들은 제외 + eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인 ) - - // (B) formEntries 테이블 JSON에서 tagNo 제거 → 업데이트 - // - 예: formEntries 안에 (id, contractItemId, formCode, data(=json)) 칼럼 존재 가정 - const formEntryRecords = await tx - .select({ - id: formEntries.id, - data: formEntries.data, - }) - .from(formEntries) - .where( - and( - eq(formEntries.contractItemId, selectedPackageId), - eq(formEntries.formCode, fm.formCode) + ) + + // 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기 + const formMappings = await getFormMappingsByTagType(tagType, classValue); + + if (!formMappings.length) continue; + + // 3-3) 이 태그 타입/클래스와 관련된 태그 번호 추출 + const relevantTagNos = tagsToDelete + .filter(tag => tag.tagType === tagType && + (classValue ? tag.class === classValue : !tag.class)) + .map(tag => tag.tagNo); + + // 3-4) 각 폼 코드에 대해 처리 + for (const formMapping of formMappings) { + // 다른 태그가 없다면 폼 삭제 + if (otherTagsWithSameTypeClass[0].count === 0) { + // 폼 삭제 + await tx + .delete(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) ) - ) - - // 여러 formEntries 레코드가 있을 수도 있어서 모두 처리 - for (const entry of formEntryRecords) { - const updatedJson = removeTagFromDataJson(entry.data, tagNo) - - // 변경이 있다면 업데이트 + + // formEntries 테이블에서도 해당 formCode 관련 데이터 삭제 await tx - .update(formEntries) - .set({ data: updatedJson }) - .where(eq(formEntries.id, entry.id)) + .delete(formEntries) + .where( + and( + eq(formEntries.contractItemId, selectedPackageId), + eq(formEntries.formCode, formMapping.formCode) + ) + ) + } + // 다른 태그가 있다면 formEntries 데이터에서 해당 태그 정보만 제거 + else if (relevantTagNos.length > 0) { + const formEntryRecords = await tx + .select({ + id: formEntries.id, + data: formEntries.data, + }) + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, selectedPackageId), + eq(formEntries.formCode, formMapping.formCode) + ) + ) + + // 각 formEntry에 대해 처리 + for (const entry of formEntryRecords) { + let updatedJson = entry.data; + + // 각 tagNo에 대해 JSON 데이터에서 제거 + for (const tagNo of relevantTagNos) { + updatedJson = removeTagFromDataJson(updatedJson, tagNo); + } + + // 변경이 있다면 업데이트 + await tx + .update(formEntries) + .set({ data: updatedJson }) + .where(eq(formEntries.id, entry.id)) + } } } } - - // 3) 마지막으로 실제로 tags 테이블에서 Tag들을 삭제 - // (Tag → forms → formEntries 순서대로 처리) + + // 4) 마지막으로 tags 테이블에서 태그들 삭제 await tx.delete(tags).where(inArray(tags.id, ids)) }) - - // 4) 캐시 무효화 - // revalidateTag("tags") + + // 5) 캐시 무효화 revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}`) - + return { data: null, error: null } } catch (err) { return { data: null, error: getErrorMessage(err) } } } - // Updated service functions to support the new schema // 업데이트된 ClassOption 타입 diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx index 3814761d..e1e176cf 100644 --- a/lib/tags/table/add-tag-dialog.tsx +++ b/lib/tags/table/add-tag-dialog.tsx @@ -112,7 +112,6 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { const fieldIdsRef = React.useRef<Record<string, string>>({}) const classOptionIdsRef = React.useRef<Record<string, string>>({}) - console.log(subFields) // --------------- // Load Class Options @@ -296,6 +295,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { try { const res = await createTag(tagData, selectedPackageId); if ("error" in res) { + console.log(res.error ) failedTags.push({ tag: row.tagNo, error: res.error }); } else { successfulTags.push(row.tagNo); @@ -311,8 +311,9 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { } if (failedTags.length > 0) { + console.log("Failed tags:", failedTags); + toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); - console.error("Failed tags:", failedTags); } // Refresh the page diff --git a/lib/tasks/utils.ts b/lib/tasks/utils.ts index ea4425de..aaa1184c 100644 --- a/lib/tasks/utils.ts +++ b/lib/tasks/utils.ts @@ -1,18 +1,30 @@ import { tasks, type Task } from "@/db/schema/tasks" import { faker } from "@faker-js/faker" import { + Activity, + AlertCircle, + AlertTriangle, ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, AwardIcon, + BadgeCheck, CheckCircle2, CircleHelp, CircleIcon, CircleX, + ClipboardCheck, + ClipboardList, + FileCheck2, + FilePenLine, + FileX2, + MailCheck, PencilIcon, SearchIcon, SendIcon, Timer, + Trash2, + XCircle, } from "lucide-react" import { customAlphabet } from "nanoid" @@ -51,6 +63,7 @@ export function getStatusIcon(status: Task["status"]) { return statusIcons[status] || CircleIcon } + export function getRFQStatusIcon(status: Rfq["status"]) { const statusIcons = { DRAFT: PencilIcon, diff --git a/lib/utils.ts b/lib/utils.ts index 2eca9285..af9df057 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -28,10 +28,15 @@ export function formatDate( // Alternative: Create a separate function for date and time export function formatDateTime( - date: Date | string | number, + date: Date | string | number| null | undefined, locale: string = "en-US", opts: Intl.DateTimeFormatOptions = {} ) { + + if (date === null || date === undefined || date === '') { + return ''; // 또는 '-', 'N/A' 등 원하는 기본값 반환 + } + return new Intl.DateTimeFormat(locale, { month: opts.month ?? "long", day: opts.day ?? "numeric", diff --git a/lib/vendor-candidates/service.ts b/lib/vendor-candidates/service.ts new file mode 100644 index 00000000..68971f18 --- /dev/null +++ b/lib/vendor-candidates/service.ts @@ -0,0 +1,360 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { vendorCandidates} from "@/db/schema/vendors" +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; +import { revalidateTag, unstable_noStore } from "next/cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; +import db from "@/db/db"; +import { sendEmail } from "../mail/sendEmail"; +import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetVendorsCandidateSchema, RemoveCandidatesInput, removeCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function getVendorCandidates(input: GetVendorsCandidateSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // 1) Advanced filters + const advancedWhere = filterColumns({ + table: vendorCandidates, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // 2) Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(vendorCandidates.companyName, s), + ilike(vendorCandidates.contactEmail, s), + ilike(vendorCandidates.contactPhone, s), + ilike(vendorCandidates.country, s), + ilike(vendorCandidates.source, s), + ilike(vendorCandidates.status, s), + // etc. + ) + } + + // 3) Combine finalWhere + // Example: Only show vendorStatus = "PQ_SUBMITTED" + const finalWhere = and( + advancedWhere, + globalWhere, + ) + + + + // 5) Sorting + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(vendorCandidates[item.id]) + : asc(vendorCandidates[item.id]) + ) + : [desc(vendorCandidates.createdAt)] + + // 6) Query & count + const { data, total } = await db.transaction(async (tx) => { + // a) Select from the view + const candidatesData = await tx + .select() + .from(vendorCandidates) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(input.perPage) + + // b) Count total + const resCount = await tx + .select({ count: count() }) + .from(vendorCandidates) + .where(finalWhere) + + return { data: candidatesData, total: resCount[0]?.count } + }) + + // 7) Calculate pageCount + const pageCount = Math.ceil(total / input.perPage) + + // Now 'data' already contains JSON arrays of contacts & items + // thanks to the subqueries in the view definition! + return { data, pageCount } + } catch (err) { + console.error(err) + return { data: [], pageCount: 0 } + } + }, + // Cache key + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["vendor-candidates"], + } + )() +} + +export async function createVendorCandidate(input: CreateVendorCandidateSchema) { + try { + // Validate input + const validated = createVendorCandidateSchema.parse(input); + + // Insert into database + const [newCandidate] = await db + .insert(vendorCandidates) + .values({ + companyName: validated.companyName, + contactEmail: validated.contactEmail, + contactPhone: validated.contactPhone || null, + country: validated.country || null, + source: validated.source || null, + status: validated.status, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + // Invalidate cache + revalidateTag("vendor-candidates"); + + return { success: true, data: newCandidate }; + } catch (error) { + console.error("Failed to create vendor candidate:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + + +// Helper function to group vendor candidates by status +async function groupVendorCandidatesByStatus( tx: PgTransaction<any, any, any>,) { + return tx + .select({ + status: vendorCandidates.status, + count: count(), + }) + .from(vendorCandidates) + .groupBy(vendorCandidates.status); +} + +/** + * Get count of vendor candidates grouped by status + */ +export async function getVendorCandidateCounts() { + return unstable_cache( + async () => { + try { + // Initialize counts object with all possible statuses set to 0 + const initial: Record<"COLLECTED" | "INVITED" | "DISCARDED", number> = { + COLLECTED: 0, + INVITED: 0, + DISCARDED: 0, + }; + + // Execute query within transaction and transform results + const result = await db.transaction(async (tx) => { + const rows = await groupVendorCandidatesByStatus(tx); + return rows.reduce<Record<string, number>>((acc, { status, count }) => { + if (status in acc) { + acc[status] = count; + } + return acc; + }, initial); + }); + + return result; + } catch (err) { + console.error("Failed to get vendor candidate counts:", err); + return { + COLLECTED: 0, + INVITED: 0, + DISCARDED: 0, + }; + } + }, + ["vendor-candidate-status-counts"], // Cache key + { + revalidate: 3600, // Revalidate every hour + // tags: ["vendor-candidates"], // Use the same tag as other vendor candidate functions + } + )(); +} + + +/** + * Update a vendor candidate + */ +export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) { + try { + // Validate input + const validated = updateVendorCandidateSchema.parse(input); + + // Prepare update data (excluding id) + const { id, ...updateData } = validated; + + // Add updatedAt timestamp + const dataToUpdate = { + ...updateData, + updatedAt: new Date(), + }; + + // Update database + const [updatedCandidate] = await db + .update(vendorCandidates) + .set(dataToUpdate) + .where(eq(vendorCandidates.id, id)) + .returning(); + + // If status was updated to "INVITED", send email + if (validated.status === "INVITED" && updatedCandidate.contactEmail) { + await sendEmail({ + to: updatedCandidate.contactEmail, + subject: "Invitation to Register as a Vendor", + template: "vendor-invitation", + context: { + companyName: updatedCandidate.companyName, + language: "en", + registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`, + } + }); + } + + // Invalidate cache + revalidateTag("vendor-candidates"); + + return { success: true, data: updatedCandidate }; + } catch (error) { + console.error("Failed to update vendor candidate:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Update status of multiple vendor candidates at once + */ +export async function bulkUpdateVendorCandidateStatus({ + ids, + status +}: { + ids: number[], + status: "COLLECTED" | "INVITED" | "DISCARDED" +}) { + try { + // Validate inputs + if (!ids.length) { + return { success: false, error: "No IDs provided" }; + } + + if (!["COLLECTED", "INVITED", "DISCARDED"].includes(status)) { + return { success: false, error: "Invalid status" }; + } + + // Get current data of candidates (needed for email sending) + const candidatesBeforeUpdate = await db + .select() + .from(vendorCandidates) + .where(inArray(vendorCandidates.id, ids)); + + // Update all records + const updatedCandidates = await db + .update(vendorCandidates) + .set({ + status, + updatedAt: new Date(), + }) + .where(inArray(vendorCandidates.id, ids)) + .returning(); + + // If status is "INVITED", send emails to all updated candidates + if (status === "INVITED") { + const emailPromises = updatedCandidates + .filter(candidate => candidate.contactEmail) + .map(candidate => + sendEmail({ + to: candidate.contactEmail!, + subject: "Invitation to Register as a Vendor", + template: "vendor-invitation", + context: { + companyName: candidate.companyName, + language: "en", + registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`, + } + }) + ); + + // Wait for all emails to be sent + await Promise.all(emailPromises); + } + + // Invalidate cache + revalidateTag("vendor-candidates"); + + return { + success: true, + data: updatedCandidates, + count: updatedCandidates.length + }; + } catch (error) { + console.error("Failed to bulk update vendor candidates:", error); + return { success: false, error: getErrorMessage(error) }; + } +} + + + + +/** + * Remove multiple vendor candidates by their IDs + */ +export async function removeCandidates(input: RemoveCandidatesInput) { + try { + // Validate input + const validated = removeCandidatesSchema.parse(input); + + // Get candidates before deletion (for logging purposes) + const candidatesBeforeDelete = await db + .select({ + id: vendorCandidates.id, + companyName: vendorCandidates.companyName, + }) + .from(vendorCandidates) + .where(inArray(vendorCandidates.id, validated.ids)); + + // Delete the candidates + const deletedCandidates = await db + .delete(vendorCandidates) + .where(inArray(vendorCandidates.id, validated.ids)) + .returning({ id: vendorCandidates.id }); + + // If no candidates were deleted, return an error + if (!deletedCandidates.length) { + return { + success: false, + error: "No candidates were found with the provided IDs", + }; + } + + // Log deletion for audit purposes + console.log( + `Deleted ${deletedCandidates.length} vendor candidates:`, + candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`) + ); + + // Invalidate cache + revalidateTag("vendor-candidates"); + revalidateTag("vendor-candidate-status-counts"); + revalidateTag("vendor-candidate-total-count"); + + return { + success: true, + count: deletedCandidates.length, + deletedIds: deletedCandidates.map(c => c.id), + }; + } catch (error) { + console.error("Failed to remove vendor candidates:", error); + return { success: false, error: getErrorMessage(error) }; + } +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/add-candidates-dialog.tsx b/lib/vendor-candidates/table/add-candidates-dialog.tsx new file mode 100644 index 00000000..db475064 --- /dev/null +++ b/lib/vendor-candidates/table/add-candidates-dialog.tsx @@ -0,0 +1,327 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown } from "lucide-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 { cn } from "@/lib/utils" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" + +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + +// shadcn/ui Select +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations" +import { createVendorCandidate } from "../service" +import { vendorCandidates } from "@/db/schema/vendors" + +// Register locales for countries +i18nIsoCountries.registerLocale(enLocale) +i18nIsoCountries.registerLocale(koLocale) + +// Generate country array +const locale = "ko" +const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }) +const countryArray = Object.entries(countryMap).map(([code, label]) => ({ + code, + label, +})) + +export function AddCandidateDialog() { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm<CreateVendorCandidateSchema>({ + resolver: zodResolver(createVendorCandidateSchema), + defaultValues: { + companyName: "", + contactEmail: "", + contactPhone: "", + country: "", + source: "", + status: "COLLECTED", // Default status set to COLLECTED + }, + }) + + async function onSubmit(data: CreateVendorCandidateSchema) { + setIsSubmitting(true) + try { + const result = await createVendorCandidate(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } catch (error) { + console.error("Failed to create vendor candidate:", error) + alert("An unexpected error occurred") + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Vendor Candidate + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Create New Vendor Candidate</DialogTitle> + <DialogDescription> + 새 Vendor Candidate 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* Company Name 필드 */} + <FormField + control={form.control} + name="companyName" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + Company Name + </FormLabel> + <FormControl> + <Input + placeholder="Enter company name" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Email 필드 */} + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + Contact Email + </FormLabel> + <FormControl> + <Input + placeholder="email@example.com" + type="email" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Phone 필드 */} + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Phone</FormLabel> + <FormControl> + <Input + placeholder="+82-10-1234-5678" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Country 필드 */} + <FormField + control={form.control} + name="country" + render={({ field }) => { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + <FormItem> + <FormLabel>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 h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup className="max-h-[300px] overflow-y-auto"> + {countryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => + field.onChange(country.code) + } + > + <Check + className={cn( + "mr-2 h-4 w-4", + country.code === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + ) + }} + /> + + {/* Source 필드 */} + <FormField + control={form.control} + name="source" + render={({ field }) => ( + <FormItem> + <FormLabel>Source</FormLabel> + <FormControl> + <Input + placeholder="Where this candidate was found" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status 필드 */} + {/* <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={isSubmitting} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {vendorCandidates.status.enumValues.map((status) => ( + <SelectItem key={status} value={status}> + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/candidates-table-columns.tsx b/lib/vendor-candidates/table/candidates-table-columns.tsx new file mode 100644 index 00000000..dc014d4e --- /dev/null +++ b/lib/vendor-candidates/table/candidates-table-columns.tsx @@ -0,0 +1,193 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" +import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" +import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidates> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidates>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorCandidates> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorCandidates> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidates>[] } + const groupMap: Record<string, ColumnDef<VendorCandidates>[]> = {} + + candidateColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorCandidates> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeader column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + const Icon = getCandidateStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorCandidates>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx new file mode 100644 index 00000000..2696292d --- /dev/null +++ b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx @@ -0,0 +1,337 @@ +"use client" + +import * as React from "react" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, + Mail, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" +import { bulkUpdateVendorCandidateStatus, removeCandidates, updateVendorCandidate } from "../service" + +interface CandidatesTableFloatingBarProps { + table: Table<VendorCandidates> +} + +export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "export" | "delete" | "invite" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeCandidates({ + ids: rows.map((row) => row.original.id), + }) + if (error) { + toast.error(error) + return + } + toast.success("Users deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) 상태 업데이트 + function handleSelectStatus(newStatus: VendorCandidates["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} candidate${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: rows.map((row) => row.original.id), + status: newStatus, + }) + if (error) { + toast.error(error) + return + } + toast.success("Candidates updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 3) 초대하기 (INVITED 상태로 바꾸고 이메일 전송) + function handleInvite() { + setAction("invite") + setConfirmProps({ + title: `Invite ${rows.length} candidate${rows.length > 1 ? "s" : ""}?`, + description: "This will change their status to INVITED and send invitation emails.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: rows.map((row) => row.original.id), + status: "INVITED", + }) + if (error) { + toast.error(error) + return + } + toast.success("Invitation emails sent successfully") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + {/* 초대하기 버튼 (새로 추가) */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="sm" + className="h-7 border" + onClick={handleInvite} + disabled={isPending} + > + {isPending && action === "invite" ? ( + <Loader + className="mr-1 size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Mail className="mr-1 size-3.5" aria-hidden="true" /> + )} + <span>Invite</span> + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Send invitation emails</p> + </TooltipContent> + </Tooltip> + + <Select + onValueChange={(value: VendorCandidates["status"]) => { + handleSelectStatus(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {vendorCandidates.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export candidates</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete candidates</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-status" || action === "invite")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : action === "invite" + ? "Invite" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx new file mode 100644 index 00000000..a2229a54 --- /dev/null +++ b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileDown, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { AddCandidateDialog } from "./add-candidates-dialog" +import { VendorCandidates } from "@/db/schema/vendors" +import { DeleteCandidatesDialog } from "./delete-candidates-dialog" +import { InviteCandidatesDialog } from "./invite-candidates-dialog" +import { ImportVendorCandidatesButton } from "./import-button" +import { exportVendorCandidateTemplate } from "./excel-template-download" + + +interface CandidatesTableToolbarActionsProps { + table: Table<VendorCandidates> +} + +export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const hasSelection = selectedRows.length > 0 + const [refreshKey, setRefreshKey] = React.useState(0) + + // Handler to refresh the table after import + const handleImportSuccess = () => { + // Trigger a refresh of the table data + setRefreshKey(prev => prev + 1) + } + + return ( + <div className="flex items-center gap-2"> + {/* Show actions only when rows are selected */} + {hasSelection ? ( + <> + {/* Invite dialog - new addition */} + <InviteCandidatesDialog + candidates={selectedRows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + + {/* Delete dialog */} + <DeleteCandidatesDialog + candidates={selectedRows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + </> + ) : null} + + {/* Add new candidate dialog */} + <AddCandidateDialog /> + + {/* Import Excel button */} + <ImportVendorCandidatesButton onSuccess={handleImportSuccess} /> + + {/* Export dropdown menu */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => { + exportTableToExcel(table, { + filename: "vendor-candidates", + excludeColumns: ["select", "actions"], + useGroupHeader: false, + }) + }} + > + <FileDown className="mr-2 h-4 w-4" /> + <span>Export Current Data</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={exportVendorCandidateTemplate}> + <FileDown className="mr-2 h-4 w-4" /> + <span>Download Template</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/candidates-table.tsx b/lib/vendor-candidates/table/candidates-table.tsx new file mode 100644 index 00000000..2c01733c --- /dev/null +++ b/lib/vendor-candidates/table/candidates-table.tsx @@ -0,0 +1,173 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import { useFeatureFlags } from "./feature-flags-provider" +import { getVendorCandidateCounts, getVendorCandidates } from "../service" +import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" +import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar" +import { getColumns } from "./candidates-table-columns" +import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions" +import { DeleteCandidatesDialog } from "./delete-candidates-dialog" +import { UpdateCandidateSheet } from "./update-candidate-sheet" +import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" + +interface VendorCandidatesTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorCandidates>>, + Awaited<ReturnType<typeof getVendorCandidateCounts>>, + ] + > +} + +export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }, statusCounts] = + React.use(promises) + + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<VendorCandidates> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<VendorCandidates>[] = [ + + { + id: "status", + label: "Status", + options: vendorCandidates.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + count: statusCounts[status], + })), + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidates>[] = [ + { + id: "companyName", + label: "Company Name", + type: "text", + }, + { + id: "contactEmail", + label: "Contact Email", + type: "text", + }, + { + id: "contactPhone", + label: "Contact Phone", + type: "text", + }, + { + id: "source", + label: "source", + type: "text", + }, + { + id: "status", + label: "Status", + type: "multi-select", + options: vendorCandidates.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getCandidateStatusIcon(status), + count: statusCounts[status], + })), + }, + + { + id: "createdAt", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<VendorCandidateTableFloatingBar table={table} />} + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <CandidatesTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <UpdateCandidateSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + candidate={rowAction?.row.original ?? null} + /> + <DeleteCandidatesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + candidates={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +} diff --git a/lib/vendor-candidates/table/delete-candidates-dialog.tsx b/lib/vendor-candidates/table/delete-candidates-dialog.tsx new file mode 100644 index 00000000..e9fabf76 --- /dev/null +++ b/lib/vendor-candidates/table/delete-candidates-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { VendorCandidates } from "@/db/schema/vendors" +import { removeCandidates } from "../service" + +interface DeleteCandidatesDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + candidates: Row<VendorCandidates>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteCandidatesDialog({ + candidates, + showTrigger = true, + onSuccess, + ...props +}: DeleteCandidatesDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeCandidates({ + ids: candidates.map((candidate) => candidate.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Candidates deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({candidates.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{candidates.length}</span> + {candidates.length === 1 ? " candidate" : " candidates"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({candidates.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{candidates.length}</span> + {candidates.length === 1 ? " candidate" : " candidates"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/vendor-candidates/table/excel-template-download.tsx b/lib/vendor-candidates/table/excel-template-download.tsx new file mode 100644 index 00000000..b69ab821 --- /dev/null +++ b/lib/vendor-candidates/table/excel-template-download.tsx @@ -0,0 +1,94 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { VendorCandidates } from "@/db/schema/vendors" + +/** + * Export an empty template for vendor candidates with column headers + * matching the expected import format + */ +export async function exportVendorCandidateTemplate() { + // Create a new workbook and worksheet + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Vendor Candidates") + + // Define the columns with expected headers + const columns = [ + { header: "Company Name", key: "companyName", width: 30 }, + { header: "Contact Email", key: "contactEmail", width: 30 }, + { header: "Contact Phone", key: "contactPhone", width: 20 }, + { header: "Country", key: "country", width: 20 }, + { header: "Source", key: "source", width: 20 }, + { header: "Status", key: "status", width: 15 }, + ] + + // Add columns to the worksheet + worksheet.columns = columns + + // Style the header row + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.alignment = { horizontal: "center" } + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + + // Add example data rows + const exampleData = [ + { + companyName: "ABC Corporation", + contactEmail: "contact@abc.com", + contactPhone: "+1-123-456-7890", + country: "US", + source: "Website", + status: "COLLECTED", + }, + { + companyName: "XYZ Ltd.", + contactEmail: "info@xyz.com", + contactPhone: "+44-987-654-3210", + country: "GB", + source: "Referral", + status: "COLLECTED", + }, + ] + + // Add the example rows to the worksheet + exampleData.forEach((data) => { + worksheet.addRow(data) + }) + + // Add data validation for Status column + const statusValues = ["COLLECTED", "INVITED", "DISCARDED"] + for (let i = 2; i <= 100; i++) { // Apply to rows 2-100 + worksheet.getCell(`F${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: [`"${statusValues.join(',')}"`] + } + } + + // Add instructions row + worksheet.insertRow(1, ["Please fill in the data below. Required fields: Company Name, Contact Email"]) + worksheet.mergeCells("A1:F1") + const instructionRow = worksheet.getRow(1) + instructionRow.font = { bold: true, color: { argb: "FF0000FF" } } + instructionRow.alignment = { horizontal: "center" } + + // Download the workbook + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = "vendor-candidates-template.xlsx" + link.click() + URL.revokeObjectURL(url) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/feature-flags-provider.tsx b/lib/vendor-candidates/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-candidates/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendor-candidates/table/feature-flags.tsx b/lib/vendor-candidates/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/vendor-candidates/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + <TasksTableContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </TasksTableContext.Provider> + ) +} diff --git a/lib/vendor-candidates/table/import-button.tsx b/lib/vendor-candidates/table/import-button.tsx new file mode 100644 index 00000000..1a2a4f7c --- /dev/null +++ b/lib/vendor-candidates/table/import-button.tsx @@ -0,0 +1,211 @@ +"use client" + +import React, { useRef } from 'react' +import ExcelJS from 'exceljs' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Upload, Loader } from 'lucide-react' +import { createVendorCandidate } from '../service' +import { Input } from '@/components/ui/input' + +interface ImportExcelProps { + onSuccess?: () => void +} + +export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { + const fileInputRef = useRef<HTMLInputElement>(null) + const [isImporting, setIsImporting] = React.useState(false) + + // Helper function to get cell value as string + const getCellValueAsString = (cell: ExcelJS.Cell): string => { + if (!cell || cell.value === undefined || cell.value === null) return ''; + + if (typeof cell.value === 'string') return cell.value.trim(); + if (typeof cell.value === 'number') return cell.value.toString(); + + // Handle rich text + if (typeof cell.value === 'object' && 'richText' in cell.value) { + return cell.value.richText.map((rt: any) => rt.text).join(''); + } + + // Handle dates + if (cell.value instanceof Date) { + return cell.value.toISOString().split('T')[0]; + } + + // Fallback + return String(cell.value); + } + + const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (!file) return + + setIsImporting(true) + + try { + // Read the Excel file using ExcelJS + const data = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(data) + + // Get the first worksheet + const worksheet = workbook.getWorksheet(1) + if (!worksheet) { + toast.error("No worksheet found in the spreadsheet") + return + } + + // Check if there's an instruction row + const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null && + worksheet.getRow(1).getCell(2).value === null; + + // Get header row index (row 2 if there's an instruction row, otherwise row 1) + const headerRowIndex = hasInstructionRow ? 2 : 1; + + // Get column headers and their indices + const headerRow = worksheet.getRow(headerRowIndex); + const headers: Record<number, string> = {}; + const columnIndices: Record<string, number> = {}; + + headerRow.eachCell((cell, colNumber) => { + const header = getCellValueAsString(cell); + headers[colNumber] = header; + columnIndices[header] = colNumber; + }); + + // Process data rows + const rows: any[] = []; + const startRow = headerRowIndex + 1; + + for (let i = startRow; i <= worksheet.rowCount; i++) { + const row = worksheet.getRow(i); + + // Skip empty rows + if (row.cellCount === 0) continue; + + // Check if this is likely an example row + const isExample = i === startRow && worksheet.getRow(i+1).values?.length === 0; + if (isExample) continue; + + const rowData: Record<string, any> = {}; + let hasData = false; + + // Map the data using header indices + Object.entries(columnIndices).forEach(([header, colIndex]) => { + const value = getCellValueAsString(row.getCell(colIndex)); + if (value) { + rowData[header] = value; + hasData = true; + } + }); + + if (hasData) { + rows.push(rowData); + } + } + + if (rows.length === 0) { + toast.error("No data found in the spreadsheet") + setIsImporting(false) + return + } + + // Process each row + let successCount = 0; + let errorCount = 0; + + // Create promises for all vendor candidate creation operations + const promises = rows.map(async (row) => { + try { + // Map Excel columns to our data model + const candidateData = { + companyName: String(row['Company Name'] || ''), + contactEmail: String(row['Contact Email'] || ''), + contactPhone: String(row['Contact Phone'] || ''), + country: String(row['Country'] || ''), + source: String(row['Source'] || ''), + // Default to COLLECTED if not specified + status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED" + }; + + // Validate required fields + if (!candidateData.companyName || !candidateData.contactEmail) { + console.error("Missing required fields", candidateData); + errorCount++; + return null; + } + + // Create the vendor candidate + const result = await createVendorCandidate(candidateData); + + if (result.error) { + console.error(`Failed to import row: ${result.error}`, candidateData); + errorCount++; + return null; + } + + successCount++; + return result.data; + } catch (error) { + console.error("Error processing row:", error, row); + errorCount++; + return null; + } + }); + + // Wait for all operations to complete + await Promise.all(promises); + + // Show results + if (successCount > 0) { + toast.success(`Successfully imported ${successCount} vendor candidates`); + if (errorCount > 0) { + toast.warning(`Failed to import ${errorCount} rows due to errors`); + } + // Call the success callback to refresh data + onSuccess?.(); + } else if (errorCount > 0) { + toast.error(`Failed to import all ${errorCount} rows due to errors`); + } + + } catch (error) { + console.error("Import error:", error); + toast.error("Error importing data. Please check file format."); + } finally { + setIsImporting(false); + // Reset the file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + } + + return ( + <> + <Input + type="file" + ref={fileInputRef} + onChange={handleImport} + accept=".xlsx,.xls" + className="hidden" + /> + <Button + variant="outline" + size="sm" + onClick={() => fileInputRef.current?.click()} + disabled={isImporting} + className="gap-2" + > + {isImporting ? ( + <Loader className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" aria-hidden="true" /> + )} + <span className="hidden sm:inline"> + {isImporting ? "Importing..." : "Import"} + </span> + </Button> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/invite-candidates-dialog.tsx b/lib/vendor-candidates/table/invite-candidates-dialog.tsx new file mode 100644 index 00000000..366b6f45 --- /dev/null +++ b/lib/vendor-candidates/table/invite-candidates-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Mail } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { VendorCandidates } from "@/db/schema/vendors" +import { bulkUpdateVendorCandidateStatus } from "../service" + +interface InviteCandidatesDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + candidates: Row<VendorCandidates>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteCandidatesDialog({ + candidates, + showTrigger = true, + onSuccess, + ...props +}: InviteCandidatesDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onInvite() { + startInviteTransition(async () => { + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: candidates.map((candidate) => candidate.id), + status: "INVITED", + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Invitation emails sent") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Mail className="size-4" aria-hidden="true" /> + Invite ({candidates.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Send invitations?</DialogTitle> + <DialogDescription> + This will send invitation emails to{" "} + <span className="font-medium">{candidates.length}</span> + {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Invite selected vendors" + variant="default" + onClick={onInvite} + disabled={isInvitePending} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Send Invitations + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Mail className="size-4" aria-hidden="true" /> + Invite ({candidates.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Send invitations?</DrawerTitle> + <DrawerDescription> + This will send invitation emails to{" "} + <span className="font-medium">{candidates.length}</span> + {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Invite selected vendors" + variant="default" + onClick={onInvite} + disabled={isInvitePending} + > + {isInvitePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Send Invitations + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/table/update-candidate-sheet.tsx b/lib/vendor-candidates/table/update-candidate-sheet.tsx new file mode 100644 index 00000000..c475210b --- /dev/null +++ b/lib/vendor-candidates/table/update-candidate-sheet.tsx @@ -0,0 +1,339 @@ +"use client" + +import * as React from "react" +import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +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 { cn } from "@/lib/utils" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" + +import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations" +import { updateVendorCandidate } from "../service" + +// Register locales for countries +i18nIsoCountries.registerLocale(enLocale) +i18nIsoCountries.registerLocale(koLocale) + +// Generate country array +const locale = "ko" +const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }) +const countryArray = Object.entries(countryMap).map(([code, label]) => ({ + code, + label, +})) + +interface UpdateCandidateSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + candidate: VendorCandidates | null +} + +export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + // Set default values from candidate data when the component receives a new candidate + React.useEffect(() => { + if (candidate) { + form.reset({ + id: candidate.id, + companyName: candidate.companyName, + contactEmail: candidate.contactEmail, + contactPhone: candidate.contactPhone || "", + country: candidate.country || "", + source: candidate.source || "", + status: candidate.status, + }) + } + }, [candidate]) + + const form = useForm<UpdateVendorCandidateSchema>({ + resolver: zodResolver(updateVendorCandidateSchema), + defaultValues: { + id: candidate?.id || 0, + companyName: candidate?.companyName || "", + contactEmail: candidate?.contactEmail || "", + contactPhone: candidate?.contactPhone || "", + country: candidate?.country || "", + source: candidate?.source || "", + status: candidate?.status || "COLLECTED", + }, + }) + + function onSubmit(input: UpdateVendorCandidateSchema) { + startUpdateTransition(async () => { + if (!candidate) return + + const { error } = await updateVendorCandidate({ + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Vendor candidate updated") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update Vendor Candidate</SheetTitle> + <SheetDescription> + Update the vendor candidate details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* Company Name Field */} + <FormField + control={form.control} + name="companyName" + render={({ field }) => ( + <FormItem> + <FormLabel>Company Name</FormLabel> + <FormControl> + <Input + placeholder="Enter company name" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Email Field */} + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Email</FormLabel> + <FormControl> + <Input + placeholder="email@example.com" + type="email" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Contact Phone Field */} + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Phone</FormLabel> + <FormControl> + <Input + placeholder="+82-10-1234-5678" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Country Field */} + <FormField + control={form.control} + name="country" + render={({ field }) => { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + <FormItem> + <FormLabel>Country</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + disabled={isUpdatePending} + > + {selectedCountry + ? selectedCountry.label + : "Select a country"} + <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup className="max-h-[300px] overflow-y-auto"> + {countryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => + field.onChange(country.code) + } + > + <Check + className={cn( + "mr-2 h-4 w-4", + country.code === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + ) + }} + /> + + {/* Source Field */} + <FormField + control={form.control} + name="source" + render={({ field }) => ( + <FormItem> + <FormLabel>Source</FormLabel> + <FormControl> + <Input + placeholder="Where this candidate was found" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status Field */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={isUpdatePending} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {vendorCandidates.status.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline" disabled={isUpdatePending}> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/utils.ts b/lib/vendor-candidates/utils.ts new file mode 100644 index 00000000..8973d557 --- /dev/null +++ b/lib/vendor-candidates/utils.ts @@ -0,0 +1,40 @@ +import { + Activity, + AlertCircle, + AlertTriangle, + ArrowDownIcon, + ArrowRightIcon, + ArrowUpIcon, + AwardIcon, + BadgeCheck, + CheckCircle2, + CircleHelp, + CircleIcon, + CircleX, + ClipboardCheck, + ClipboardList, + FileCheck2, + FilePenLine, + FileX2, + MailCheck, + PencilIcon, + SearchIcon, + SendIcon, + Timer, + Trash2, + XCircle, +} from "lucide-react" + +import { VendorCandidates } from "@/db/schema/vendors" + + +export function getCandidateStatusIcon(status: VendorCandidates["status"]) { + const statusIcons = { + COLLECTED: ClipboardList, // Data collection icon + INVITED: MailCheck, // Email sent and checked icon + DISCARDED: Trash2, // Trashed/discarded icon + } + + return statusIcons[status] || CircleIcon +} + diff --git a/lib/vendor-candidates/validations.ts b/lib/vendor-candidates/validations.ts new file mode 100644 index 00000000..0abb568e --- /dev/null +++ b/lib/vendor-candidates/validations.ts @@ -0,0 +1,84 @@ +import { vendorCandidates } from "@/db/schema/vendors" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsCandidateCache = createSearchParamsCache({ + // Common flags + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // Paging + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // Sorting - adjusting for vendorInvestigationsView + sort: getSortingStateParser<typeof vendorCandidates.$inferSelect>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // Advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // Global search + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // Fields specific to vendor investigations + // ----------------------------------------------------------------- + + // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED + status: parseAsStringEnum(["COLLECTED", "INVITED", "DISCARDED"]), + + // In case you also want to filter by vendorName, vendorCode, etc. + companyName: parseAsString.withDefault(""), + contactEmail: parseAsString.withDefault(""), + contactPhone: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + source: parseAsString.withDefault(""), + + +}) + +// Finally, export the type you can use in your server action: +export type GetVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsCandidateCache.parse>> + + +// Updated version of the updateVendorCandidateSchema +export const updateVendorCandidateSchema = z.object({ + id: z.number(), + companyName: z.string().min(1).max(255).optional(), + contactEmail: z.string().email().max(255).optional(), + contactPhone: z.string().max(50).optional(), + country: z.string().max(100).optional(), + source: z.string().max(100).optional(), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(), + updatedAt: z.date().optional().default(() => new Date()), +}); + +// Create schema for vendor candidates +export const createVendorCandidateSchema = z.object({ + companyName: z.string().min(1).max(255), + contactEmail: z.string().email().max(255), + contactPhone: z.string().max(50).optional(), + country: z.string().max(100).optional(), + source: z.string().max(100).optional(), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"), +}); + +// Export types for both schemas +export type UpdateVendorCandidateSchema = z.infer<typeof updateVendorCandidateSchema>; +export type CreateVendorCandidateSchema = z.infer<typeof createVendorCandidateSchema>; + + +export const removeCandidatesSchema = z.object({ + ids: z.array(z.number()).min(1, "At least one candidate ID must be provided"), +}); + +export type RemoveCandidatesInput = z.infer<typeof removeCandidatesSchema>;
\ No newline at end of file diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts index b14a64e0..c0a30808 100644 --- a/lib/vendor-document/service.ts +++ b/lib/vendor-document/service.ts @@ -3,7 +3,6 @@ import { eq, SQL } from "drizzle-orm" import db from "@/db/db" import { documentAttachments, documents, issueStages, revisions, vendorDocumentsView } from "@/db/schema/vendorDocu" -import { contracts } from "@/db/schema/vendorData" import { GetVendorDcoumentsSchema } from "./validations" import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts new file mode 100644 index 00000000..b731a95c --- /dev/null +++ b/lib/vendor-investigation/service.ts @@ -0,0 +1,229 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView } from "@/db/schema/vendors" +import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations" +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; +import { revalidateTag, unstable_noStore } from "next/cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; +import db from "@/db/db"; +import { sendEmail } from "../mail/sendEmail"; +import fs from "fs" +import path from "path" +import { v4 as uuid } from "uuid" + +export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // 1) Advanced filters + const advancedWhere = filterColumns({ + table: vendorInvestigationsView, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // 2) Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(vendorInvestigationsView.vendorName, s), + ilike(vendorInvestigationsView.vendorCode, s), + ilike(vendorInvestigationsView.investigationNotes, s), + ilike(vendorInvestigationsView.vendorEmail, s) + // etc. + ) + } + + // 3) Combine finalWhere + // Example: Only show vendorStatus = "PQ_SUBMITTED" + const finalWhere = and( + advancedWhere, + globalWhere, + eq(vendorInvestigationsView.vendorStatus, "PQ_SUBMITTED") + ) + + + + // 5) Sorting + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(vendorInvestigationsView[item.id]) + : asc(vendorInvestigationsView[item.id]) + ) + : [desc(vendorInvestigationsView.investigationCreatedAt)] + + // 6) Query & count + const { data, total } = await db.transaction(async (tx) => { + // a) Select from the view + const investigationsData = await tx + .select() + .from(vendorInvestigationsView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(input.perPage) + + // b) Count total + const resCount = await tx + .select({ count: count() }) + .from(vendorInvestigationsView) + .where(finalWhere) + + return { data: investigationsData, total: resCount[0]?.count } + }) + + // 7) Calculate pageCount + const pageCount = Math.ceil(total / input.perPage) + + // Now 'data' already contains JSON arrays of contacts & items + // thanks to the subqueries in the view definition! + return { data, pageCount } + } catch (err) { + console.error(err) + return { data: [], pageCount: 0 } + } + }, + // Cache key + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["vendors-in-investigation"], + } + )() +} + + +interface RequestInvestigateVendorsInput { + ids: number[] +} + +export async function requestInvestigateVendors({ + ids, +}: RequestInvestigateVendorsInput) { + try { + if (!ids || ids.length === 0) { + return { error: "No vendor IDs provided." } + } + + // 1. Create a new investigation row for each vendor + // You could also check if an investigation already exists for each vendor + // before inserting. For now, we’ll assume we always insert new ones. + const newRecords = await db + .insert(vendorInvestigations) + .values( + ids.map((vendorId) => ({ + vendorId + })) + ) + .returning() + + // 2. Optionally, send an email notification + // Adjust recipient, subject, and body as needed. + await sendEmail({ + to: "dujin.kim@dtsolution.io", + subject: "New Vendor Investigation(s) Requested", + // This template name could match a Handlebars file like: `investigation-request.hbs` + template: "investigation-request", + context: { + // For example, if you're translating in Korean: + language: "ko", + // Add any data you want to use within the template + vendorIds: ids, + notes: "Please initiate the planned investigations soon." + }, + }) + + // 3. Optionally, revalidate any pages that might show updated data + // revalidatePath("/your-vendors-page") // or wherever you list the vendors + + return { data: newRecords, error: null } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err) + return { error: errorMessage } + } +} + + +export async function updateVendorInvestigationAction(formData: FormData) { + try { + // 1) Separate text fields from file fields + const textEntries: Record<string, string> = {} + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + textEntries[key] = value + } + } + + // 2) Convert text-based "investigationId" to a number + if (textEntries.investigationId) { + textEntries.investigationId = String(Number(textEntries.investigationId)) + } + + // 3) Parse/validate with Zod + const parsed = updateVendorInvestigationSchema.parse(textEntries) + // parsed is type UpdateVendorInvestigationSchema + + // 4) Update the vendor_investigations table + await db + .update(vendorInvestigations) + .set({ + investigationStatus: parsed.investigationStatus, + scheduledStartAt: parsed.scheduledStartAt + ? new Date(parsed.scheduledStartAt) + : null, + scheduledEndAt: parsed.scheduledEndAt ? new Date(parsed.scheduledEndAt) : null, + completedAt: parsed.completedAt ? new Date(parsed.completedAt) : null, + investigationNotes: parsed.investigationNotes ?? "", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + + // 5) Handle file attachments + // formData.getAll("attachments") can contain multiple files + const files = formData.getAll("attachments") as File[] + + // Make sure the folder exists + const uploadDir = path.join(process.cwd(), "public", "vendor-investigation") + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + + for (const file of files) { + if (file && file.size > 0) { + // Create a unique filename + const ext = path.extname(file.name) // e.g. ".pdf" + const newFileName = `${uuid()}${ext}` + + const filePath = path.join(uploadDir, newFileName) + + // 6) Write file to disk + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + fs.writeFileSync(filePath, buffer) + + // 7) Insert a record in vendor_investigation_attachments + await db.insert(vendorInvestigationAttachments).values({ + investigationId: parsed.investigationId, + fileName: file.name, // original name + filePath: `/vendor-investigation/${newFileName}`, // relative path in public/ + attachmentType: "REPORT", // or user-specified + }) + } + } + + // Revalidate anything if needed + revalidateTag("vendors-in-investigation") + + return { data: "OK", error: null } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + return { error: message } + } +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/feature-flags-provider.tsx b/lib/vendor-investigation/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-investigation/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx new file mode 100644 index 00000000..fd76a9a5 --- /dev/null +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -0,0 +1,251 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Ellipsis, Users, Boxes } from "lucide-react" +// import { toast } from "sonner" // If needed +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" // or your date util + +// Example: If you have a type for row actions +import { type DataTableRowAction } from "@/types/table" +import { ContactItem, PossibleItem, vendorInvestigationsColumnsConfig, VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + +// Props that define how we handle special columns (contacts, items, actions, etc.) +interface GetVendorInvestigationsColumnsProps { + setRowAction?: React.Dispatch< + React.SetStateAction< + DataTableRowAction<VendorInvestigationsViewWithContacts> | null + > + > + openContactsModal?: (investigationId: number, contacts: ContactItem[]) => void + openItemsDrawer?: (investigationId: number, items: PossibleItem[]) => void +} + +// This function returns the array of columns for TanStack Table +export function getColumns({ + setRowAction, + openContactsModal, + openItemsDrawer, +}: GetVendorInvestigationsColumnsProps): ColumnDef< + VendorInvestigationsViewWithContacts +>[] { + // -------------------------------------------- + // 1) Select (checkbox) column + // -------------------------------------------- + const selectColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // -------------------------------------------- + // 2) Actions column (optional) + // -------------------------------------------- + const actionsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const inv = row.original + + return ( + <Button + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + aria-label="Open menu" + onClick={() => { + // e.g. open a dropdown or set your row action + setRowAction?.({ type: "update", row }) + }} + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + ) + }, + size: 40, + } + + // -------------------------------------------- + // 3) Contacts column (badge count -> open modal) + // -------------------------------------------- + const contactsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "contacts", + header: "Contacts", + cell: ({ row }) => { + const { contacts, investigationId } = row.original + const count = contacts?.length ?? 0 + + const handleClick = () => { + openContactsModal?.(investigationId, contacts) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + count > 0 ? `View ${count} contacts` : "Add contacts" + } + > + <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + <span className="sr-only"> + {count > 0 ? `${count} Contacts` : "Add Contacts"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // -------------------------------------------- + // 4) Possible Items column (badge count -> open drawer) + // -------------------------------------------- + const possibleItemsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "possibleItems", + header: "Items", + cell: ({ row }) => { + const { possibleItems, investigationId } = row.original + const count = possibleItems?.length ?? 0 + + const handleClick = () => { + openItemsDrawer?.(investigationId, possibleItems) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + count > 0 ? `View ${count} items` : "Add items" + } + > + <Boxes className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + <span className="sr-only"> + {count > 0 ? `${count} Items` : "Add Items"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // -------------------------------------------- + // 5) Build "grouped" columns from config + // -------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorInvestigationsViewWithContacts>[]> = {} + + vendorInvestigationsColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + const childCol: ColumnDef<VendorInvestigationsViewWithContacts> = { + accessorKey: cfg.id, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + const val = cell.getValue() + + // Example: Format date fields + if ( + cfg.id === "investigationCreatedAt" || + cfg.id === "investigationUpdatedAt" || + cfg.id === "scheduledStartAt" || + cfg.id === "scheduledEndAt" || + cfg.id === "completedAt" + ) { + const dateVal = val ? new Date(val as string) : null + return dateVal ? formatDate(dateVal) : "" + } + + // Example: You could show an icon for "investigationStatus" + if (cfg.id === "investigationStatus") { + return <span className="capitalize">{val as string}</span> + } + + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // Turn the groupMap into nested columns + const nestedColumns: ColumnDef<VendorInvestigationsViewWithContacts>[] = [] + for (const [groupName, colDefs] of Object.entries(groupMap)) { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + } + + // -------------------------------------------- + // 6) Return final columns array + // (You can reorder these as you wish.) + // -------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + contactsColumn, + possibleItemsColumn, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx new file mode 100644 index 00000000..9f89a6ac --- /dev/null +++ b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx @@ -0,0 +1,41 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Check } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + + +interface VendorsTableToolbarActionsProps { + table: Table<VendorInvestigationsViewWithContacts> +} + +export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( + <div className="flex items-center gap-2"> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx new file mode 100644 index 00000000..fa4e2ab8 --- /dev/null +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -0,0 +1,133 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./investigation-table-columns" +import { getVendorsInvestigation } from "../service" +import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions" +import { + VendorInvestigationsViewWithContacts, + ContactItem, + PossibleItem +} from "@/config/vendorInvestigationsColumnsConfig" +import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorsInvestigation>>, + ] + > +} + +export function VendorsInvestigationTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Get data from Suspense + const [rawResponse] = React.use(promises) + + // Transform the data to match the expected types + const transformedData: VendorInvestigationsViewWithContacts[] = React.useMemo(() => { + return rawResponse.data.map(item => { + // Parse contacts field if it's a string + let contacts: ContactItem[] = [] + if (typeof item.contacts === 'string') { + try { + contacts = JSON.parse(item.contacts) as ContactItem[] + } catch (e) { + console.error('Failed to parse contacts:', e) + } + } else if (Array.isArray(item.contacts)) { + contacts = item.contacts + } + + // Parse possibleItems field if it's a string + let possibleItems: PossibleItem[] = [] + if (typeof item.possibleItems === 'string') { + try { + possibleItems = JSON.parse(item.possibleItems) as PossibleItem[] + } catch (e) { + console.error('Failed to parse possibleItems:', e) + } + } else if (Array.isArray(item.possibleItems)) { + possibleItems = item.possibleItems + } + + // Return a new object with the transformed fields + return { + ...item, + contacts, + possibleItems + } as VendorInvestigationsViewWithContacts + }) + }, [rawResponse.data]) + + const pageCount = rawResponse.pageCount + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null) + + // Get router + const router = useRouter() + + // Call getColumns() with router injection + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [ + { id: "vendorCode", label: "Vendor Code" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorInvestigationsViewWithContacts>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + ] + + const { table } = useDataTable({ + data: transformedData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "investigationCreatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.investigationId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + <UpdateVendorInvestigationSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + investigation={rowAction?.row.original ?? null} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx new file mode 100644 index 00000000..fe30c892 --- /dev/null +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -0,0 +1,324 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Loader } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { + updateVendorInvestigationSchema, + type UpdateVendorInvestigationSchema, +} from "../validations" +import { updateVendorInvestigationAction } from "../service" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + +/** + * The shape of `vendorInvestigation` + * might come from your `vendorInvestigationsView` row + * or your existing type for a single investigation. + */ + +interface UpdateVendorInvestigationSheetProps + extends React.ComponentPropsWithoutRef<typeof Sheet> { + investigation: VendorInvestigationsViewWithContacts | null +} + +/** + * A sheet for updating a vendor investigation (plus optional attachments). + */ +export function UpdateVendorInvestigationSheet({ + investigation, + ...props +}: UpdateVendorInvestigationSheetProps) { + const [isPending, startTransition] = React.useTransition() + + // RHF + Zod + const form = useForm<UpdateVendorInvestigationSchema>({ + resolver: zodResolver(updateVendorInvestigationSchema), + defaultValues: { + investigationId: investigation?.investigationId ?? 0, + investigationStatus: investigation?.investigationStatus ?? "PLANNED", + scheduledStartAt: investigation?.scheduledStartAt ?? undefined, + scheduledEndAt: investigation?.scheduledEndAt ?? undefined, + completedAt: investigation?.completedAt ?? undefined, + investigationNotes: investigation?.investigationNotes ?? "", + }, + }) + + React.useEffect(() => { + if (investigation) { + form.reset({ + investigationId: investigation.investigationId, + investigationStatus: investigation.investigationStatus || "PLANNED", + scheduledStartAt: investigation.scheduledStartAt ?? undefined, + scheduledEndAt: investigation.scheduledEndAt ?? undefined, + completedAt: investigation.completedAt ?? undefined, + investigationNotes: investigation.investigationNotes ?? "", + }) + } + }, [investigation, form]) + + // Format date for form data + const formatDateForFormData = (date: Date | undefined): string | null => { + if (!date) return null; + return date.toISOString(); + } + + // Submit handler + async function onSubmit(values: UpdateVendorInvestigationSchema) { + if (!values.investigationId) return + + startTransition(async () => { + // 1) Build a FormData object for the server action + const formData = new FormData() + + // Add text fields + formData.append("investigationId", String(values.investigationId)) + formData.append("investigationStatus", values.investigationStatus) + + // Format dates properly before appending to FormData + if (values.scheduledStartAt) { + const formattedDate = formatDateForFormData(values.scheduledStartAt) + if (formattedDate) formData.append("scheduledStartAt", formattedDate) + } + + if (values.scheduledEndAt) { + const formattedDate = formatDateForFormData(values.scheduledEndAt) + if (formattedDate) formData.append("scheduledEndAt", formattedDate) + } + + if (values.completedAt) { + const formattedDate = formatDateForFormData(values.completedAt) + if (formattedDate) formData.append("completedAt", formattedDate) + } + + if (values.investigationNotes) { + formData.append("investigationNotes", values.investigationNotes) + } + + // Add attachments (if any) + // Note: If you have multiple files in "attachments", we store them in the form under the same key. + const attachmentValue = form.getValues("attachments"); + if (attachmentValue instanceof FileList) { + for (let i = 0; i < attachmentValue.length; i++) { + formData.append("attachments", attachmentValue[i]); + } + } + + const { error } = await updateVendorInvestigationAction(formData) + if (error) { + toast.error(error) + return + } + + toast.success("Investigation updated!") + form.reset() + props.onOpenChange?.(false) + }) + } + + // Format date value for input field + const formatDateForInput = (date: Date | undefined): string => { + if (!date) return ""; + return date instanceof Date ? date.toISOString().slice(0, 10) : ""; + } + + // Handle date input change + const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>, onChange: (...event: any[]) => void) => { + const val = e.target.value; + if (val) { + // Ensure proper date handling by setting to noon to avoid timezone issues + const newDate = new Date(`${val}T12:00:00`); + onChange(newDate); + } else { + onChange(undefined); + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update Investigation</SheetTitle> + <SheetDescription> + Change the investigation details & attachments + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + // Must use multipart to support file uploads + encType="multipart/form-data" + > + {/* investigationStatus */} + <FormField + control={form.control} + name="investigationStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PLANNED">PLANNED</SelectItem> + <SelectItem value="IN_PROGRESS">IN_PROGRESS</SelectItem> + <SelectItem value="COMPLETED">COMPLETED</SelectItem> + <SelectItem value="CANCELED">CANCELED</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* scheduledStartAt */} + <FormField + control={form.control} + name="scheduledStartAt" + render={({ field }) => ( + <FormItem> + <FormLabel>Scheduled Start</FormLabel> + <FormControl> + <Input + type="date" + value={formatDateForInput(field.value)} + onChange={(e) => handleDateChange(e, field.onChange)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* scheduledEndAt */} + <FormField + control={form.control} + name="scheduledEndAt" + render={({ field }) => ( + <FormItem> + <FormLabel>Scheduled End</FormLabel> + <FormControl> + <Input + type="date" + value={formatDateForInput(field.value)} + onChange={(e) => handleDateChange(e, field.onChange)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* completedAt */} + <FormField + control={form.control} + name="completedAt" + render={({ field }) => ( + <FormItem> + <FormLabel>Completed At</FormLabel> + <FormControl> + <Input + type="date" + value={formatDateForInput(field.value)} + onChange={(e) => handleDateChange(e, field.onChange)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* investigationNotes */} + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( + <FormItem> + <FormLabel>Notes</FormLabel> + <FormControl> + <Input placeholder="Notes about the investigation..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* attachments: multiple file upload */} + <FormField + control={form.control} + name="attachments" + render={({ field: { value, onChange, ...fieldProps } }) => ( + <FormItem> + <FormLabel>Attachments</FormLabel> + <FormControl> + <Input + type="file" + multiple + onChange={(e) => { + onChange(e.target.files); // Store the FileList directly + }} + {...fieldProps} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Footer Buttons */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts new file mode 100644 index 00000000..18a50022 --- /dev/null +++ b/lib/vendor-investigation/validations.ts @@ -0,0 +1,93 @@ +import { vendorInvestigationsView } from "@/db/schema/vendors" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsInvestigationCache = createSearchParamsCache({ + // Common flags + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // Paging + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // Sorting - adjusting for vendorInvestigationsView + sort: getSortingStateParser<typeof vendorInvestigationsView.$inferSelect>().withDefault([ + { id: "investigationCreatedAt", desc: true }, + ]), + + // Advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // Global search + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // Fields specific to vendor investigations + // ----------------------------------------------------------------- + + // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED + investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), + + // In case you also want to filter by vendorName, vendorCode, etc. + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + + // If you need to filter by vendor status (e.g., PQ_SUBMITTED, ACTIVE, etc.), + // you can include it here too. Example: + // vendorStatus: parseAsStringEnum([ + // "PENDING_REVIEW", + // "IN_REVIEW", + // "REJECTED", + // "IN_PQ", + // "PQ_SUBMITTED", + // "PQ_FAILED", + // "PQ_APPROVED", + // "APPROVED", + // "ACTIVE", + // "INACTIVE", + // "BLACKLISTED", + // ]).optional(), +}) + +// Finally, export the type you can use in your server action: +export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>> + + +export const updateVendorInvestigationSchema = z.object({ + investigationId: z.number(), + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), + + // If the user might send empty strings, we'll allow it by unioning with z.literal('') + // Then transform empty string to undefined + scheduledStartAt: z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + + scheduledEndAt:z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + + completedAt: z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + investigationNotes: z.string().optional(), + attachments: z.any().optional(), + }) + +export type UpdateVendorInvestigationSchema = z.infer< + typeof updateVendorInvestigationSchema +>
\ No newline at end of file diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 2da16888..8f095c0e 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,7 +2,7 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; import logger from '@/lib/logger'; import { filterColumns } from "@/lib/filter-columns"; @@ -38,7 +38,7 @@ import type { GetRfqHistorySchema, } from "./validations"; -import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; import path from "path"; import fs from "fs/promises"; @@ -48,8 +48,10 @@ import { promises as fsPromises } from 'fs'; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items } from "@/db/schema/items"; -import { id_ID } from "@faker-js/faker"; import { users } from "@/db/schema/users"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { projects, vendorProjectPQs } from "@/db/schema"; /* ----------------------------------------------------- @@ -178,7 +180,9 @@ export async function getVendorStatusCounts() { "REJECTED": 0, "IN_PQ": 0, "PQ_FAILED": 0, + "PQ_APPROVED": 0, "APPROVED": 0, + "READY_TO_SEND": 0, "PQ_SUBMITTED": 0 }; @@ -275,7 +279,7 @@ export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -288,25 +292,25 @@ export async function createVendor(params: { }[] }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 - + try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params - + // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, vendorData.email)) .limit(1); - + // 이미 사용자가 존재하면 에러 반환 if (existingUser.length > 0) { - return { - data: null, - error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` + return { + data: null, + error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } - + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -319,36 +323,36 @@ export async function createVendor(params: { website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, - + // 대표자 정보 representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, - + // 신용/현금흐름 creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, }) - + // 2) If there are attached files, store them // (2-1) 일반 첨부 if (files.length > 0) { await storeVendorFiles(tx, newVendor.id, files, "GENERAL") } - + // (2-2) 신용평가 파일 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") } - + // (2-3) 현금흐름 파일 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") } - + for (const contact of contacts) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, @@ -360,7 +364,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -665,21 +669,21 @@ export async function getItemsForVendor(vendorId: number) { // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 const itemsData = await db - .select({ - itemCode: items.itemCode, - itemName: items.itemName, - description: items.description, - }) - .from(items) - .leftJoin( - vendorPossibleItems, - eq(items.itemCode, vendorPossibleItems.itemCode) - ) - // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 - .where( - isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) - ) - .orderBy(asc(items.itemName)) + .select({ + itemCode: items.itemCode, + itemName: items.itemName, + description: items.description, + }) + .from(items) + .leftJoin( + vendorPossibleItems, + eq(items.itemCode, vendorPossibleItems.itemCode) + ) + // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 + .where( + isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) + ) + .orderBy(asc(items.itemName)) return { data: itemsData.map((item) => ({ @@ -843,14 +847,15 @@ export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number export async function checkJoinPortal(taxID: string) { try { // 이미 등록된 회사가 있는지 검색 - const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1) + const result = await db.query.vendors.findFirst({ + where: eq(vendors.taxId, taxID) + }); - if (result.length > 0) { + if (result) { // 이미 가입되어 있음 - // data에 예시로 vendorName이나 다른 정보를 담아 반환 return { success: false, - data: result[0].vendorName ?? "Already joined", + data: result.vendorName ?? "Already joined", } } @@ -888,11 +893,9 @@ interface CreateCompanyInput { export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // 벤더 정보 조회 - const vendor = await db.select() - .from(vendors) - .where(eq(vendors.id, vendorId)) - .limit(1) - .then(rows => rows[0]); + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, vendorId) + }); if (!vendor) { throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`); @@ -1007,6 +1010,7 @@ export async function cleanupTempFiles(fileName: string) { interface ApproveVendorsInput { ids: number[]; + projectId?: number | null } /** @@ -1014,7 +1018,7 @@ interface ApproveVendorsInput { */ export async function approveVendors(input: ApproveVendorsInput) { unstable_noStore(); - + try { // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { @@ -1027,7 +1031,7 @@ export async function approveVendors(input: ApproveVendorsInput) { }) .where(inArray(vendors.id, input.ids)) .returning(); - + // 2. 업데이트된 벤더 정보 조회 const updatedVendors = await tx .select({ @@ -1037,21 +1041,22 @@ export async function approveVendors(input: ApproveVendorsInput) { }) .from(vendors) .where(inArray(vendors.id, input.ids)); - + // 3. 각 벤더에 대한 유저 계정 생성 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - + // 이미 존재하는 유저인지 확인 - const existingUser = await tx - .select({ id: users.id }) - .from(users) - .where(eq(users.email, vendor.email)) - .limit(1); - + const existingUser = await db.query.users.findFirst({ + where: eq(users.email, vendor.email), + columns: { + id: true + } + }); + // 유저가 존재하지 않는 경우에만 생성 - if (existingUser.length === 0) { + if (!existingUser) { await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, @@ -1061,20 +1066,20 @@ export async function approveVendors(input: ApproveVendorsInput) { } }) ); - + // 4. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - + try { const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 - - const subject = + + const subject = "[eVCP] Admin Account Created"; - + const loginUrl = "http://3.36.56.124:3000/en/login"; - + await sendEmail({ to: vendor.email, subject, @@ -1091,25 +1096,44 @@ export async function approveVendors(input: ApproveVendorsInput) { } }) ); - + return updated; }); - + // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("users"); // 유저 캐시도 무효화 - + return { data: result, error: null }; } catch (err) { console.error("Error approving vendors:", err); return { data: null, error: getErrorMessage(err) }; } } + export async function requestPQVendors(input: ApproveVendorsInput) { unstable_noStore(); - + try { + // 프로젝트 정보 가져오기 (projectId가 있는 경우) + let projectInfo = null; + if (input.projectId) { + const project = await db + .select({ + id: projects.id, + projectCode: projects.code, + projectName: projects.name, + }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1); + + if (project.length > 0) { + projectInfo = project[0]; + } + } + // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { // 1. 벤더 상태 업데이트 @@ -1121,7 +1145,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { }) .where(inArray(vendors.id, input.ids)) .returning(); - + // 2. 업데이트된 벤더 정보 조회 const updatedVendors = await tx .select({ @@ -1131,28 +1155,51 @@ export async function requestPQVendors(input: ApproveVendorsInput) { }) .from(vendors) .where(inArray(vendors.id, input.ids)); - - // 3. 각 벤더에게 이메일 발송 + + // 3. 프로젝트 PQ인 경우, vendorProjectPQs 테이블에 레코드 추가 + if (input.projectId && projectInfo) { + // 각 벤더에 대해 프로젝트 PQ 연결 생성 + const vendorProjectPQsData = input.ids.map(vendorId => ({ + vendorId, + projectId: input.projectId!, + status: "REQUESTED", + createdAt: new Date(), + updatedAt: new Date(), + })); + + await tx.insert(vendorProjectPQs).values(vendorProjectPQsData); + } + + // 4. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - + try { const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 - - const subject = - "[eVCP] You are invited to submit PQ"; - - const loginUrl = "http://3.36.56.124:3000/en/login"; - + + // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 + const subject = input.projectId + ? `[eVCP] You are invited to submit Project PQ for ${projectInfo?.projectCode || 'a project'}` + : "[eVCP] You are invited to submit PQ"; + + // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) + const baseLoginUrl = "http://3.36.56.124:3000/en/login"; + const loginUrl = input.projectId + ? `${baseLoginUrl}?projectId=${input.projectId}` + : baseLoginUrl; + await sendEmail({ to: vendor.email, subject, - template: "pq", // 이메일 템플릿 이름 + template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, language: userLang, + projectCode: projectInfo?.projectCode || '', + projectName: projectInfo?.projectName || '', + hasProject: !!input.projectId, }, }); } catch (emailError) { @@ -1161,17 +1208,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) { } }) ); - + return updated; }); - + // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); - + if (input.projectId) { + revalidateTag(`project-${input.projectId}`); + } + return { data: result, error: null }; } catch (err) { - console.error("Error approving vendors:", err); + console.error("Error requesting PQ from vendors:", err); return { data: null, error: getErrorMessage(err) }; } } @@ -1190,46 +1240,40 @@ export async function sendVendors(input: SendVendorsInput) { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 - const approvedVendors = await tx - .select() - .from(vendors) - .where( - and( - inArray(vendors.id, input.ids), - eq(vendors.status, "APPROVED") - ) - ); + const approvedVendors = await db.query.vendors.findMany({ + where: and( + inArray(vendors.id, input.ids), + eq(vendors.status, "APPROVED") + ) + }); if (!approvedVendors.length) { throw new Error("No approved vendors found in the selection"); } - // 벤더별 처리 결과를 저장할 배열 const results = []; // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { // 2-1. 벤더 연락처 정보 조회 - const contacts = await tx - .select() - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendor.id)); + const contacts = await db.query.vendorContacts.findMany({ + where: eq(vendorContacts.vendorId, vendor.id) + }); // 2-2. 벤더 가능 아이템 조회 - const possibleItems = await tx - .select() - .from(vendorPossibleItems) - .where(eq(vendorPossibleItems.vendorId, vendor.id)); - + const possibleItems = await db.query.vendorPossibleItems.findMany({ + where: eq(vendorPossibleItems.vendorId, vendor.id) + }); // 2-3. 벤더 첨부파일 조회 - const attachments = await tx - .select({ - id: vendorAttachments.id, - fileName: vendorAttachments.fileName, - filePath: vendorAttachments.filePath, - }) - .from(vendorAttachments) - .where(eq(vendorAttachments.vendorId, vendor.id)); + const attachments = await db.query.vendorAttachments.findMany({ + where: eq(vendorAttachments.vendorId, vendor.id), + columns: { + id: true, + fileName: true, + filePath: true + } + }); + // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { @@ -1287,7 +1331,7 @@ export async function sendVendors(input: SendVendorsInput) { const subject = "[eVCP] Vendor Registration Completed"; - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' const portalUrl = `${baseUrl}/en/partners`; @@ -1343,3 +1387,298 @@ export async function sendVendors(input: SendVendorsInput) { } } + +interface RequestInfoProps { + ids: number[]; +} + +export async function requestInfo({ ids }: RequestInfoProps) { + try { + // 1. 벤더 정보 가져오기 + const vendorList = await db.query.vendors.findMany({ + where: inArray(vendors.id, ids), + }); + + if (!vendorList.length) { + return { error: "벤더 정보를 찾을 수 없습니다." }; + } + + // 2. 각 벤더에게 이메일 보내기 + for (const vendor of vendorList) { + // 이메일이 없는 경우 스킵 + if (!vendor.email) continue; + + // 벤더 정보 페이지 URL 생성 + const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`; + + // 벤더에게 이메일 보내기 + await sendEmail({ + to: vendor.email, + subject: "[EVCP] 추가 정보 요청 / Additional Information Request", + template: "vendor-additional-info", + context: { + vendorName: vendor.vendorName, + vendorInfoUrl: vendorInfoUrl, + language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 + }, + }); + } + + // 3. 성공적으로 처리됨 + return { success: true }; + } catch (error) { + console.error("벤더 정보 요청 중 오류 발생:", error); + return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; + } +} + + +export async function getVendorDetailById(id: number) { + try { + // View를 통해 벤더 정보 조회 + const vendor = await db + .select() + .from(vendorDetailView) + .where(eq(vendorDetailView.id, id)) + .limit(1) + .then(rows => rows[0] || null); + + if (!vendor) { + return null; + } + + // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱 + const contacts = typeof vendor.contacts === 'string' + ? JSON.parse(vendor.contacts) + : vendor.contacts; + + const attachments = typeof vendor.attachments === 'string' + ? JSON.parse(vendor.attachments) + : vendor.attachments; + + // 파싱된 데이터로 반환 + return { + ...vendor, + contacts, + attachments + }; + } catch (error) { + console.error("Error fetching vendor detail:", error); + throw new Error("Failed to fetch vendor detail"); + } +} + +export type UpdateVendorInfoData = { + id: number + vendorName: string + website?: string + address?: string + email: string + phone?: string + country?: string + representativeName?: string + representativeBirth?: string + representativeEmail?: string + representativePhone?: string + corporateRegistrationNumber?: string + creditAgency?: string + creditRating?: string + cashFlowRating?: string +} + +export type ContactInfo = { + id?: number + contactName: string + contactPosition?: string + contactEmail: string + contactPhone?: string + isPrimary?: boolean +} + +/** + * 벤더 정보를 업데이트하는 함수 + */ +export async function updateVendorInfo(params: { + vendorData: UpdateVendorInfoData + files?: File[] + creditRatingFiles?: File[] + cashFlowRatingFiles?: File[] + contacts: ContactInfo[] + filesToDelete?: number[] // 삭제할 파일 ID 목록 +}) { + try { + const { + vendorData, + files = [], + creditRatingFiles = [], + cashFlowRatingFiles = [], + contacts, + filesToDelete = [] + } = params + + // 세션 및 권한 확인 + const session = await getServerSession(authOptions) + if (!session?.user || !session.user.companyId) { + return { data: null, error: "권한이 없습니다. 로그인이 필요합니다." }; + } + + const companyId = Number(session.user.companyId); + + // 자신의 회사 정보만 수정 가능 (관리자는 모든 회사 정보 수정 가능) + if ( + // !session.user.isAdmin && + vendorData.id !== companyId) { + return { data: null, error: "자신의 회사 정보만 수정할 수 있습니다." }; + } + + // 트랜잭션으로 업데이트 수행 + await db.transaction(async (tx) => { + // 1. 벤더 정보 업데이트 + await tx.update(vendors).set({ + vendorName: vendorData.vendorName, + address: vendorData.address || null, + email: vendorData.email, + phone: vendorData.phone || null, + website: vendorData.website || null, + country: vendorData.country || null, + representativeName: vendorData.representativeName || null, + representativeBirth: vendorData.representativeBirth || null, + representativeEmail: vendorData.representativeEmail || null, + representativePhone: vendorData.representativePhone || null, + corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, + creditAgency: vendorData.creditAgency || null, + creditRating: vendorData.creditRating || null, + cashFlowRating: vendorData.cashFlowRating || null, + updatedAt: new Date(), + }).where(eq(vendors.id, vendorData.id)) + + // 2. 연락처 정보 관리 + // 2-1. 기존 연락처 가져오기 + const existingContacts = await tx + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorData.id)) + + // 2-2. 기존 연락처 ID 목록 + const existingContactIds = existingContacts.map(c => c.id) + + // 2-3. 업데이트할 연락처와 새로 추가할 연락처 분류 + const contactsToUpdate = contacts.filter(c => c.id && existingContactIds.includes(c.id)) + const contactsToAdd = contacts.filter(c => !c.id) + + // 2-4. 삭제할 연락처 (기존에 있지만 새 목록에 없는 것) + const contactIdsToKeep = contactsToUpdate.map(c => c.id) + .filter((id): id is number => id !== undefined) + const contactIdsToDelete = existingContactIds.filter(id => !contactIdsToKeep.includes(id)) + + // 2-5. 연락처 삭제 + if (contactIdsToDelete.length > 0) { + await tx + .delete(vendorContacts) + .where(and( + eq(vendorContacts.vendorId, vendorData.id), + inArray(vendorContacts.id, contactIdsToDelete) + )) + } + + // 2-6. 연락처 업데이트 + for (const contact of contactsToUpdate) { + if (contact.id !== undefined) { + await tx + .update(vendorContacts) + .set({ + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary || false, + updatedAt: new Date(), + }) + .where(and( + eq(vendorContacts.id, contact.id), + eq(vendorContacts.vendorId, vendorData.id) + )) + } + } + + // 2-7. 연락처 추가 + for (const contact of contactsToAdd) { + await tx + .insert(vendorContacts) + .values({ + vendorId: vendorData.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary || false, + }) + } + + // 3. 파일 삭제 처리 + if (filesToDelete.length > 0) { + // 3-1. 삭제할 파일 정보 가져오기 + const attachmentsToDelete = await tx + .select() + .from(vendorAttachments) + .where(and( + eq(vendorAttachments.vendorId, vendorData.id), + inArray(vendorAttachments.id, filesToDelete) + )) + + // 3-2. 파일 시스템에서 파일 삭제 + for (const attachment of attachmentsToDelete) { + try { + // 파일 경로는 /public 기준이므로 process.cwd()/public을 앞에 붙임 + const filePath = path.join(process.cwd(), 'public', attachment.filePath.replace(/^\//, '')) + await fs.access(filePath, fs.constants.F_OK) // 파일 존재 확인 + await fs.unlink(filePath) // 파일 삭제 + } catch (error) { + console.warn(`Failed to delete file for attachment ${attachment.id}:`, error) + // 파일 삭제 실패해도 DB에서는 삭제 진행 + } + } + + // 3-3. DB에서 파일 기록 삭제 + await tx + .delete(vendorAttachments) + .where(and( + eq(vendorAttachments.vendorId, vendorData.id), + inArray(vendorAttachments.id, filesToDelete) + )) + } + + // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용) + // 4-1. 일반 파일 저장 + if (files.length > 0) { + await storeVendorFiles(tx, vendorData.id, files, "GENERAL"); + } + + // 4-2. 신용평가 파일 저장 + if (creditRatingFiles.length > 0) { + await storeVendorFiles(tx, vendorData.id, creditRatingFiles, "CREDIT_RATING"); + } + + // 4-3. 현금흐름 파일 저장 + if (cashFlowRatingFiles.length > 0) { + await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING"); + } + }) + + // 캐시 무효화 + revalidateTag("vendors") + revalidateTag(`vendor-${vendorData.id}`) + + return { + data: { + success: true, + message: '벤더 정보가 성공적으로 업데이트되었습니다.', + vendorId: vendorData.id + }, + error: null + } + } catch (error) { + console.error("Vendor info update error:", error); + return { data: null, error: getErrorMessage(error) } + } +}
\ No newline at end of file diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx index a82f59e1..3ffa9c5f 100644 --- a/lib/vendors/table/attachmentButton.tsx +++ b/lib/vendors/table/attachmentButton.tsx @@ -16,25 +16,25 @@ interface AttachmentsButtonProps { export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) { if (!hasAttachments) return null; - + const handleDownload = async () => { try { toast.loading('첨부파일을 준비하는 중...'); - + // 서버 액션 호출 const result = await downloadVendorAttachments(vendorId); - + // 로딩 토스트 닫기 toast.dismiss(); - + if (!result || !result.url) { toast.error('다운로드 준비 중 오류가 발생했습니다.'); return; } - + // 파일 다운로드 트리거 toast.success('첨부파일 다운로드가 시작되었습니다.'); - + // 다운로드 링크 열기 const a = document.createElement('a'); a.href = result.url; @@ -43,27 +43,34 @@ export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = document.body.appendChild(a); a.click(); document.body.removeChild(a); - + } catch (error) { toast.dismiss(); toast.error('첨부파일 다운로드에 실패했습니다.'); console.error('첨부파일 다운로드 오류:', error); } }; - + return ( - <Button - variant="ghost" - size="icon" - onClick={handleDownload} - title={`${attachmentsList.length}개 파일 다운로드`} - > - <PaperclipIcon className="h-4 w-4" /> - {attachmentsList.length > 1 && ( - <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1"> + <> + {attachmentsList && attachmentsList.length > 0 && + <Button + variant="ghost" + size="icon" + onClick={handleDownload} + title={`${attachmentsList.length}개 파일 다운로드`} + > + <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {/* {attachmentsList.length > 1 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center" + > {attachmentsList.length} </Badge> - )} - </Button> + )} */} + </Button> + } + </> ); } diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx new file mode 100644 index 00000000..872162dd --- /dev/null +++ b/lib/vendors/table/request-additional-Info-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Vendor } from "@/db/schema/vendors" +import { requestInfo } from "../service" + +interface RequestInfoDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestInfoDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestInfoDialogProps) { + const [isRequestPending, startRequestTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startRequestTransition(async () => { + const { error, success } = await requestInfo({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 추가 정보 요청 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + <br /><br /> + 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 추가 정보를 입력하게 됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending} + > + {isRequestPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 요청 발송 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 추가 정보 요청 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle> + <DrawerDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + <br /><br /> + 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 추가 정보를 입력하게 됩니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending} + > + {isRequestPending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 요청 발송 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx new file mode 100644 index 00000000..c590d7ec --- /dev/null +++ b/lib/vendors/table/request-project-pq-dialog.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, ChevronDown, BuildingIcon } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Label } from "@/components/ui/label" +import { Vendor } from "@/db/schema/vendors" +import { requestPQVendors } from "../service" +import { getProjects, type Project } from "@/lib/rfqs/service" + +interface RequestProjectPQDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestProjectPQDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestProjectPQDialogProps) { + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const [projects, setProjects] = React.useState<Project[]>([]) + const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null) + const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) + + // 프로젝트 목록 로드 + React.useEffect(() => { + async function loadProjects() { + setIsLoadingProjects(true) + try { + const projectsList = await getProjects() + setProjects(projectsList) + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error) + toast.error("프로젝트 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoadingProjects(false) + } + } + + loadProjects() + }, []) + + // 다이얼로그가 닫힐 때 선택된 프로젝트 초기화 + React.useEffect(() => { + if (!props.open) { + setSelectedProjectId(null) + } + }, [props.open]) + + // 프로젝트 선택 처리 + const handleProjectChange = (value: string) => { + setSelectedProjectId(Number(value)) + } + + function onApprove() { + if (!selectedProjectId) { + toast.error("프로젝트를 선택해주세요.") + return + } + + startApproveTransition(async () => { + const { error } = await requestPQVendors({ + ids: vendors.map((vendor) => vendor.id), + projectId: selectedProjectId, + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + + toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) + onSuccess?.() + }) + } + + const dialogContent = ( + <> + <div className="space-y-4 py-2"> + <div className="space-y-2"> + <Label htmlFor="project-selection">프로젝트 선택</Label> + <Select + onValueChange={handleProjectChange} + disabled={isLoadingProjects || isApprovePending} + > + <SelectTrigger id="project-selection" className="w-full"> + <SelectValue placeholder="프로젝트를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {isLoadingProjects ? ( + <SelectItem value="loading" disabled>프로젝트 로딩 중...</SelectItem> + ) : projects.length === 0 ? ( + <SelectItem value="empty" disabled>등록된 프로젝트가 없습니다</SelectItem> + ) : ( + projects.map((project) => ( + <SelectItem key={project.id} value={project.id.toString()}> + {project.projectCode} - {project.projectName} + </SelectItem> + )) + )} + </SelectContent> + </Select> + </div> + </div> + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <BuildingIcon className="size-4" aria-hidden="true" /> + 프로젝트 PQ 요청 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + </DialogDescription> + </DialogHeader> + + {dialogContent} + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택한 벤더에게 요청하기" + variant="default" + onClick={onApprove} + disabled={isApprovePending || !selectedProjectId} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 요청하기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <BuildingIcon className="size-4" aria-hidden="true" /> + 프로젝트 PQ 요청 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle> + <DrawerDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {dialogContent} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택한 벤더에게 요청하기" + variant="default" + onClick={onApprove} + disabled={isApprovePending || !selectedProjectId} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 요청하기 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx new file mode 100644 index 00000000..0309ee4a --- /dev/null +++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Check, SendHorizonal } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Vendor } from "@/db/schema/vendors" +import { requestInvestigateVendors } from "@/lib/vendor-investigation/service" + +interface ApprovalVendorDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestVendorsInvestigateDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: ApprovalVendorDialogProps) { + + console.log(vendors) + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startApproveTransition(async () => { + const { error } = await requestInvestigateVendors({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Vendor Investigation successfully sent to 벤더실사담당자") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <SendHorizonal className="size-4" aria-hidden="true" /> + Vendor Investigation Request ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle> + <DialogDescription> + Are you sure you want to request{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After sent, 벤더실사담당자 will be notified and can manage it. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Request selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Request + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Check className="size-4" aria-hidden="true" /> + Investigation Request ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Confirm Vendor Investigation</DrawerTitle> + <DrawerDescription> + Are you sure you want to request{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After sent, 벤더실사담당자 will be notified and can manage it. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Request selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Request + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx index a34abb77..1f93bd7f 100644 --- a/lib/vendors/table/send-vendor-dialog.tsx +++ b/lib/vendors/table/send-vendor-dialog.tsx @@ -28,7 +28,7 @@ import { DrawerTrigger, } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" -import { requestPQVendors, sendVendors } from "../service" +import { sendVendors } from "../service" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -58,7 +58,7 @@ export function SendVendorsDialog({ } props.onOpenChange?.(false) - toast.success("PQ successfully sent to vendors") + toast.success("Vendor Information successfully sent to MDG") onSuccess?.() }) } diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index c503e369..77750c47 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -79,82 +79,96 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<Vendor> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() +// ---------------------------------------------------------------- +// 2) actions 컬럼 (Dropdown 메뉴) +// ---------------------------------------------------------------- +const actionsColumn: ColumnDef<Vendor> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const isApproved = row.original.status === "APPROVED"; - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - // 1) 만약 rowAction을 열고 싶다면 - // setRowAction({ row, type: "update" }) + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) - // 2) 자세히 보기 페이지로 클라이언트 라우팅 - router.push(`/evcp/vendors/${row.original.id}/info`); - }} + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/vendors/${row.original.id}/info`); + }} + > + Details + </DropdownMenuItem> + + {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */} + {isApproved && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "requestInfo" })} + className="text-blue-600 font-medium" > - Details + 추가 정보 기입 </DropdownMenuItem> - <Separator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.status} - onValueChange={(value) => { - startUpdateTransition(() => { - toast.promise( - modifyVendor({ - id: String(row.original.id), - status: value as Vendor["status"], - }), - { - loading: "Updating...", - success: "Label updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {vendors.status.enumValues.map((status) => ( - <DropdownMenuRadioItem - key={status} - value={status} - className="capitalize" - disabled={isUpdatePending} - > - {status} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } + )} + + <Separator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.status} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifyVendor({ + id: String(row.original.id), + status: value as Vendor["status"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {vendors.status.enumValues.map((status) => ( + <DropdownMenuRadioItem + key={status} + value={status} + className="capitalize" + disabled={isUpdatePending} + > + {status} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, +} // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index c0605191..3cb2c552 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -2,15 +2,24 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Check } from "lucide-react" +import { Download, Upload, Check, BuildingIcon } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { Vendor } from "@/db/schema/vendors" import { ApproveVendorsDialog } from "./approve-vendor-dialog" import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog" +import { RequestProjectPQDialog } from "./request-project-pq-dialog" import { SendVendorsDialog } from "./send-vendor-dialog" +import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog" +import { RequestInfoDialog } from "./request-additional-Info-dialog" interface VendorsTableToolbarActionsProps { table: Table<Vendor> @@ -19,7 +28,7 @@ interface VendorsTableToolbarActionsProps { export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 const fileInputRef = React.useRef<HTMLInputElement>(null) - + // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 const pendingReviewVendors = React.useMemo(() => { return table @@ -28,9 +37,8 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .map(row => row.original) .filter(vendor => vendor.status === "PENDING_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - - - // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + + // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링 const inReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -38,7 +46,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .map(row => row.original) .filter(vendor => vendor.status === "IN_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - + const approvedVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -46,14 +54,36 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .map(row => row.original) .filter(vendor => vendor.status === "APPROVED"); }, [table.getFilteredSelectedRowModel().rows]); - - - + + const sendVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "READY_TO_SEND"); + }, [table.getFilteredSelectedRowModel().rows]); + + const pqApprovedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "PQ_APPROVED"); + }, [table.getFilteredSelectedRowModel().rows]); + + // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링 + const projectPQEligibleVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => + ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) + ); + }, [table.getFilteredSelectedRowModel().rows]); + return ( <div className="flex items-center gap-2"> - - - {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */} {pendingReviewVendors.length > 0 && ( <ApproveVendorsDialog @@ -61,22 +91,44 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - + + {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */} {inReviewVendors.length > 0 && ( <RequestPQVendorsDialog vendors={inReviewVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - + + {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */} + {projectPQEligibleVendors.length > 0 && ( + <RequestProjectPQDialog + vendors={projectPQEligibleVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + {approvedVendors.length > 0 && ( - <SendVendorsDialog + <RequestInfoDialog vendors={approvedVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - - + + {sendVendors.length > 0 && ( + <RequestInfoDialog + vendors={sendVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + + {pqApprovedVendors.length > 0 && ( + <RequestVendorsInvestigateDialog + vendors={pqApprovedVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + {/** 4) Export 버튼 */} <Button variant="outline" diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx index c04d57a9..36fd45bd 100644 --- a/lib/vendors/table/vendors-table.tsx +++ b/lib/vendors/table/vendors-table.tsx @@ -20,6 +20,7 @@ import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet" import { UpdateVendorSheet } from "./update-vendor-sheet" +import { getVendorStatusIcon } from "@/lib/vendors/utils" interface VendorsTableProps { promises: Promise< @@ -72,9 +73,11 @@ export function VendorsTable({ promises }: VendorsTableProps) { label: "Status", type: "multi-select", options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: (status), value: status, count: statusCounts[status], + icon: getVendorStatusIcon(status), + })), }, { id: "createdAt", label: "Created at", type: "date" }, diff --git a/lib/vendors/utils.ts b/lib/vendors/utils.ts new file mode 100644 index 00000000..305d772d --- /dev/null +++ b/lib/vendors/utils.ts @@ -0,0 +1,48 @@ +import { + Activity, + AlertCircle, + AlertTriangle, + ArrowDownIcon, + ArrowRightIcon, + ArrowUpIcon, + AwardIcon, + BadgeCheck, + CheckCircle2, + CircleHelp, + CircleIcon, + CircleX, + ClipboardCheck, + ClipboardList, + FileCheck2, + FilePenLine, + FileX2, + MailCheck, + PencilIcon, + SearchIcon, + SendIcon, + Timer, + Trash2, + XCircle, +} from "lucide-react" + +import { Vendor } from "@/db/schema/vendors" + +export function getVendorStatusIcon(status: Vendor["status"]) { + const statusIcons = { + PENDING_REVIEW: ClipboardList, // 가입 신청 중 (초기 신청) + IN_REVIEW: FilePenLine, // 심사 중 + REJECTED: XCircle, // 심사 거부됨 + IN_PQ: ClipboardCheck, // PQ 진행 중 + PQ_SUBMITTED: FileCheck2, // PQ 제출 + PQ_FAILED: FileX2, // PQ 실패 + PQ_APPROVED: BadgeCheck, // PQ 통과, 승인됨 + APPROVED: CheckCircle2, // PQ 통과, 승인됨 + READY_TO_SEND: CheckCircle2, // PQ 통과, 승인됨 + ACTIVE: Activity, // 활성 상태 (실제 거래 중) + INACTIVE: AlertCircle, // 비활성 상태 (일시적) + BLACKLISTED: AlertTriangle, // 거래 금지 상태 + } + + return statusIcons[status] || CircleIcon +} + diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 14efc8dc..1c08f8ff 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -1,4 +1,3 @@ -import { tasks, type Task } from "@/db/schema/tasks"; import { createSearchParamsCache, parseAsArrayOf, @@ -9,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors"; +import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" @@ -339,3 +338,103 @@ export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema> export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema> export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>> + + + +export const updateVendorInfoSchema = z.object({ + vendorName: z.string().min(1, "업체명은 필수 입력사항입니다."), + taxId: z.string(), + address: z.string().optional(), + country: z.string().min(1, "국가를 선택해 주세요."), + phone: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해 주세요."), + website: z.string().optional(), + + // 한국 사업자 정보 (KR일 경우 필수 항목들) + representativeName: z.string().optional(), + representativeBirth: z.string().optional(), + representativeEmail: z.string().optional(), + representativePhone: z.string().optional(), + corporateRegistrationNumber: z.string().optional(), + + // 신용평가 정보 + creditAgency: z.string().optional(), + creditRating: z.string().optional(), + cashFlowRating: z.string().optional(), + + // 첨부파일 + attachedFiles: z.any().optional(), + creditRatingAttachment: z.any().optional(), + cashFlowRatingAttachment: z.any().optional(), + + // 연락처 정보 + contacts: z.array(contactSchema).min(1, "최소 1명의 담당자가 필요합니다."), +}) + +export const updateVendorSchemaWithConditions = updateVendorInfoSchema.superRefine( + (data, ctx) => { + // 국가가 한국(KR)인 경우, 한국 사업자 정보 필수 + if (data.country === "KR") { + if (!data.representativeName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 이름은 필수 입력사항입니다.", + path: ["representativeName"], + }) + } + + if (!data.representativeBirth) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 생년월일은 필수 입력사항입니다.", + path: ["representativeBirth"], + }) + } + + if (!data.representativeEmail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 이메일은 필수 입력사항입니다.", + path: ["representativeEmail"], + }) + } + + if (!data.representativePhone) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 전화번호는 필수 입력사항입니다.", + path: ["representativePhone"], + }) + } + + if (!data.corporateRegistrationNumber) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "법인등록번호는 필수 입력사항입니다.", + path: ["corporateRegistrationNumber"], + }) + } + + // 신용평가사가 선택된 경우, 등급 정보 필수 + if (data.creditAgency) { + if (!data.creditRating) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "신용평가등급은 필수 입력사항입니다.", + path: ["creditRating"], + }) + } + + if (!data.cashFlowRating) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "현금흐름등급은 필수 입력사항입니다.", + path: ["cashFlowRating"], + }) + } + } + } + } +) + +export type UpdateVendorInfoSchema = z.infer<typeof updateVendorInfoSchema>
\ No newline at end of file diff --git a/types/table.d.ts b/types/table.d.ts index 979f2f27..aa75481e 100644 --- a/types/table.d.ts +++ b/types/table.d.ts @@ -54,7 +54,7 @@ export type Filter<TData> = Prettify< export interface DataTableRowAction<TData> { row: Row<TData> - type: "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" + type:"requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" } export interface QueryBuilderOpts { |
