From dfdfae3018f8499240f48d28ce634f4a5c56e006 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 2 Apr 2025 09:54:08 +0000 Subject: 벤더 코멘트 처리 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/equip-class/page.tsx | 2 +- app/[lng]/evcp/pq-criteria/[id]/page.tsx | 81 ++ app/[lng]/evcp/pq-criteria/page.tsx | 37 +- app/[lng]/evcp/pq/[vendorId]/page.tsx | 109 +- app/[lng]/evcp/vendor-candidates/page.tsx | 66 + app/[lng]/evcp/vendor-investigation/page.tsx | 65 + app/[lng]/partners/(partners)/info/page.tsx | 21 + app/[lng]/partners/pq/page.tsx | 62 +- components/additional-info/join-form.tsx | 1344 ++++++++++++++++++++ components/client-data-table/data-table.tsx | 84 +- components/pq/pq-input-tabs.tsx | 136 +- components/pq/pq-review-detail.tsx | 452 ++++--- components/pq/project-select-wrapper.tsx | 35 + components/pq/project-select.tsx | 173 +++ components/signup/join-form.tsx | 329 +---- components/vendor-data/vendor-data-container.tsx | 14 +- config/candidatesColumnsConfig.ts | 64 + config/menuConfig.ts | 19 +- config/rfqsColumnsConfig.ts | 14 +- config/vendorInvestigationsColumnsConfig.ts | 124 ++ db/db.ts | 3 +- db/schema/index.ts | 12 + db/schema/pq.ts | 103 +- db/schema/vendorData.ts | 2 +- db/schema/vendorDocu.ts | 161 +-- db/schema/vendors.ts | 250 +++- drizzle.config.ts | 2 +- i18n/locales/en/translation.json | 30 + i18n/locales/ko/translation.json | 33 + lib/forms/services.ts | 4 + lib/mail/templates/vendor-additional-info.hbs | 76 ++ lib/mail/templates/vendor-invitation.hbs | 86 ++ lib/pq/service.ts | 1172 +++++++++++++---- lib/pq/table/add-pq-dialog.tsx | 431 +++++-- lib/pq/table/import-pq-button.tsx | 258 ++++ lib/pq/table/import-pq-handler.tsx | 146 +++ lib/pq/table/pq-excel-template.tsx | 205 +++ lib/pq/table/pq-table-toolbar-actions.tsx | 86 +- lib/pq/table/pq-table.tsx | 4 +- lib/rfqs/table/ItemsDialog.tsx | 112 +- lib/rfqs/table/add-rfq-dialog.tsx | 6 +- lib/tags/service.ts | 153 ++- lib/tags/table/add-tag-dialog.tsx | 5 +- lib/tasks/utils.ts | 13 + lib/utils.ts | 7 +- lib/vendor-candidates/service.ts | 360 ++++++ .../table/add-candidates-dialog.tsx | 327 +++++ .../table/candidates-table-columns.tsx | 193 +++ .../table/candidates-table-floating-bar.tsx | 337 +++++ .../table/candidates-table-toolbar-actions.tsx | 93 ++ lib/vendor-candidates/table/candidates-table.tsx | 173 +++ .../table/delete-candidates-dialog.tsx | 149 +++ .../table/excel-template-download.tsx | 94 ++ .../table/feature-flags-provider.tsx | 108 ++ lib/vendor-candidates/table/feature-flags.tsx | 96 ++ lib/vendor-candidates/table/import-button.tsx | 211 +++ .../table/invite-candidates-dialog.tsx | 150 +++ .../table/update-candidate-sheet.tsx | 339 +++++ lib/vendor-candidates/utils.ts | 40 + lib/vendor-candidates/validations.ts | 84 ++ lib/vendor-document/service.ts | 1 - lib/vendor-investigation/service.ts | 229 ++++ .../table/feature-flags-provider.tsx | 108 ++ .../table/investigation-table-columns.tsx | 251 ++++ .../table/investigation-table-toolbar-actions.tsx | 41 + .../table/investigation-table.tsx | 133 ++ .../table/update-investigation-sheet.tsx | 324 +++++ lib/vendor-investigation/validations.ts | 93 ++ lib/vendors/service.ts | 551 ++++++-- lib/vendors/table/attachmentButton.tsx | 45 +- .../table/request-additional-Info-dialog.tsx | 152 +++ lib/vendors/table/request-project-pq-dialog.tsx | 242 ++++ .../table/request-vendor-investigate-dialog.tsx | 152 +++ lib/vendors/table/send-vendor-dialog.tsx | 4 +- lib/vendors/table/vendors-table-columns.tsx | 158 +-- .../table/vendors-table-toolbar-actions.tsx | 86 +- lib/vendors/table/vendors-table.tsx | 5 +- lib/vendors/utils.ts | 48 + lib/vendors/validations.ts | 103 +- types/table.d.ts | 2 +- 80 files changed, 10607 insertions(+), 1466 deletions(-) create mode 100644 app/[lng]/evcp/pq-criteria/[id]/page.tsx create mode 100644 app/[lng]/evcp/vendor-candidates/page.tsx create mode 100644 app/[lng]/evcp/vendor-investigation/page.tsx create mode 100644 app/[lng]/partners/(partners)/info/page.tsx create mode 100644 components/additional-info/join-form.tsx create mode 100644 components/pq/project-select-wrapper.tsx create mode 100644 components/pq/project-select.tsx create mode 100644 config/candidatesColumnsConfig.ts create mode 100644 config/vendorInvestigationsColumnsConfig.ts create mode 100644 db/schema/index.ts create mode 100644 lib/mail/templates/vendor-additional-info.hbs create mode 100644 lib/mail/templates/vendor-invitation.hbs create mode 100644 lib/pq/table/import-pq-button.tsx create mode 100644 lib/pq/table/import-pq-handler.tsx create mode 100644 lib/pq/table/pq-excel-template.tsx create mode 100644 lib/vendor-candidates/service.ts create mode 100644 lib/vendor-candidates/table/add-candidates-dialog.tsx create mode 100644 lib/vendor-candidates/table/candidates-table-columns.tsx create mode 100644 lib/vendor-candidates/table/candidates-table-floating-bar.tsx create mode 100644 lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx create mode 100644 lib/vendor-candidates/table/candidates-table.tsx create mode 100644 lib/vendor-candidates/table/delete-candidates-dialog.tsx create mode 100644 lib/vendor-candidates/table/excel-template-download.tsx create mode 100644 lib/vendor-candidates/table/feature-flags-provider.tsx create mode 100644 lib/vendor-candidates/table/feature-flags.tsx create mode 100644 lib/vendor-candidates/table/import-button.tsx create mode 100644 lib/vendor-candidates/table/invite-candidates-dialog.tsx create mode 100644 lib/vendor-candidates/table/update-candidate-sheet.tsx create mode 100644 lib/vendor-candidates/utils.ts create mode 100644 lib/vendor-candidates/validations.ts create mode 100644 lib/vendor-investigation/service.ts create mode 100644 lib/vendor-investigation/table/feature-flags-provider.tsx create mode 100644 lib/vendor-investigation/table/investigation-table-columns.tsx create mode 100644 lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx create mode 100644 lib/vendor-investigation/table/investigation-table.tsx create mode 100644 lib/vendor-investigation/table/update-investigation-sheet.tsx create mode 100644 lib/vendor-investigation/validations.ts create mode 100644 lib/vendors/table/request-additional-Info-dialog.tsx create mode 100644 lib/vendors/table/request-project-pq-dialog.tsx create mode 100644 lib/vendors/table/request-vendor-investigate-dialog.tsx create mode 100644 lib/vendors/utils.ts 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

- 벤더 데이터 입력을 위한 Form 리스트입니다.{" "} + Object Class List를 확인할 수 있습니다.{" "} {/* 버튼 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 +} + +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 ( + +

+
+

+ Pre-Qualification Check Sheet +

+

+ 벤더 등록을 위한, 벤더가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다. +

+
+ +
+ + }> + {/* */} + + + + } + > + + + + ) +} \ 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 @@ -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 ( - -
-
-
-

- Pre-Qualification Check Sheet -

-

- 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다. - -

-
+
+
+

+ Pre-Qualification Check Sheet +

+

+ 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다. +

+
- }> {/* */} + } > - + ) -} +} \ 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 } -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
Vendor not found
+ + // 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 ( - {vendor && - - } + {pqsList.hasGeneralPq || pqsList.projectPQs.length > 0 ? ( + +
+

+ {vendor.vendorName} PQ Review +

+ + + {pqsList.hasGeneralPq && ( + + General PQ Standard + + )} + + {pqsList.projectPQs.map((project) => ( + + {project.projectName} {project.status} + + ))} + +
+ + {/* Tab content for General PQ */} + {pqsList.hasGeneralPq && ( + + getPQDataByVendorId(vendorId)} + pqType="general" + /> + + )} + + {/* Tab content for each Project PQ */} + {pqsList.projectPQs.map((project) => ( + + getPQDataByVendorId(vendorId, project.projectId)} + pqType="project" + /> + + ))} +
+ ) : ( +
+

No PQ submissions found for this vendor

+
+ )}
) -} +} \ 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 +} + +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 ( + + +
+
+
+

+ Vendor Candidates Management +

+

+ 수집한 벤더 후보를 등록하고 초대 메일을 송부할 수 있습니다. + +

+
+
+
+ + + }> + + + } + > + + +
+ ) +} 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 +} + +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 ( + + +
+
+
+

+ Vendor Investigation Management +

+

+ 요청된 Vendor 실사에 대한 스케줄 정보를 관리하고 결과를 입력할 수 있습니다. + +

+
+
+
+ + + }> + + + } + > + + +
+ ) +} 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 ( + }> + + + ) +} \ 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 ( + {/* 헤더 - 프로젝트 정보 포함 */}

Pre-Qualification Check Sheet + {currentProject && ( + + - {currentProject.projectCode} + + )}

- PQ에 적절한 응답을 제출하시기 바랍니다. 진행 중 문의가 있으면 담당자에게 연락바랍니다. + PQ에 적절한 응답을 제출하시기 바랍니다.

- {/* 클라이언트 탭 UI 로드 (Suspense는 여기서는 크게 필요치 않을 수도 있음) */} + {/* 일반/프로젝트 PQ 선택 탭 */} + {projectPQs.length > 0 && ( +
+ + + + 일반 PQ + + + {projectPQs.map(project => ( + + + {project.projectCode} + + + ))} + + +
+ )} + + {/* PQ 입력 탭 */} }> - + -
) } \ 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 = { + 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(null) + const [isLoading, setIsLoading] = React.useState(true) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 첨부파일 상태 + const [existingFiles, setExistingFiles] = React.useState([]) + const [existingCreditFiles, setExistingCreditFiles] = React.useState([]) + const [existingCashFlowFiles, setExistingCashFlowFiles] = React.useState([]) + + const [selectedFiles, setSelectedFiles] = React.useState([]) + const [creditRatingFile, setCreditRatingFile] = React.useState([]) + const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState([]) + + // React Hook Form + const form = useForm({ + 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([]) + + 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 ( +
+ + 벤더 정보를 불러오는 중입니다... +
+ ) + } + + // Render + return ( +
+
+
+
+

+ {t("infoForm.title", { + defaultValue: "Update Vendor Information", + })} +

+

+ {t("infoForm.description", { + defaultValue: + "Here you can view and update your company information and attachments.", + })} +

+ + {vendor?.status && ( +
+ + {vendor.status} + +
+ )} +
+ + + + {/* 첨부파일 요약 카드 - 기존 파일 있는 경우만 표시 */} + {(existingFiles.length > 0 || existingCreditFiles.length > 0 || existingCashFlowFiles.length > 0) && ( + + + 첨부파일 요약 + + 현재 등록된 파일 목록입니다. 전체 다운로드하거나 개별 파일을 다운로드할 수 있습니다. + + + +
+ {existingFiles.length > 0 && ( +
+

일반 첨부파일

+ + + {existingFiles.map((file) => ( + + + + + {file.fileName} + + {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} + + +
+ handleDownloadFile(file.id)}> + + + handleDeleteExistingFile(file.id)}> + + +
+
+
+ ))} +
+
+
+ )} + + {existingCreditFiles.length > 0 && ( +
+

신용평가 첨부파일

+ + + {existingCreditFiles.map((file) => ( + + + + + {file.fileName} + + {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} + + +
+ handleDownloadFile(file.id)}> + + + handleDeleteExistingFile(file.id)}> + + +
+
+
+ ))} +
+
+
+ )} + + {existingCashFlowFiles.length > 0 && ( +
+

현금흐름 첨부파일

+ + + {existingCashFlowFiles.map((file) => ( + + + + + {file.fileName} + + {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} + + +
+ handleDownloadFile(file.id)}> + + + handleDeleteExistingFile(file.id)}> + + +
+
+
+ ))} +
+
+
+ )} +
+
+ + {(existingFiles.length + existingCreditFiles.length + existingCashFlowFiles.length) > 1 && ( + + )} + +
+ )} + +
+ + +
+

기본 정보

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

담당자 정보 (최소 1명)

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

한국 사업자 정보

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

기타 첨부파일 추가

+ ( + + + 첨부 파일 (추가) + + + {({ maxSize }) => ( + + +
+ +
+ 파일 업로드 + + 드래그 또는 클릭 + {maxSize + ? ` (최대: ${prettyBytes(maxSize)})` + : null} + +
+
+
+ )} +
+ {selectedFiles.length > 0 && ( +
+ + + {selectedFiles.map((file, i) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(i)}> + + + + + ))} + + +
+ )} +
+ )} + /> +
+ {/* Submit 버튼 */} +
+ +
+
+ +
+
+
+ ) +} \ No newline at end of file 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({ const [sorting, setSorting] = React.useState([]) const [grouping, setGrouping] = React.useState([]) const [columnSizing, setColumnSizing] = React.useState({}) - - // 실제 리사이징 상태만 추적 - 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({ left: [], right: ["update"], @@ -115,41 +97,12 @@ export function ClientDataTable({ 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({ - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {/* 리사이즈 핸들 - 헤더에만 추가 */} + {header.column.getCanResize() && ( + )} - - {/* 리사이즈 핸들 - 헤더에만 추가 */} - {header.column.getCanResize() && ( - - )} +
) })} @@ -322,11 +273,6 @@ export function ClientDataTable({ )} - - {/* 리사이징 시에만 캡처 레이어 활성화 */} - {isResizing && ( -
- )}
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 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 ( +
+
+

프로젝트 정보

+ + {getStatusLabel(projectData.status)} + +
+ +
+
+

프로젝트 코드

+

{projectData.projectCode}

+
+
+

프로젝트명

+

{projectData.projectName}

+
+ {projectData.submittedAt && ( +
+

제출일

+

{formatDate(projectData.submittedAt)}

+
+ )} +
+
+ ); + }; + + // 상태 표시용 함수 + 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 (
+ {/* 프로젝트 정보 섹션 */} + {renderProjectInfo()} + {/* Top Controls */}
@@ -485,7 +566,7 @@ export function PQInputTabs({ {/* 2-column grid */}
{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({ - {/* Answer Field */} - + + {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} + {projectId && contractInfo && ( +
+ 계약 정보 +
+ {contractInfo} +
+
+ )} + + {projectId && additionalRequirement && ( +
+ 추가 요구사항 +
+ {additionalRequirement} +
+
+ )} + + {/* Answer Field */} ( - + Answer