diff options
324 files changed, 175 insertions, 63289 deletions
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx deleted file mode 100644 index 5aebf15d..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { getCodeGroups } from "@/lib/docu-list-rule/code-groups/service"; -import { CodeGroupsTable } from "@/lib/docu-list-rule/code-groups/table/code-groups-table"; -import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation"; -import { InformationButton } from "@/components/information/information-button"; - -interface IndexPageProps { - searchParams: Promise<SearchParams>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - const search = searchParamsCodeGroupsCache.parse(searchParams); - - const promises = Promise.all([ - getCodeGroups({ - ...search, - }), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">Code Group 정의</h2> - <InformationButton pagePath="evcp/docu-list-rule/code-groups" /> - </div> - {/* <p className="text-muted-foreground"> - 문서 번호에 사용될 수 있는 다양한 코드 그룹의 정의를 관리하는 페이지입니다. - </p> */} - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={7} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["8rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem"]} - shrinkZero - /> - } - > - <CodeGroupsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx deleted file mode 100644 index cf0bf02e..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { getComboBoxCodeGroups } from "@/lib/docu-list-rule/combo-box-settings/service"; -import { ComboBoxSettingsTable } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table"; -import { InformationButton } from "@/components/information/information-button"; -import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation"; - -interface IndexPageProps { - searchParams: Promise<any>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - - const promises = Promise.all([ - getComboBoxCodeGroups( - searchParamsCodeGroupsCache.parse(searchParams) - ), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">Combo Box 설정</h2> - <InformationButton pagePath="evcp/docu-list-rule/combo-box-settings" /> - </div> - {/* <p className="text-muted-foreground"> - Combo Box 옵션을 관리하는 페이지입니다. - 각 Code Group별로 Combo Box에 표시될 옵션들을 설정할 수 있습니다. - </p> */} - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["8rem", "12rem", "10rem", "8rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <ComboBoxSettingsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx deleted file mode 100644 index 5c2c600e..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from "react"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { getDocumentClassCodeGroups } from "@/lib/docu-list-rule/document-class/service"; -import { DocumentClassTable } from "@/lib/docu-list-rule/document-class/table/document-class-table"; -import { InformationButton } from "@/components/information/information-button"; -import { searchParamsDocumentClassCache } from "@/lib/docu-list-rule/document-class/validation"; - -interface IndexPageProps { - searchParams: Promise<any>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - - const promises = Promise.all([ - getDocumentClassCodeGroups( - searchParamsDocumentClassCache.parse(searchParams) - ), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">Document Class 관리</h2> - <InformationButton pagePath="evcp/docu-list-rule/document-class" /> - </div> - {/* <p className="text-muted-foreground"> - Document Class를 관리합니다. - </p> */} - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={1} - cellWidths={["10rem", "20rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <DocumentClassTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx deleted file mode 100644 index 25023e4b..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "Document Numbering Rule", -} - - - -export default async function DocumentNumberingLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - const sidebarNavItems = [ - { - title: "Document Class 관리", - href: `/${lng}/engineering/docu-list-rule/document-class`, - }, - { - title: "Code Group 정의", - href: `/${lng}/engineering/docu-list-rule/code-groups`, - }, - { - title: "Combo Box 설정", - href: `/${lng}/engineering/docu-list-rule/combo-box-settings`, - }, - { - title: "Number Type 관리", - href: `/${lng}/engineering/docu-list-rule/number-types`, - }, - { - title: "Number Type별 설정", - href: `/${lng}/engineering/docu-list-rule/number-type-configs`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">Document Numbering Rule (해양)</h2> - <p className="text-muted-foreground"> - 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링 - </p> - </div> - - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx deleted file mode 100644 index 4195ba24..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from "react"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { NumberTypeConfigsTable } from "@/lib/docu-list-rule/number-type-configs/table/number-type-configs-table"; -import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service"; -import { InformationButton } from "@/components/information/information-button"; - -interface IndexPageProps { - searchParams: Promise<any>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - - const promises = Promise.all([ - getNumberTypes({ - page: 1, - perPage: 1000, // 모든 Number Type을 가져오기 위해 큰 값 설정 - search: "", - sort: [{ id: "id", desc: false }], // DB 등록 순서대로 정렬 - filters: [], - joinOperator: "and", - flags: ["advancedTable"], - numberTypeId: "", - description: "", - isActive: "" - }), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">Number Type별 설정</h2> - <InformationButton pagePath="evcp/docu-list-rule/number-type-configs" /> - </div> - {/* <p className="text-muted-foreground"> - 각 문서 번호 유형별로 어떤 코드 그룹들을 어떤 순서로 사용할지 설정하는 페이지입니다. - </p> */} - </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", "12rem", "12rem", "12rem"]} - shrinkZero - /> - } - > - <NumberTypeConfigsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx deleted file mode 100644 index 6fa010c7..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from "react"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { NumberTypesTable } from "@/lib/docu-list-rule/number-types/table/number-types-table"; -import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service"; -import { InformationButton } from "@/components/information/information-button"; -import { searchParamsNumberTypesCache } from "@/lib/docu-list-rule/number-types/validation"; - -interface IndexPageProps { - searchParams: Promise<any>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - - const promises = Promise.all([ - getNumberTypes( - searchParamsNumberTypesCache.parse(searchParams) - ), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">Number Type 관리</h2> - <InformationButton pagePath="evcp/docu-list-rule/number-types" /> - </div> - {/* <p className="text-muted-foreground"> - 문서 번호 유형을 추가, 수정, 삭제할 수 있는 페이지입니다. - </p> */} - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={0} - cellWidths={["10rem", "20rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <NumberTypesTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx deleted file mode 100644 index b8d3559f..00000000 --- a/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from "next/navigation" - - -export default async function DocumentNumberingPage({ - params, -}: { - params: Promise<{ lng: string }> -}) { - const resolvedParams = await params; - // Code Group 페이지로 리다이렉트 - redirect(`/${resolvedParams.lng}/engineering/docu-list-rule/document-class`) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/document-list-only/layout.tsx b/app/[lng]/engineering/(engineering)/document-list-only/layout.tsx deleted file mode 100644 index 17e78c0a..00000000 --- a/app/[lng]/engineering/(engineering)/document-list-only/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Shell } from "@/components/shell" -import VendorDocumentListClientEvcp from "@/components/document-lists/vendor-doc-list-client-evcp" - -// Layout 컴포넌트는 서버 컴포넌트입니다 -export default async function EvcpDocuments({ - children, -}: { - children: React.ReactNode -}) { - return ( - <Shell className="gap-2"> - <VendorDocumentListClientEvcp> - {children} - </VendorDocumentListClientEvcp> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/document-list-only/page.tsx b/app/[lng]/engineering/(engineering)/document-list-only/page.tsx deleted file mode 100644 index 5b49a6ef..00000000 --- a/app/[lng]/engineering/(engineering)/document-list-only/page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// evcp/document-list-only/page.tsx - 전체 계약 대상 문서 목록 -import * as React from "react" -import { Suspense } from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { DocumentStagesTable } from "@/lib/vendor-document-list/plant/document-stages-table" -import { documentStageSearchParamsCache } from "@/lib/vendor-document-list/plant/document-stage-validations" -import { getDocumentStagesOnly } from "@/lib/vendor-document-list/plant/document-stages-service" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -// 문서 테이블 래퍼 컴포넌트 (전체 계약용) -async function DocumentTableWrapper({ - searchParams -}: { - searchParams: SearchParams -}) { - const search = documentStageSearchParamsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 필터 타입 변환 - const convertedFilters = validFilters.map(filter => ({ - id: (filter.id || filter.rowId) as string, - value: filter.value, - operator: (filter.operator === 'iLike' ? 'ilike' : - filter.operator === 'notILike' ? 'notin' : - filter.operator === 'isEmpty' ? 'eq' : - filter.operator === 'isNotEmpty' ? 'ne' : - filter.operator === 'isBetween' ? 'eq' : - filter.operator === 'isRelativeToToday' ? 'eq' : - filter.operator || 'eq') as 'eq' | 'in' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'like' | 'ilike' | 'notin' - })) - - // evcp: 전체 계약 대상으로 문서 조회 - const documentsPromise = getDocumentStagesOnly({ - ...search, - filters: convertedFilters, - }, -1) // 세션에서 자동으로 도메인 감지 - - return ( - <DocumentStagesTable - promises={Promise.all([documentsPromise])} - contractId={-1} // 전체 계약을 의미 - projectType="plant" // 기본값으로 plant 사용 - /> - ) -} - -function TableLoadingSkeleton() { - return ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Skeleton className="h-6 w-32" /> - <div className="flex items-center gap-2"> - <Skeleton className="h-8 w-20" /> - <Skeleton className="h-8 w-24" /> - </div> - </div> - <div className="rounded-md border"> - <div className="p-4"> - <div className="space-y-3"> - {Array.from({ length: 5 }).map((_, i) => ( - <div key={i} className="flex items-center space-x-4"> - <Skeleton className="h-4 w-4" /> - <Skeleton className="h-4 w-24" /> - <Skeleton className="h-4 w-48" /> - <Skeleton className="h-4 w-20" /> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-12" /> - </div> - ))} - </div> - </div> - </div> - </div> - ) -} - -// 메인 페이지 컴포넌트 -export default async function DocumentStagesManagementPage({ - searchParams -}: IndexPageProps) { - const resolvedSearchParams = await searchParams - - return ( - <div className="mx-auto"> - {/* 문서 테이블 */} - <Suspense fallback={<TableLoadingSkeleton />}> - <DocumentTableWrapper - searchParams={resolvedSearchParams} - /> - </Suspense> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/document-list-ship/page.tsx b/app/[lng]/engineering/(engineering)/document-list-ship/page.tsx deleted file mode 100644 index e3915419..00000000 --- a/app/[lng]/engineering/(engineering)/document-list-ship/page.tsx +++ /dev/null @@ -1,144 +0,0 @@ -// page.tsx (간단한 Promise 생성과 로그인 처리) -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 { searchParamsShipDocuCache } from "@/lib/vendor-document-list/validations" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { LogIn } from "lucide-react" -import { getUserVendorDocumentStats, getUserVendorDocumentStatsAll, getUserVendorDocuments, getUserVendorDocumentsAll } from "@/lib/vendor-document-list/enhanced-document-service" -import { UserVendorDocumentDisplay } from "@/components/ship-vendor-document/user-vendor-document-table-container" -import { InformationButton } from "@/components/information/information-button" -import { UserVendorALLDocumentDisplay } from "@/components/ship-vendor-document-all/user-vendor-document-table-container" -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsShipDocuCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // Get session - const session = await getServerSession(authOptions) - - // Check if user is logged in - if (!session || !session.user) { - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 문서 관리 - </h2> - - </div> - {/* <p className="text-muted-foreground"> - 소속 회사의 모든 도서/도면을 확인하고 관리합니다. - </p> */} - </div> - </div> - - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - 문서를 확인하려면 먼저 로그인하세요. - </p> - <Button size="lg" asChild> - <Link href="/partners"> - <LogIn className="mr-2 h-4 w-4" /> - 로그인하기 - </Link> - </Button> - </div> - </div> - </Shell> - ) - } - - // User is logged in, get user ID - const requesterId = session.user.id ? Number(session.user.id) : null - - if (!requesterId) { - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Document Management - </h2> - </div> - </div> - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> - <p className="mb-6 text-muted-foreground"> - 사용자 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요. - </p> - </div> - </div> - </Shell> - ) - } - - // 검색 파라미터 정리 - const searchInput = { - ...search, - filters: validFilters, - } - - // Promise 생성 (모든 데이터를 페이지에서 처리) - const documentsPromise = getUserVendorDocumentsAll(requesterId, searchInput) - const statsPromise = getUserVendorDocumentStatsAll(requesterId) - - // Promise.all로 감싸서 전달 - const allPromises = Promise.all([documentsPromise, statsPromise]) - const statsResult = await documentsPromise - - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 조선 Document Management - </h2> - <InformationButton pagePath="evcp/document-list-ship" /> - </div> - <p className="text-muted-foreground"> - - </p> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker can go here */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={1} - filterableColumnCount={3} - cellWidths={["10rem", "30rem", "15rem", "15rem", "15rem", "15rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <UserVendorALLDocumentDisplay - allPromises={allPromises} - /> - </React.Suspense> - </Shell> - ) -} - diff --git a/app/[lng]/engineering/(engineering)/faq/manage/actions.ts b/app/[lng]/engineering/(engineering)/faq/manage/actions.ts deleted file mode 100644 index bc443a8a..00000000 --- a/app/[lng]/engineering/(engineering)/faq/manage/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use server';
-
-import { promises as fs } from 'fs';
-import path from 'path';
-import { FaqCategory } from '@/components/faq/FaqCard';
-import { fallbackLng } from '@/i18n/settings';
-
-const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
-
-export async function updateFaqData(lng: string, newData: FaqCategory[]) {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- const updatedData = {
- ...allData,
- [lng]: newData
- };
-
- const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
- await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
-
- return { success: true };
- } catch (error) {
- console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
- return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
- }
-}
-
-export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- return { data: allData[lng] || allData[fallbackLng] || [] };
- } catch (error) {
- console.error('FAQ 데이터 읽기 중 오류 발생:', error);
- return { data: [] };
- }
-}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/faq/manage/page.tsx b/app/[lng]/engineering/(engineering)/faq/manage/page.tsx deleted file mode 100644 index 011bbfa4..00000000 --- a/app/[lng]/engineering/(engineering)/faq/manage/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FaqManager } from '@/components/faq/FaqManager';
-import { getFaqData, updateFaqData } from './actions';
-import { revalidatePath } from 'next/cache';
-import { FaqCategory } from '@/components/faq/FaqCard';
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqManagePage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const { data } = await getFaqData(lng);
-
- async function handleSave(newData: FaqCategory[]) {
- 'use server';
- await updateFaqData(lng, newData);
- revalidatePath(`/${lng}/evcp/faq`);
- }
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
- <p className="text-muted-foreground">
- Manage FAQ categories and items for {lng.toUpperCase()} language.
- </p>
- </div>
- <FaqManager initialData={data} onSave={handleSave} lng={lng} />
- </div>
- </section>
- </div>
- );
-}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/faq/page.tsx b/app/[lng]/engineering/(engineering)/faq/page.tsx deleted file mode 100644 index 9b62b7e4..00000000 --- a/app/[lng]/engineering/(engineering)/faq/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { faqCategories } from "@/config/faqDataConfig"
-import { FaqCard } from "@/components/faq/FaqCard"
-import { Button } from "@/components/ui/button"
-import { Settings } from "lucide-react"
-import Link from "next/link"
-import { fallbackLng } from "@/i18n/settings"
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqPage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="flex justify-between items-center">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">Frequently Asked Questions</h2>
- <p className="text-muted-foreground">
- Find answers to common questions about using the EVCP system.
- </p>
- </div>
- <Link href={`/${lng}/evcp/faq/manage`}>
- <Button variant="outline">
- <Settings className="w-4 h-4 mr-2" />
- Manage FAQ
- </Button>
- </Link>
- </div>
- <Separator className="my-6" />
-
- <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
- <TabsList>
- {localizedFaqCategories.map((category) => (
- <TabsTrigger key={category.label} value={category.label}>
- {category.label}
- </TabsTrigger>
- ))}
- </TabsList>
-
- {localizedFaqCategories.map((category) => (
- <TabsContent key={category.label} value={category.label} className="space-y-4">
- {category.items.map((item, index) => (
- <FaqCard key={index} item={item} />
- ))}
- </TabsContent>
- ))}
- </Tabs>
- </div>
- </section>
- </div>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/form-list/page.tsx b/app/[lng]/engineering/(engineering)/form-list/page.tsx deleted file mode 100644 index a2c6fbb9..00000000 --- a/app/[lng]/engineering/(engineering)/form-list/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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/form-list/validation" -import { ItemsTable } from "@/lib/items/table/items-table" -import { getFormLists } from "@/lib/form-list/service" -import { FormListsTable } from "@/lib/form-list/table/formLists-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getFormLists({ - ...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"> - 레지스터 목록 from S-EDP - </h2> - {/* <p className="text-muted-foreground"> - 협력업체 데이터 입력을 위한 레지스터 목록 리스트입니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <FormListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/items/page.tsx b/app/[lng]/engineering/(engineering)/items/page.tsx deleted file mode 100644 index f8d9a5b1..00000000 --- a/app/[lng]/engineering/(engineering)/items/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// app/items/page.tsx (업데이트) -import * as React from "react" -import { type SearchParams } from "@/types/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/items/validations" -import { getItems } from "@/lib/items/service" -import { ItemsTable } from "@/lib/items/table/items-table" -import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // pageSize 기반으로 모드 자동 결정 - const isInfiniteMode = search.perPage >= 1_000_000 - - // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 - // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 - const promises = isInfiniteMode - ? undefined - : Promise.all([ - getItems(search), // searchParamsCache의 결과를 그대로 사용 - ]) - - 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"> - 패키지 넘버 - </h2> - {/* <p className="text-muted-foreground"> - S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다. - </p> */} - </div> - </div> - - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker 등 추가 컴포넌트 */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - {/* 통합된 ItemsTable 컴포넌트 사용 */} - <ItemsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/layout.tsx b/app/[lng]/engineering/(engineering)/layout.tsx deleted file mode 100644 index 82b53307..00000000 --- a/app/[lng]/engineering/(engineering)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ReactNode } from 'react'; -import { Header } from '@/components/layout/Header'; -import { SiteFooter } from '@/components/layout/Footer'; - -export default function EvcpLayout({ children }: { children: ReactNode }) { - return ( - <div className="relative flex min-h-svh flex-col bg-background"> - {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */} - <Header /> - <main className="flex flex-1 flex-col"> - <div className='container-wrapper'> - {children} - </div> - </main> - <SiteFooter/> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/projects/page.tsx b/app/[lng]/engineering/(engineering)/projects/page.tsx deleted file mode 100644 index 199b175b..00000000 --- a/app/[lng]/engineering/(engineering)/projects/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 { ItemsTable } from "@/lib/items/table/items-table" -import { getProjectLists } from "@/lib/projects/service" -import { ProjectsTable } from "@/lib/projects/table/projects-table" -import { searchParamsProjectsCache } from "@/lib/projects/validation" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsProjectsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getProjectLists({ - ...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"> - 프로젝트 리스트 from S-EDP - </h2> - {/* <p className="text-muted-foreground"> - S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <ProjectsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/report/page.tsx b/app/[lng]/engineering/(engineering)/report/page.tsx deleted file mode 100644 index 64778ef1..00000000 --- a/app/[lng]/engineering/(engineering)/report/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Shell } from "@/components/shell"; -import { ErrorBoundary } from "@/components/error-boundary"; -import { getDashboardData } from "@/lib/dashboard/service"; -import { DashboardClient } from "@/lib/dashboard/dashboard-client"; - -// 데이터 fetch 시 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생. -// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리 -// getDashboardData 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨. -export const dynamic = 'force-dynamic' - -export default async function IndexPage() { - // domain을 명시적으로 전달 - const domain = "engineering"; - - try { - // 서버에서 직접 데이터 fetch - const dashboardData = await getDashboardData(domain); - - return ( - <Shell className="gap-2"> - <DashboardClient initialData={dashboardData} /> - </Shell> - ); - } catch (error) { - console.error("Dashboard data fetch error:", error); - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-center py-12"> - <div className="text-center space-y-2"> - <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> - <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> - </div> - </div> - </Shell> - ); - } -} - -function DashboardSkeleton() { - return ( - <div className="space-y-6"> - {/* 헤더 스켈레톤 */} - <div className="flex items-center justify-between"> - <div className="space-y-2"> - <Skeleton className="h-8 w-48" /> - <Skeleton className="h-4 w-72" /> - </div> - <Skeleton className="h-10 w-24" /> - </div> - - {/* 요약 카드 스켈레톤 */} - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> - {[...Array(4)].map((_, i) => ( - <div key={i} className="space-y-3 p-6 border rounded-lg"> - <div className="flex items-center justify-between"> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-4" /> - </div> - <Skeleton className="h-8 w-12" /> - <Skeleton className="h-3 w-20" /> - </div> - ))} - </div> - - {/* 차트 스켈레톤 */} - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - {[...Array(2)].map((_, i) => ( - <div key={i} className="space-y-4 p-6 border rounded-lg"> - <div className="space-y-2"> - <Skeleton className="h-6 w-32" /> - <Skeleton className="h-4 w-48" /> - </div> - <Skeleton className="h-[300px] w-full" /> - </div> - ))} - </div> - - {/* 탭 스켈레톤 */} - <div className="space-y-4"> - <Skeleton className="h-10 w-64" /> - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> - {[...Array(6)].map((_, i) => ( - <div key={i} className="space-y-4 p-6 border rounded-lg"> - <Skeleton className="h-6 w-32" /> - <div className="space-y-3"> - <div className="flex justify-between"> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-12" /> - </div> - <div className="flex gap-2"> - <Skeleton className="h-6 w-16" /> - <Skeleton className="h-6 w-16" /> - <Skeleton className="h-6 w-16" /> - </div> - <Skeleton className="h-2 w-full" /> - </div> - </div> - ))} - </div> - </div> - </div> - ); -} diff --git a/app/[lng]/engineering/(engineering)/tag-numbering/page.tsx b/app/[lng]/engineering/(engineering)/tag-numbering/page.tsx deleted file mode 100644 index 86ad2ec2..00000000 --- a/app/[lng]/engineering/(engineering)/tag-numbering/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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/tag-numbering/validation" -import { getTagNumbering } from "@/lib/tag-numbering/service" -import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTagNumbering({ - ...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"> - 태그 타입 목록 from S-EDP - </h2> - {/* <p className="text-muted-foreground"> - 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <TagNumberingTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/tasks/page.tsx b/app/[lng]/engineering/(engineering)/tasks/page.tsx deleted file mode 100644 index 91b946fb..00000000 --- a/app/[lng]/engineering/(engineering)/tasks/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 { DateRangePicker } from "@/components/date-range-picker" -import { Shell } from "@/components/shell" - -import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider" -import { TasksTable } from "@/lib/tasks/table/tasks-table" -import { - getTaskPriorityCounts, - getTasks, - getTaskStatusCounts, -} from "@/lib/tasks/service" -import { searchParamsCache } from "@/lib/tasks/validations" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTasks({ - ...search, - filters: validFilters, - }), - getTaskStatusCounts(), - getTaskPriorityCounts(), - ]) - - return ( - <Shell className="gap-2"> - <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 - /> - } - > - <TasksTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/tbe/page.tsx b/app/[lng]/engineering/(engineering)/tbe/page.tsx deleted file mode 100644 index 211cf376..00000000 --- a/app/[lng]/engineering/(engineering)/tbe/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { AllTbeTable } from "@/lib/tbe/table/tbe-table" -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" - -interface IndexPageProps { - params: { - lng: string - } - searchParams: Promise<SearchParams> -} - -// 타입별 페이지 설명 구성 (Budgetary 제외) -const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = { - "purchase": { - title: "Purchase RFQ Technical Bid Evaluation", - description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.", - rfqType: RfqType.PURCHASE - }, - "purchase-budgetary": { - title: "Purchase Budgetary RFQ Technical Bid Evaluation", - description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.", - rfqType: RfqType.PURCHASE_BUDGETARY - } -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // URL 쿼리 파라미터에서 타입 추출 - const searchParams = await props.searchParams - // 기본값으로 'purchase' 사용 - const typeParam = searchParams?.type as string || 'purchase' - - // 유효한 타입인지 확인하고 기본값 설정 - const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase' - const rfqType = typeConfig[validType].rfqType - - // SearchParams 파싱 (Zod) - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 현재 선택된 타입의 데이터 로드 - const promises = Promise.all([ - getAllTBE({ - ...search, - filters: validFilters, - rfqType - }) - ]) - - // 페이지 경로 생성 함수 - 단순화 - const getTabUrl = (type: string) => { - return `/${lng}/evcp/tbe?type=${type}`; - } - - 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"> - TBE 관리 - </h2> - {/* <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/> - 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> */} - </div> - </div> - </div> - - {/* 타입 선택 탭 (Budgetary 제외) */} - <Tabs defaultValue={validType} value={validType} className="w-full"> - <TabsList className="grid grid-cols-2 w-full max-w-md"> - <TabsTrigger value="purchase" asChild> - <a href={getTabUrl('purchase')}>Purchase</a> - </TabsTrigger> - <TabsTrigger value="purchase-budgetary" asChild> - <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a> - </TabsTrigger> - </TabsList> - - <div className="mt-2"> - <p className="text-sm text-muted-foreground"> - {typeConfig[validType].description} - </p> - </div> - </Tabs> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx b/app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx deleted file mode 100644 index e6f9ce82..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation" -import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table" -import { getGeneralEvaluations } from "@/lib/general-check-list/service" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getGenralEvaluationsSchema.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getGeneralEvaluations({ - ...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"> - 협력업체 평가자료 문항 관리 - </h2> - {/* <p className="text-muted-foreground"> - 협력업체 평가에 사용되는 정기평가 체크리스트를 관리{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <GeneralEvaluationsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx b/app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx deleted file mode 100644 index f69aa525..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import DynamicTable from "@/components/form-data/form-data-table"; -import { findContractItemId, getFormData, getFormId } from "@/lib/forms/services"; - -interface IndexPageProps { - params: { - lng: string; - packageId: string; - formId: string; - projectId: string; - contractId: string; - - - }; - searchParams?: { - mode?: string; - }; -} - -export default async function FormPage({ params, searchParams }: IndexPageProps) { - // 1) 구조 분해 할당 - const resolvedParams = await params; - - // 2) searchParams도 await 필요 - const resolvedSearchParams = await searchParams; - - // 3) 구조 분해 할당 - const { lng, packageId, formId: formCode, projectId,contractId } = resolvedParams; - - // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용) - const mode = resolvedSearchParams?.mode === "ENG" ? "ENG" : "IM"; // 기본값은 IM - - // 4) 변환 - let packageIdAsNumber = Number(packageId); - const contractIdAsNumber = Number(contractId); - - // packageId가 0이면 contractId와 formCode로 실제 contractItemId 찾기 - if (packageIdAsNumber === 0 && contractIdAsNumber > 0) { - console.log(`packageId가 0이므로 contractId ${contractIdAsNumber}와 formCode ${formCode}로 contractItemId 조회`); - - const foundContractItemId = await findContractItemId(contractIdAsNumber, formCode); - - if (foundContractItemId) { - console.log(`contractItemId ${foundContractItemId}를 찾았습니다. 이 값을 사용합니다.`); - packageIdAsNumber = foundContractItemId; - } else { - console.warn(`contractItemId를 찾을 수 없습니다. packageId는 계속 0으로 유지됩니다.`); - } - } - - // 5) DB 조회 - const { columns, data, editableFieldsMap } = await getFormData(formCode, packageIdAsNumber); - - - // 6) formId 및 report temp file 조회 - const { formId } = await getFormId(String(packageIdAsNumber), formCode); - - // 7) 예외 처리 - if (!columns) { - return ( - <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다. ENG 모드의 경우에는 SHI 관리자에게 폼 생성 요청을 하시기 바랍니다.</p> - ); - } - - // 8) 렌더링 - return ( - <div className="space-y-6"> - <DynamicTable - contractItemId={packageIdAsNumber} - formCode={formCode} - formId={formId} - columnsJSON={columns} - dataJSON={data} - projectId={Number(projectId)} - editableFieldsMap={editableFieldsMap} // 새로 추가 - mode={mode} // 모드 전달 - /> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-data/layout.tsx b/app/[lng]/engineering/(engineering)/vendor-data/layout.tsx deleted file mode 100644 index 7d00359c..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-data/layout.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// app/vendor-data/layout.tsx -import * as React from "react" -import { cookies } from "next/headers" -import { Shell } from "@/components/shell" -import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services" -import { VendorDataContainer } from "@/components/vendor-data/vendor-data-container" -import { InformationButton } from "@/components/information/information-button" -// Layout 컴포넌트는 서버 컴포넌트입니다 -export default async function VendorDataLayout({ - children, -}: { - children: React.ReactNode -}) { - // evcp: 전체 계약 대상으로 프로젝트 데이터 가져오기 - const projects = await getVendorProjectsAndContracts() - - // 레이아웃 설정 쿠키 가져오기 - // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용 - const cookieStore = await cookies() - - // 이제 cookieStore.get() 메서드 사용 가능 - const layout = cookieStore.get("react-resizable-panels:layout:mail") - const collapsed = cookieStore.get("react-resizable-panels:collapsed") - - const defaultLayout = layout ? JSON.parse(layout.value) : undefined - const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined - - 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> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 데이터 입력 - </h2> - <InformationButton pagePath="partners/vendor-data" /> - </div> - {/* <p className="text-muted-foreground"> - 각종 Data 입력할 수 있습니다 - </p> */} - </div> - </div> - </div> - - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden flex-col md:flex"> - {projects.length === 0 ? ( - <div className="p-4 text-center text-sm text-muted-foreground"> - No projects found for this vendor. - </div> - ) : ( - <VendorDataContainer - projects={projects} - defaultLayout={defaultLayout} - defaultCollapsed={defaultCollapsed} - navCollapsedSize={4} - > - {/* 페이지별 콘텐츠가 여기에 들어갑니다 */} - {children} - </VendorDataContainer> - )} - </div> - </section> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-data/page.tsx b/app/[lng]/engineering/(engineering)/vendor-data/page.tsx deleted file mode 100644 index ddc21a2b..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-data/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// evcp/vendor-data/page.tsx - 전체 계약 대상 협력업체 데이터 -import * as React from "react" -import { Separator } from "@/components/ui/separator" - -export default async function IndexPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">전체 계약 협력업체 데이터 대시보드</h3> - <p className="text-sm text-muted-foreground"> - 모든 계약의 협력업체 데이터를 확인하고 관리할 수 있습니다. - </p> - </div> - <Separator /> - <div className="grid gap-4"> - <div className="rounded-lg border p-4"> - <h4 className="text-sm font-medium">사용 방법</h4> - <p className="text-sm text-muted-foreground mt-1"> - 1. 왼쪽 사이드바에서 계약을 선택하세요.<br /> - 2. 선택한 계약의 패키지 항목을 클릭하세요.<br /> - 3. 패키지의 태그 정보를 확인하고 관리할 수 있습니다.<br /> - 4. 폼 항목을 클릭하여 칼럼 정보를 확인하고 관리할 수 있습니다. - </p> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx b/app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx deleted file mode 100644 index 7250732f..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { TagsTable } from "@/lib/tags/table/tag-table" -import { searchParamsCache } from "@/lib/tags/validations" -import { getTags } from "@/lib/tags/service" - -interface IndexPageProps { - params: { - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function TagPage(props: IndexPageProps) { - const resolvedParams = await props.params - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTags({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <TagsTable promises={promises} selectedPackageId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx b/app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx deleted file mode 100644 index af9f3e11..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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"> - 협력업체 실사 관리 - </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]/engineering/page.tsx b/app/[lng]/engineering/page.tsx deleted file mode 100644 index f9662cb7..00000000 --- a/app/[lng]/engineering/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Metadata } from "next" -import { Suspense } from "react" -import { LoginFormSkeleton } from "@/components/login/login-form-skeleton" -import { LoginFormSHI } from "@/components/login/login-form-shi" - -export const metadata: Metadata = { - title: "eVCP Portal", - description: "", -} - -export default function AuthenticationPage() { - - - return ( - <> - <Suspense fallback={<LoginFormSkeleton/>}> - <LoginFormSHI /> - </Suspense> - </> - ) -} diff --git a/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx index e3810b5b..2b907a75 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx +++ b/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx @@ -1,62 +1,62 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { getProcurementItems } from "@/lib/procurement-items/service"
-import { ProcurementItemsTable } from "@/lib/procurement-items/table/procurement-items-table"
-import { searchParamsCache } from "@/lib/procurement-items/validations"
-import { InformationButton } from "@/components/information/information-button"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getProcurementItems({
- ...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>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 1회성 품목 관리
- </h2>
- <InformationButton pagePath="evcp/procurement-items" />
- </div>
- <p className="text-muted-foreground">
- 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다.
- </p>
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "20rem", "8rem", "12rem", "6rem", "8rem", "10rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <ProcurementItemsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
+import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getProcurementItems } from "@/lib/procurement-items/service" +import { ProcurementItemsTable } from "@/lib/procurement-items/table/procurement-items-table" +import { searchParamsCache } from "@/lib/procurement-items/validations" +import { InformationButton } from "@/components/information/information-button" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getProcurementItems({ + ...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> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 1회성 품목 관리 + </h2> + <InformationButton pagePath="evcp/procurement-items" /> + </div> + <p className="text-muted-foreground"> + 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다. + </p> + </div> + </div> + </div> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "20rem", "8rem", "12rem", "6rem", "8rem", "10rem", "10rem"]} + shrinkZero + /> + } + > + <ProcurementItemsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx deleted file mode 100644 index 44150492..00000000 --- a/app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// app/(routes)/legal-works/page.tsx 수정 - -import * as React from "react"; -import { Metadata } from "next"; -import { type SearchParams } from "@/types/table"; -import { Shell } from "@/components/shell"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { InformationButton } from "@/components/information/information-button"; -import { Badge } from "@/components/ui/badge"; // ✅ Badge 추가 -import { SearchParamsCacheLegalWorks } from "@/lib/legal-review/validations"; -import { getLegalWorks } from "@/lib/legal-review/service"; -import { LegalWorksTable } from "@/lib/legal-review/status/legal-table"; - -export const dynamic = "force-dynamic"; -export const revalidate = 0; - -export const metadata: Metadata = { - title: "법무검토 관리", - description: "법무 검토 요청 및 답변을 관리합니다.", -}; - -interface LegalWorksPageProps { - searchParams: Promise<SearchParams>; -} - -export default async function LegalWorksPage({ searchParams }: LegalWorksPageProps) { - const rawParams = await searchParams; - const parsedSearch = SearchParamsCacheLegalWorks.parse(rawParams); - - // ✅ EvaluationTargetsPage와 동일한 패턴으로 currentYear 추가 - const currentYear = new Date().getFullYear(); - - const promises = Promise.all([ - getLegalWorks(parsedSearch) - ]); - - return ( - <Shell className="gap-4"> - {/* Header - EvaluationTargetsPage와 동일한 패턴 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">법무검토 관리</h2> - <InformationButton pagePath="evcp/legal-review" /> - {/* ✅ EvaluationTargetsPage와 동일하게 Badge 추가 */} - <Badge variant="outline" className="text-sm"> - {currentYear}년 - </Badge> - </div> - </div> - - {/* Table */} - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={13} - searchableColumnCount={3} - filterableColumnCount={4} - cellWidths={[ - "3rem", // checkbox - "4rem", // No. - "5rem", // 구분 - "6rem", // 상태 - "8rem", // Vendor Code - "12rem", // Vendor Name - "4rem", // 긴급여부 - "7rem", // 답변요청일 - "7rem", // 의뢰일 - "7rem", // 답변예정일 - "7rem", // 법무완료일 - "8rem", // 검토요청자 - "8rem", // 법무답변자 - "4rem", // 첨부파일 - "8rem", // actions - ]} - shrinkZero - /> - } - > - {/* ✅ currentYear prop 추가 - EvaluationTargetsTable과 동일한 패턴 */} - <LegalWorksTable - promises={promises} - currentYear={currentYear} - /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/cbe/page.tsx b/app/[lng]/partners/(partners)/cbe/page.tsx deleted file mode 100644 index 4655cb60..00000000 --- a/app/[lng]/partners/(partners)/cbe/page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBEbyVendorId, } from "@/lib/rfqs/service" -import { searchParamsCBECache } from "@/lib/rfqs/validations" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { TbeVendorTable } from "@/lib/vendor-rfq-response/vendor-tbe-table/tbe-table" -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { CbeVendorTable } from "@/lib/vendor-rfq-response/vendor-cbe-table/cbe-table" -import { InformationButton } from "@/components/information/information-button" -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function CBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const session = await getServerSession(authOptions) - const vendorId = session?.user.companyId - // const vendorId = "17" - - const idAsNumber = Number(vendorId) - - const promises = Promise.all([ - getCBEbyVendorId({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - - 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> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - CBE 관리 - </h2> - <InformationButton pagePath="partners/cbe" /> - </div> - {/* <p className="text-sm text-muted-foreground"> - CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} - </p> */} - </div> - </div> - </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 - /> - } - > - <CbeVendorTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx b/app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx deleted file mode 100644 index 898dc41b..00000000 --- a/app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// app/vendor/responses/[vendorId]/[rfqRecordId]/[rfqType]/page.tsx -import * as React from "react"; -import Link from "next/link"; -import { Metadata } from "next"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; -import { - ArrowLeft, - FileText, - AlertTriangle, - TrendingUp, - CheckCircle2, - RefreshCw, - GitBranch, - Clock, - FileCheck, - Calendar -} from "lucide-react"; -import { Shell } from "@/components/shell"; -import { formatDate } from "@/lib/utils"; -import { getRfqAttachmentResponsesWithRevisions } from "@/lib/b-rfq/service"; -import { FinalRfqResponseTable } from "@/lib/b-rfq/vendor-response/response-detail-table"; - -export const metadata: Metadata = { - title: "RFQ 응답 상세", - description: "RFQ 첨부파일별 응답 관리 - 고급 리비전 추적", -}; - -interface RfqResponseDetailPageProps { - params: Promise<{ - vendorId: string; - rfqRecordId: string; - }>; -} - -export default async function RfqResponseDetailPage(props: RfqResponseDetailPageProps) { - const params = await props.params; - const { vendorId, rfqRecordId, rfqType } = params; - - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return ( - <Shell className="gap-6"> - <div className="text-center py-12"> - <p className="text-muted-foreground">로그인이 필요합니다.</p> - </div> - </Shell> - ); - } - - // 벤더 권한 확인 - if (session.user.domain !== "partners" || String(session.user.companyId) !== vendorId) { - return ( - <Shell className="gap-6"> - <div className="text-center py-12"> - <p className="text-muted-foreground">접근 권한이 없습니다.</p> - </div> - </Shell> - ); - } - - // 데이터 조회 (뷰 기반 고급 리비전 정보 포함) - const { data: responses, rfqInfo, vendorInfo, statistics, progressSummary } = - await getRfqAttachmentResponsesWithRevisions(vendorId, rfqRecordId); - - console.log("Enhanced RFQ Data:", { responses, statistics, progressSummary }); - - if (!rfqInfo) { - return ( - <Shell className="gap-6"> - <div className="text-center py-12"> - <p className="text-muted-foreground">RFQ 정보를 찾을 수 없습니다.</p> - </div> - </Shell> - ); - } - - const stats = statistics; - - return ( - <Shell className="gap-6"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <Button variant="ghost" size="sm" asChild> - <Link href="/partners/rfq-answer"> - <ArrowLeft className="h-4 w-4 mr-2" /> - 돌아가기 - </Link> - </Button> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {rfqInfo.rfqCode} - RFQ 응답 관리 - </h2> - <p className="text-muted-foreground"> - 고급 리비전 추적 및 응답 상태 관리 - </p> - </div> - </div> - - {/* 마감일 표시 */} - {progressSummary?.daysToDeadline !== undefined && ( - <div className="text-right"> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <Calendar className="h-4 w-4" /> - <span>마감까지</span> - </div> - <div className={`text-lg font-bold ${ - progressSummary.daysToDeadline < 0 - ? 'text-red-600' - : progressSummary.daysToDeadline <= 3 - ? 'text-orange-600' - : 'text-green-600' - }`}> - {progressSummary.daysToDeadline < 0 - ? `${Math.abs(progressSummary.daysToDeadline)}일 초과` - : `${progressSummary.daysToDeadline}일 남음` - } - </div> - </div> - )} - </div> - - {/* 중요 알림들 */} - <div className="space-y-3"> - {stats.versionMismatch > 0 && ( - <Alert className="border-blue-200 bg-blue-50"> - <RefreshCw className="h-4 w-4 text-blue-600" /> - <AlertDescription className="text-blue-800"> - <strong>{stats.versionMismatch}개 항목</strong>에서 발주처의 최신 리비전과 응답 리비전이 일치하지 않습니다. - 최신 버전으로 업데이트를 권장합니다. - </AlertDescription> - </Alert> - )} - - {progressSummary?.daysToDeadline !== undefined && progressSummary.daysToDeadline <= 3 && progressSummary.daysToDeadline >= 0 && ( - <Alert className="border-orange-200 bg-orange-50"> - <Clock className="h-4 w-4 text-orange-600" /> - <AlertDescription className="text-orange-800"> - 마감일이 <strong>{progressSummary.daysToDeadline}일</strong> 남았습니다. - 미응답 항목({stats.pending}개)의 신속한 처리가 필요합니다. - </AlertDescription> - </Alert> - )} - - {progressSummary?.attachmentsWithMultipleRevisions > 0 && ( - <Alert className="border-purple-200 bg-purple-50"> - <GitBranch className="h-4 w-4 text-purple-600" /> - <AlertDescription className="text-purple-800"> - <strong>{progressSummary.attachmentsWithMultipleRevisions}개 첨부파일</strong>에 - 다중 리비전이 있습니다. 히스토리를 확인하여 올바른 버전으로 응답해주세요. - </AlertDescription> - </Alert> - )} - </div> - <FinalRfqResponseTable - data={responses} - statistics={stats} - showHeader={true} - title="첨부파일별 응답 현황" - /> - - - - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-answer/page.tsx b/app/[lng]/partners/(partners)/rfq-answer/page.tsx deleted file mode 100644 index 6eae491e..00000000 --- a/app/[lng]/partners/(partners)/rfq-answer/page.tsx +++ /dev/null @@ -1,213 +0,0 @@ -// app/vendor/responses/page.tsx -import * as React from "react"; -import Link from "next/link"; -import { Metadata } from "next"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { LogIn, FileX, Clock, CheckCircle, AlertTriangle } from "lucide-react"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { Shell } from "@/components/shell"; -import { getValidFilters } from "@/lib/data-table"; -import { type SearchParams } from "@/types/table"; -import { searchParamsVendorResponseCache } from "@/lib/b-rfq/validations"; -import { getVendorResponseProgress, getVendorResponseStatusCounts, getVendorRfqResponses } from "@/lib/b-rfq/service"; -import { VendorResponsesTable } from "@/lib/b-rfq/vendor-response/vendor-responses-table"; -import { InformationButton } from "@/components/information/information-button" -export const metadata: Metadata = { - title: "응답 관리", - description: "RFQ 첨부파일 응답 현황을 관리합니다", -}; - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsVendorResponseCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 인증 확인 - const session = await getServerSession(authOptions); - - // 로그인 확인 - if (!session || !session.user) { - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 응답 관리 - </h2> - <InformationButton pagePath="partners/rfq-answer" /> - </div> - {/* <p className="text-muted-foreground"> - RFQ 첨부파일 응답 현황을 확인하고 관리합니다. - </p> */} - </div> - </div> - - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - 응답 현황을 확인하려면 먼저 로그인하세요. - </p> - <Button size="lg" asChild> - <Link href="/partners?callbackUrl=/vendor/responses"> - <LogIn className="mr-2 h-4 w-4" /> - 로그인하기 - </Link> - </Button> - </div> - </div> - </Shell> - ); - } - - // 벤더 ID 확인 - const vendorId = session.user.companyId ? String(session.user.companyId) : "0"; - - // 벤더 권한 확인 - if (session.user.domain !== "partners") { - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 접근 권한 없음 - </h2> - </div> - </div> - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">벤더 계정이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - 벤더 계정으로 로그인해주세요. - </p> - </div> - </div> - </Shell> - ); - } - - // 데이터 가져오기 - const responsesPromise = getVendorRfqResponses({ - ...search, - filters: validFilters - }, vendorId); - - // 상태별 개수 및 진행률 가져오기 - const [statusCounts, progress] = await Promise.all([ - getVendorResponseStatusCounts(vendorId), - getVendorResponseProgress(vendorId) - ]); - - // 프로미스 배열 - const promises = Promise.all([responsesPromise]); - - return ( - <Shell className="gap-6"> - <div className="flex justify-between items-center"> - <div> - <h2 className="text-2xl font-bold tracking-tight">RFQ 응답 관리</h2> - <p className="text-muted-foreground"> - RFQ 첨부파일 응답 현황을 확인하고 관리합니다. - </p> - </div> - </div> - - {/* 상태별 통계 카드 */} - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5"> - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">전체 요청</CardTitle> - <FileX className="h-4 w-4 text-muted-foreground" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{progress.totalRequests}건</div> - <p className="text-xs text-muted-foreground"> - 총 응답 요청 수 - </p> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">미응답</CardTitle> - <Clock className="h-4 w-4 text-orange-600" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-orange-600"> - {statusCounts.NOT_RESPONDED || 0}건 - </div> - <p className="text-xs text-muted-foreground"> - 응답 대기 중 - </p> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">응답완료</CardTitle> - <CheckCircle className="h-4 w-4 text-green-600" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-green-600"> - {statusCounts.RESPONDED || 0}건 - </div> - <p className="text-xs text-muted-foreground"> - 응답률: {progress.responseRate}% - </p> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">수정요청</CardTitle> - <AlertTriangle className="h-4 w-4 text-yellow-600" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-yellow-600"> - {statusCounts.REVISION_REQUESTED || 0}건 - </div> - <p className="text-xs text-muted-foreground"> - 재검토 필요 - </p> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">포기</CardTitle> - <FileX className="h-4 w-4 text-gray-600" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-gray-600"> - {statusCounts.WAIVED || 0}건 - </div> - <p className="text-xs text-muted-foreground"> - 완료율: {progress.completionRate}% - </p> - </CardContent> - </Card> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "12rem", "8rem", "10rem", "10rem", "8rem", "10rem", "8rem"]} - /> - } - > - <VendorResponsesTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx deleted file mode 100644 index 5b52e4a4..00000000 --- a/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// app/vendor/quotations/[id]/page.tsx - 견적 응답 페이지 -import { Metadata } from "next" -import { notFound } from "next/navigation" -import db from "@/db/db"; -import { eq } from "drizzle-orm" -import { procurementVendorQuotations } from "@/db/schema" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import VendorQuotationEditor from "@/lib/procurement-rfqs/vendor-response/quotation-editor"; - - -interface PageProps { - params: Promise<{ - id: string - }> -} - -export async function generateMetadata(props: PageProps): Promise<Metadata> { - return { - title: "견적서 응답", - description: "RFQ에 대한 견적서 작성 및 제출", - } -} - -export default async function VendorQuotationPage(props: PageProps) { - const params = await props.params - const quotationId = parseInt(params.id) - - if (isNaN(quotationId)) { - notFound() - } - - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - return ( - <div className="flex h-full items-center justify-center"> - <div className="text-center"> - <h2 className="text-xl font-bold">로그인이 필요합니다</h2> - <p className="mt-2 text-muted-foreground">견적서 응답을 위해 로그인해주세요.</p> - </div> - </div> - ) - } - - // 견적서 정보 가져오기 - const quotation = await db.query.procurementVendorQuotations.findFirst({ - where: eq(procurementVendorQuotations.id, quotationId), - with: { - rfq: true, // 관계 설정 필요 - vendor: true, // 관계 설정 필요 - items: true, // 관계 설정 필요 - } - }) - - if (!quotation) { - notFound() - } - - // 벤더 권한 확인 (필요한 경우) - const isAuthorized = session.user.domain === "partners" && - session.user.companyId === quotation.vendorId - - if (!isAuthorized) { - return ( - <div className="flex h-full items-center justify-center"> - <div className="text-center"> - <h2 className="text-xl font-bold">접근 권한이 없습니다</h2> - <p className="mt-2 text-muted-foreground">이 견적서에 대한 권한이 없습니다.</p> - </div> - </div> - ) - } - - return ( - <div className="container py-8"> - <VendorQuotationEditor quotation={quotation} /> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/rfq-ship/page.tsx deleted file mode 100644 index 332cca2d..00000000 --- a/app/[lng]/partners/(partners)/rfq-ship/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// app/vendor/quotations/page.tsx -import * as React from "react"; -import Link from "next/link"; -import { Metadata } from "next"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { LogIn } from "lucide-react"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { Shell } from "@/components/shell"; -import { getValidFilters } from "@/lib/data-table"; -import { type SearchParams } from "@/types/table"; -import { searchParamsVendorRfqCache } from "@/lib/procurement-rfqs/validations"; -import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/procurement-rfqs/services"; -import { VendorQuotationsTable } from "@/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table"; -import { InformationButton } from "@/components/information/information-button" -export const metadata: Metadata = { - title: "견적 목록", - description: "진행 중인 견적서 목록", -}; - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsVendorRfqCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - // 인증 확인 - const session = await getServerSession(authOptions); - - // 로그인 확인 - if (!session || !session.user) { - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 견적 목록 - </h2> - <InformationButton pagePath="partners/rfq-ship" /> - </div> - {/* <p className="text-muted-foreground"> - 진행 중인 견적서 목록을 확인하고 관리합니다. - </p> */} - </div> - </div> - - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - 견적서를 확인하려면 먼저 로그인하세요. - </p> - <Button size="lg" asChild> - <Link href="/partners?callbackUrl=/vendor/quotations"> - <LogIn className="mr-2 h-4 w-4" /> - 로그인하기 - </Link> - </Button> - </div> - </div> - </Shell> - ); - } - - // 벤더 ID 확인 - const vendorId = session.user.companyId ? String(session.user.companyId) : "0"; - - // 벤더 권한 확인 - if (session.user.domain !== "partners") { - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 접근 권한 없음 - </h2> - </div> - </div> - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">벤더 계정이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - 벤더 계정으로 로그인해주세요. - </p> - </div> - </div> - </Shell> - ); - } - - // 데이터 가져오기 - const quotationsPromise = getVendorQuotations({ - ...search, - filters: validFilters - }, vendorId); - - // 상태별 개수 가져오기 - const statusCountsPromise = getQuotationStatusCounts(vendorId); - - // 모든 프로미스 병렬 실행 - const promises = Promise.all([quotationsPromise]); - const statusCounts = await statusCountsPromise; - - return ( - <Shell className="gap-6"> - <div className="flex justify-between items-center"> - <div> - <h2 className="text-2xl font-bold tracking-tight">견적 목록</h2> - <p className="text-muted-foreground"> - 진행 중인 견적서 목록을 확인하고 관리합니다. - </p> - </div> - </div> - - <div className="grid gap-4 md:grid-cols-4"> - <Card> - <CardHeader className="py-4"> - <CardTitle className="text-base">전체 견적</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold"> - {Object.values(statusCounts).reduce((sum, count) => sum + count, 0)}건 - </div> - </CardContent> - </Card> - <Card> - <CardHeader className="py-4"> - <CardTitle className="text-base">작성 중</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{statusCounts.Draft || 0}건</div> - </CardContent> - </Card> - <Card> - <CardHeader className="py-4"> - <CardTitle className="text-base">제출됨</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold"> - {(statusCounts.Submitted || 0) + (statusCounts.Revised || 0)}건 - </div> - </CardContent> - </Card> - <Card> - <CardHeader className="py-4"> - <CardTitle className="text-base">승인됨</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{statusCounts.Accepted || 0}건</div> - </CardContent> - </Card> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={7} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "10rem", "8rem", "10rem", "10rem", "10rem", "8rem"]} - /> - } - > - <VendorQuotationsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq/page.tsx b/app/[lng]/partners/(partners)/rfq/page.tsx deleted file mode 100644 index 5cdb1dde..00000000 --- a/app/[lng]/partners/(partners)/rfq/page.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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 { searchParamsRfqsForVendorsCache } from "@/lib/rfqs/validations" -import { RfqsVendorTable } from "@/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { LogIn } from "lucide-react" -import { getRfqResponsesForVendor } from "@/lib/vendor-rfq-response/service" -import { InformationButton } from "@/components/information/information-button" -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsRfqsForVendorsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // Get session - const session = await getServerSession(authOptions) - - // Check if user is logged in - if (!session || !session.user) { - // Return login required UI instead of redirecting - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - {/* <p className="text-muted-foreground"> - RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. - </p> */} - </div> - </div> - - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - RFQ를 확인하려면 먼저 로그인하세요. - </p> - <Button size="lg" asChild> - <Link href="/partners"> - <LogIn className="mr-2 h-4 w-4" /> - 로그인하기 - </Link> - </Button> - </div> - </div> - </Shell> - ) - } - - // User is logged in, proceed with vendor ID - const vendorId = session.user.companyId - - // Validate vendorId (should be a number) - const idAsNumber = Number(vendorId) - - if (isNaN(idAsNumber)) { - // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - </div> - </div> - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> - <p className="mb-6 text-muted-foreground"> - 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요. - </p> - </div> - </div> - </Shell> - ) - } - - // If we got here, we have a valid vendor ID - const promises = Promise.all([ - getRfqResponsesForVendor({ - ...search, - filters: validFilters, - }, idAsNumber) - ]) - - 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> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - <InformationButton pagePath="partners/rfq" /> - </div> - {/* <p className="text-muted-foreground"> - RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker can go here */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsVendorTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/tbe/page.tsx b/app/[lng]/partners/(partners)/tbe/page.tsx deleted file mode 100644 index 38c24624..00000000 --- a/app/[lng]/partners/(partners)/tbe/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBEforVendor } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { TbeVendorTable } from "@/lib/vendor-rfq-response/vendor-tbe-table/tbe-table" -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { InformationButton } from "@/components/information/information-button" -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const session = await getServerSession(authOptions) - const vendorId = session?.user.companyId - // const vendorId = "17" - - const idAsNumber = Number(vendorId) - - const promises = Promise.all([ - getTBEforVendor({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - - 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> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - TBE 관리 - </h2> - <InformationButton pagePath="partners/tbe" /> - </div> - {/* <p className="text-sm text-muted-foreground"> - TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} - </p> */} - </div> - </div> - </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 - /> - } - > - <TbeVendorTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx deleted file mode 100644 index e69de29b..00000000 --- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx +++ /dev/null diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx deleted file mode 100644 index 1af65fbc..00000000 --- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table" -import { getInitialRfqDetail } from "@/lib/b-rfq/service" -import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsInitialRfqDetailCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = getInitialRfqDetail({ - ...search, - filters: validFilters, - }, idAsNumber) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Initial RFQ List - </h3> - <p className="text-sm text-muted-foreground"> - 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다. - </p> - </div> - <Separator /> - <div> - <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx deleted file mode 100644 index d6836437..00000000 --- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import { RfqDashboardView } from "@/db/schema" -import { findBRfqById } from "@/lib/b-rfq/service" - -export const metadata: Metadata = { - title: "견적 RFQ 상세", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqDashboardView | null = await findBRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "견적/입찰 문서관리", - href: `/${lng}/evcp/b-rfq/${id}`, - }, - { - title: "Initial RFQ 발송", - href: `/${lng}/evcp/b-rfq/${id}/initial`, - }, - { - title: "Final RFQ 발송", - href: `/${lng}/evcp/b-rfq/${id}/final`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/b-rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - PR발행 전 RFQ를 생성하여 관리하는 화면입니다. - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx deleted file mode 100644 index 26dc45fb..00000000 --- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations" -import { getRfqAttachments } from "@/lib/b-rfq/service" -import { RfqAttachmentsTable } from "@/lib/b-rfq/attachment/attachment-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsRfqAttachmentsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = getRfqAttachments({ - ...search, - filters: validFilters, - }, idAsNumber) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 견적 RFQ 문서관리 - </h3> - <p className="text-sm text-muted-foreground"> - 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다. - </p> - </div> - <Separator /> - <div> - <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/b-rfq/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/page.tsx deleted file mode 100644 index a66d7b58..00000000 --- a/app/[lng]/procurement/(procurement)/b-rfq/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { searchParamsRFQDashboardCache } from "@/lib/b-rfq/validations" -import { getRFQDashboard } from "@/lib/b-rfq/service" -import { RFQDashboardTable } from "@/lib/b-rfq/summary-table/summary-rfq-table" - -export const metadata: Metadata = { - title: "견적 RFQ", - description: "", -} - -interface PQReviewPageProps { - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - const searchParams = await props.searchParams - const search = searchParamsRFQDashboardCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getRFQDashboard({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - console.log(search, "견적") - - return ( - <Shell className="gap-4"> - <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"> - 견적 RFQ - </h2> - </div> - </div> - </div> - - {/* Items처럼 직접 테이블 렌더링 */} - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQDashboardTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx b/app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx deleted file mode 100644 index 26108323..00000000 --- a/app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getBasicContractTemplates } from "@/lib/basic-contract/service" -import { searchParamsTemplatesCache } from "@/lib/basic-contract/validations" -import { BasicContractTemplateTable } from "@/lib/basic-contract/template/basic-contract-template" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsTemplatesCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getBasicContractTemplates({ - ...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"> - 기본 계약문서 관리 - </h2> - {/* <p className="text-muted-foreground"> - 기본계약서를 비롯하여 초기 서명이 필요한 문서를 등록하고 편집할 수 있습니다. 활성화된 템플릿이 서명 요청의 리스트에 나타나게 됩니다..{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <BasicContractTemplateTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/basic-contract/page.tsx b/app/[lng]/procurement/(procurement)/basic-contract/page.tsx deleted file mode 100644 index 19211d4e..00000000 --- a/app/[lng]/procurement/(procurement)/basic-contract/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getBasicContracts } from "@/lib/basic-contract/service" -import { searchParamsCache } from "@/lib/basic-contract/validations" -import { BasicContractsTable } from "@/lib/basic-contract/status/basic-contract-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getBasicContracts({ - ...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"> - 기본계약서 서명 현황 - </h2> - {/* <p className="text-muted-foreground"> - 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명된 문서들을 다운로드할 수 있습니다. {" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <BasicContractsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/bqcbe/page.tsx b/app/[lng]/procurement/(procurement)/bqcbe/page.tsx deleted file mode 100644 index 831bb5a8..00000000 --- a/app/[lng]/procurement/(procurement)/bqcbe/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllCBE } from "@/lib/rfqs/service" -import { searchParamsCBECache } from "@/lib/rfqs/validations" - -import { AllCbeTable } from "@/lib/cbe/table/cbe-table" - -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getAllCBE({ - ...search, - filters: validFilters, - rfqType - } - ) - ]) - - // 4) 렌더링 - 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"> - CBE 관리 - </h2> - {/* <p className="text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllCbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/bqtbe/page.tsx b/app/[lng]/procurement/(procurement)/bqtbe/page.tsx deleted file mode 100644 index 3e56cfaa..00000000 --- a/app/[lng]/procurement/(procurement)/bqtbe/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { AllTbeTable } from "@/lib/tbe/table/tbe-table" -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getAllTBE({ - ...search, - filters: validFilters, - rfqType - } - ) - ]) - - // 4) 렌더링 - 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"> - TBE 관리 - </h2> - {/* <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx deleted file mode 100644 index 956facd3..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBE, getTBE } from "@/lib/rfqs/service" -import { searchParamsCBECache, } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx deleted file mode 100644 index 2b80e64f..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/budgetary/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/budgetary/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/budgetary/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/budgetary-rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>Budgetary RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx deleted file mode 100644 index dd9df563..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" -import { RfqType } from "@/lib/rfqs/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx deleted file mode 100644 index ec894e1c..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx deleted file mode 100644 index f342bbff..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" -import { Ellipsis } from "lucide-react" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.PURCHASE_BUDGETARY, - title = "Budgetary Quote", - description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - 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"> - {title} - </h2> - {/* <p className="text-muted-foreground"> - {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx deleted file mode 100644 index 956facd3..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBE, getTBE } from "@/lib/rfqs/service" -import { searchParamsCBECache, } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx deleted file mode 100644 index d58d8363..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/budgetary/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/budgetary/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/budgetary/${id}/cbe`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/budgetary`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>Budgetary Quote 목록으로 돌아가기</span> - </Button> - </Link> - </div> - - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx deleted file mode 100644 index dd9df563..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" -import { RfqType } from "@/lib/rfqs/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx deleted file mode 100644 index ec894e1c..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/page.tsx deleted file mode 100644 index 15b4cdd4..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" -import { Ellipsis } from "lucide-react" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.BUDGETARY, - title = "Budgetary Quote", - description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - 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"> - {title} - </h2> - {/* <p className="text-muted-foreground"> - {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/dashboard/page.tsx b/app/[lng]/procurement/(procurement)/dashboard/page.tsx deleted file mode 100644 index 1d61dc16..00000000 --- a/app/[lng]/procurement/(procurement)/dashboard/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// app/invalid-access/page.tsx - -export default function InvalidAccessPage() { - return ( - <main style={{ padding: '40px', textAlign: 'center' }}> - <h1>부적절한 접근입니다</h1> - <p> - 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br /> - SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다. - </p> - <p> - <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong> - </p> - </main> - ); - } -
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/equip-class/page.tsx b/app/[lng]/procurement/(procurement)/equip-class/page.tsx deleted file mode 100644 index 34fd32b6..00000000 --- a/app/[lng]/procurement/(procurement)/equip-class/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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/equip-class/validation" -import { FormListsTable } from "@/lib/form-list/table/formLists-table" -import { getTagClassists } from "@/lib/equip-class/service" -import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTagClassists({ - ...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"> - 객체 클래스 목록 from S-EDP - </h2> - {/* <p className="text-muted-foreground"> - 객체 클래스 목록을 확인할 수 있습니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <EquipClassTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/esg-check-list/page.tsx b/app/[lng]/procurement/(procurement)/esg-check-list/page.tsx deleted file mode 100644 index 8bccd3b7..00000000 --- a/app/[lng]/procurement/(procurement)/esg-check-list/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getEsgEvaluations } from "@/lib/esg-check-list/service" -import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation" -import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getEsgEvaluationsSchema.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getEsgEvaluations({ - ...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"> - ESG 자가진단평가서 항목 관리 - </h2> - {/* <p className="text-muted-foreground"> - 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <EsgEvaluationsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx deleted file mode 100644 index 45da961b..00000000 --- a/app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* IMPORT */
-import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
-import { getRegEvalCriteria } from '@/lib/evaluation-criteria/service';
-import { getValidFilters } from '@/lib/data-table';
-import RegEvalCriteriaTable from '@/lib/evaluation-criteria/table/reg-eval-criteria-table';
-import { searchParamsCache } from '@/lib/evaluation-criteria/validations';
-import { Shell } from '@/components/shell';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Suspense } from 'react';
-import { type SearchParams } from '@/types/table';
-
-// ----------------------------------------------------------------------------------------------------
-
-/* TYPES */
-interface EvaluationCriteriaPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* REGULAR EVALUATION CRITERIA PAGE */
-async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
- const searchParams = await props.searchParams;
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
- const promises = Promise.all([
- getRegEvalCriteria({
- ...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">
- 협력업체 평가기준표 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 평가기준표를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </Suspense>
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={11}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RegEvalCriteriaTable promises={promises} />
- </Suspense>
- </Shell>
- )
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* EXPORT */
-export default EvaluationCriteriaPage;
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx deleted file mode 100644 index 3a403620..00000000 --- a/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { EvaluationPage } from "@/lib/evaluation-submit/evaluation-page" -import { Metadata } from "next" - -export const metadata: Metadata = { - title: "평가 작성", - description: "협력업체 평가를 작성합니다", -} - -interface PageProps { - params: { - id: string - } -} - -export default function Page({ params }: PageProps) { - return <EvaluationPage /> -} - -export async function generateStaticParams() { - // 동적 경로이므로 빈 배열 반환 - return [] -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx deleted file mode 100644 index 00f1820f..00000000 --- a/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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 { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { LogIn } from "lucide-react" -import { getSHIEvaluationSubmissions } from "@/lib/evaluation-submit/service" -import { getSHIEvaluationsSubmitSchema } from "@/lib/evaluation-submit/validation" -import { SHIEvaluationSubmissionsTable } from "@/lib/evaluation-submit/table/submit-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getSHIEvaluationsSubmitSchema.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // Get session - const session = await getServerSession(authOptions) - - // Check if user is logged in - if (!session || !session.user) { - // Return login required UI instead of redirecting - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 정기평가 - </h2> - </div> - {/* <p className="text-muted-foreground"> - 요청된 정기평가를 입력하고 제출할 수 있습니다. - </p> */} - </div> - </div> - - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - 정기평가를 확인하려면 먼저 로그인하세요. - </p> - <Button size="lg" asChild> - <Link href="/partners"> - <LogIn className="mr-2 h-4 w-4" /> - 로그인하기 - </Link> - </Button> - </div> - </div> - </Shell> - ) - } - - const userId = session.user.id - - // Validate vendorId (should be a number) - const idAsNumber = Number(userId) - - - if (isNaN(idAsNumber)) { - // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 정기평가 - </h2> - </div> - </div> - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> - <p className="mb-6 text-muted-foreground"> - 관리자에게 문의하세요. - </p> - </div> - </div> - </Shell> - ) - } - - // If we got here, we have a valid vendor ID - const promises = Promise.all([ - getSHIEvaluationSubmissions({ - ...search, - filters: validFilters, - }, idAsNumber) - ]) - - 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"> - 정기평가 - </h2> - {/* <p className="text-muted-foreground"> - 요청된 정기평가를 입력하고 제출할 수 있습니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker can go here */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <SHIEvaluationSubmissionsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx deleted file mode 100644 index a0523eea..00000000 --- a/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { HelpCircle } from "lucide-react" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" - -import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation" -import { getEvaluationTargets } from "@/lib/evaluation-target-list/service" -import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table" -import { InformationButton } from "@/components/information/information-button" -export const metadata: Metadata = { - title: "협력업체 평가 대상 관리", - description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.", -} - -interface EvaluationTargetsPageProps { - searchParams: Promise<SearchParams> -} - - - -export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) { - const searchParams = await props.searchParams - const search = searchParamsEvaluationTargetsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // 현재 평가년도 (필터에서 가져오거나 기본값 사용) - const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getEvaluationTargets({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - {/* 간소화된 헤더 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 평가 대상 관리 - </h2> - <InformationButton pagePath="evcp/evaluation-target-list" /> - </div> - <Badge variant="outline" className="text-sm"> - {currentEvaluationYear}년도 - </Badge> - - </div> - </div> - </div> - - {/* 메인 테이블 (통계는 테이블 내부로 이동) */} - <React.Suspense - key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 - fallback={ - <DataTableSkeleton - columnCount={12} - searchableColumnCount={2} - filterableColumnCount={6} - cellWidths={[ - "3rem", // checkbox - "5rem", // 평가년도 - "4rem", // 구분 - "8rem", // 벤더코드 - "12rem", // 벤더명 - "4rem", // 내외자 - "6rem", // 자재구분 - "5rem", // 상태 - "5rem", // 의견일치 - "8rem", // 담당자현황 - "10rem", // 관리자의견 - "8rem" // actions - ]} - shrinkZero - /> - } - > - {currentEvaluationYear && - <EvaluationTargetsTable - promises={promises} - evaluationYear={currentEvaluationYear} - /> -} - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/evaluation/page.tsx b/app/[lng]/procurement/(procurement)/evaluation/page.tsx deleted file mode 100644 index 2d8cbed7..00000000 --- a/app/[lng]/procurement/(procurement)/evaluation/page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -// ================================================================ -// 4. PERIODIC EVALUATIONS PAGE -// ================================================================ - -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { HelpCircle } from "lucide-react" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table" -import { getPeriodicEvaluations } from "@/lib/evaluation/service" -import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation" - -export const metadata: Metadata = { - title: "협력업체 정기평가", - description: "협력업체 정기평가 진행 현황을 관리합니다.", -} - -interface PeriodicEvaluationsPageProps { - searchParams: Promise<SearchParams> -} - -// 프로세스 안내 팝오버 컴포넌트 -function ProcessGuidePopover() { - return ( - <Popover> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon" className="h-6 w-6"> - <HelpCircle className="h-4 w-4 text-muted-foreground" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-96" align="start"> - <div className="space-y-3"> - <div className="space-y-1"> - <h4 className="font-medium">정기평가 프로세스</h4> - {/* <p className="text-sm text-muted-foreground"> - 확정된 평가 대상 업체들에 대한 정기평가 절차입니다. - </p> */} - </div> - <div className="space-y-3 text-sm"> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 1 - </div> - <div> - <p className="font-medium">평가 대상 확정</p> - <p className="text-muted-foreground">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 2 - </div> - <div> - <p className="font-medium">업체 자료 제출</p> - <p className="text-muted-foreground">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 3 - </div> - <div> - <p className="font-medium">평가자 검토</p> - <p className="text-muted-foreground">지정된 평가자들이 평가표를 기반으로 점수를 매기고 검토합니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 4 - </div> - <div> - <p className="font-medium">최종 확정</p> - <p className="text-muted-foreground">모든 평가가 완료되면 최종 점수와 등급이 확정됩니다.</p> - </div> - </div> - </div> - </div> - </PopoverContent> - </Popover> - ) -} - -// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함 -function getDefaultEvaluationYear() { - return new Date().getFullYear() -} - - - -export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) { - const searchParams = await props.searchParams - const search = searchParamsEvaluationsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters || []) - - // 기본 필터 처리 - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // 현재 평가년도 - const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getPeriodicEvaluations({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - {/* 헤더 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 정기평가 - </h2> - <Badge variant="outline" className="text-sm"> - {currentEvaluationYear}년도 - </Badge> - </div> - </div> - </div> - - {/* 메인 테이블 */} - <React.Suspense - key={JSON.stringify(searchParams)} - fallback={ - <DataTableSkeleton - columnCount={15} - searchableColumnCount={2} - filterableColumnCount={8} - cellWidths={[ - "3rem", // checkbox - "5rem", // 평가년도 - "5rem", // 평가기간 - "4rem", // 구분 - "8rem", // 벤더코드 - "12rem", // 벤더명 - "4rem", // 내외자 - "6rem", // 자재구분 - "5rem", // 문서제출 - "4rem", // 제출일 - "4rem", // 마감일 - "4rem", // 총점 - "4rem", // 등급 - "5rem", // 진행상태 - "8rem" // actions - ]} - shrinkZero - /> - } - > - <PeriodicEvaluationsTable - promises={promises} - evaluationYear={currentEvaluationYear} - /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/faq/manage/actions.ts b/app/[lng]/procurement/(procurement)/faq/manage/actions.ts deleted file mode 100644 index bc443a8a..00000000 --- a/app/[lng]/procurement/(procurement)/faq/manage/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use server';
-
-import { promises as fs } from 'fs';
-import path from 'path';
-import { FaqCategory } from '@/components/faq/FaqCard';
-import { fallbackLng } from '@/i18n/settings';
-
-const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
-
-export async function updateFaqData(lng: string, newData: FaqCategory[]) {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- const updatedData = {
- ...allData,
- [lng]: newData
- };
-
- const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
- await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
-
- return { success: true };
- } catch (error) {
- console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
- return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
- }
-}
-
-export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- return { data: allData[lng] || allData[fallbackLng] || [] };
- } catch (error) {
- console.error('FAQ 데이터 읽기 중 오류 발생:', error);
- return { data: [] };
- }
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/faq/manage/page.tsx b/app/[lng]/procurement/(procurement)/faq/manage/page.tsx deleted file mode 100644 index 011bbfa4..00000000 --- a/app/[lng]/procurement/(procurement)/faq/manage/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FaqManager } from '@/components/faq/FaqManager';
-import { getFaqData, updateFaqData } from './actions';
-import { revalidatePath } from 'next/cache';
-import { FaqCategory } from '@/components/faq/FaqCard';
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqManagePage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const { data } = await getFaqData(lng);
-
- async function handleSave(newData: FaqCategory[]) {
- 'use server';
- await updateFaqData(lng, newData);
- revalidatePath(`/${lng}/evcp/faq`);
- }
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
- <p className="text-muted-foreground">
- Manage FAQ categories and items for {lng.toUpperCase()} language.
- </p>
- </div>
- <FaqManager initialData={data} onSave={handleSave} lng={lng} />
- </div>
- </section>
- </div>
- );
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/faq/page.tsx b/app/[lng]/procurement/(procurement)/faq/page.tsx deleted file mode 100644 index 00956591..00000000 --- a/app/[lng]/procurement/(procurement)/faq/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { faqCategories } from "@/config/faqDataConfig"
-import { FaqCard } from "@/components/faq/FaqCard"
-import { Button } from "@/components/ui/button"
-import { Settings } from "lucide-react"
-import Link from "next/link"
-import { fallbackLng } from "@/i18n/settings"
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqPage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="flex justify-between items-center">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ</h2>
- {/* <p className="text-muted-foreground">
- Find answers to common questions about using the EVCP system.
- </p> */}
- </div>
- <Link href={`/${lng}/evcp/faq/manage`}>
- <Button variant="outline">
- <Settings className="w-4 h-4 mr-2" />
- FAQ 관리
- </Button>
- </Link>
- </div>
- <Separator className="my-6" />
-
- <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
- <TabsList>
- {localizedFaqCategories.map((category) => (
- <TabsTrigger key={category.label} value={category.label}>
- {category.label}
- </TabsTrigger>
- ))}
- </TabsList>
-
- {localizedFaqCategories.map((category) => (
- <TabsContent key={category.label} value={category.label} className="space-y-4">
- {category.items.map((item, index) => (
- <FaqCard key={index} item={item} />
- ))}
- </TabsContent>
- ))}
- </Tabs>
- </div>
- </section>
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/incoterms/page.tsx b/app/[lng]/procurement/(procurement)/incoterms/page.tsx deleted file mode 100644 index 804bc5af..00000000 --- a/app/[lng]/procurement/(procurement)/incoterms/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { getValidFilters } from "@/lib/data-table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { SearchParamsCache } from "@/lib/incoterms/validations"; -import { getIncoterms } from "@/lib/incoterms/service"; -import { IncotermsTable } from "@/lib/incoterms/table/incoterms-table"; - -interface IndexPageProps { - searchParams: Promise<SearchParams>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - const search = SearchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - const promises = Promise.all([ - getIncoterms({ - ...search, - filters: validFilters, - }), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight">인코텀즈 관리</h2> - {/* <p className="text-muted-foreground"> - 인코텀즈(Incoterms)를 등록, 수정, 삭제할 수 있습니다. - </p> */} - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <IncotermsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/items-tech/layout.tsx b/app/[lng]/procurement/(procurement)/items-tech/layout.tsx deleted file mode 100644 index d375059b..00000000 --- a/app/[lng]/procurement/(procurement)/items-tech/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"
-import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default function ItemsShipLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // 아이템 타입 정의
- const itemTypes = [
- { id: "ship", name: "조선 아이템" },
- { id: "top", name: "해양 TOP" },
- { id: "hull", name: "해양 HULL" },
- ]
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ItemTechContainer itemTypes={itemTypes}>
- {children}
- </ItemTechContainer>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/items-tech/page.tsx b/app/[lng]/procurement/(procurement)/items-tech/page.tsx deleted file mode 100644 index 55ac9c63..00000000 --- a/app/[lng]/procurement/(procurement)/items-tech/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { shipbuildingSearchParamsCache, offshoreTopSearchParamsCache, offshoreHullSearchParamsCache } from "@/lib/items-tech/validations"
-import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
-import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
-import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
-
-// 대소문자 문제 해결 - 실제 파일명에 맞게 import
-import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage({ searchParams }: IndexPageProps) {
- const params = await searchParams
- const shipbuildingSearch = shipbuildingSearchParamsCache.parse(params)
- const offshoreTopSearch = offshoreTopSearchParamsCache.parse(params)
- const offshoreHullSearch = offshoreHullSearchParamsCache.parse(params)
- const validShipbuildingFilters = getValidFilters(shipbuildingSearch.filters || [])
- const validOffshoreTopFilters = getValidFilters(offshoreTopSearch.filters || [])
- const validOffshoreHullFilters = getValidFilters(offshoreHullSearch.filters || [])
-
-
- // URL에서 아이템 타입 가져오기
- const itemType = params.type || "ship"
-
- return (
- <div>
- {itemType === "ship" && (
- <ItemsShipTable
- promises={Promise.all([
- getShipbuildingItems({
- ...shipbuildingSearch,
- filters: validShipbuildingFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "top" && (
- <OffshoreTopTable
- promises={Promise.all([
- getOffshoreTopItems({
- ...offshoreTopSearch,
- filters: validOffshoreTopFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "hull" && (
- <OffshoreHullTable
- promises={Promise.all([
- getOffshoreHullItems({
- ...offshoreHullSearch,
- filters: validOffshoreHullFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/items/page.tsx b/app/[lng]/procurement/(procurement)/items/page.tsx deleted file mode 100644 index f8d9a5b1..00000000 --- a/app/[lng]/procurement/(procurement)/items/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// app/items/page.tsx (업데이트) -import * as React from "react" -import { type SearchParams } from "@/types/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/items/validations" -import { getItems } from "@/lib/items/service" -import { ItemsTable } from "@/lib/items/table/items-table" -import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // pageSize 기반으로 모드 자동 결정 - const isInfiniteMode = search.perPage >= 1_000_000 - - // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 - // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 - const promises = isInfiniteMode - ? undefined - : Promise.all([ - getItems(search), // searchParamsCache의 결과를 그대로 사용 - ]) - - 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"> - 패키지 넘버 - </h2> - {/* <p className="text-muted-foreground"> - S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다. - </p> */} - </div> - </div> - - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker 등 추가 컴포넌트 */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - {/* 통합된 ItemsTable 컴포넌트 사용 */} - <ItemsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/layout.tsx b/app/[lng]/procurement/(procurement)/layout.tsx deleted file mode 100644 index 82b53307..00000000 --- a/app/[lng]/procurement/(procurement)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ReactNode } from 'react'; -import { Header } from '@/components/layout/Header'; -import { SiteFooter } from '@/components/layout/Footer'; - -export default function EvcpLayout({ children }: { children: ReactNode }) { - return ( - <div className="relative flex min-h-svh flex-col bg-background"> - {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */} - <Header /> - <main className="flex flex-1 flex-col"> - <div className='container-wrapper'> - {children} - </div> - </main> - <SiteFooter/> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/menu-list/page.tsx b/app/[lng]/procurement/(procurement)/menu-list/page.tsx deleted file mode 100644 index dee45ab1..00000000 --- a/app/[lng]/procurement/(procurement)/menu-list/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// app/evcp/menu-list/page.tsx - -import { Suspense } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { RefreshCw, Settings } from "lucide-react"; -import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie"; -import { InitializeButton } from "@/lib/menu-list/table/initialize-button"; -import { MenuListTable } from "@/lib/menu-list/table/menu-list-table"; -import { Shell } from "@/components/shell" -import * as React from "react" - -export default async function MenuListPage() { - // 초기 데이터 로드 - const [menusResult, usersResult] = await Promise.all([ - getMenuAssignments(), - getActiveUsers() - ]); - - 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"> - 메뉴 관리 - </h2> - {/* <p className="text-muted-foreground"> - 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다. - </p> */} - </div> - </div> - - </div> - - - <React.Suspense - fallback={ - "" - } - > - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Settings className="h-5 w-5" /> - 메뉴 리스트 - </CardTitle> - <CardDescription> - 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다. - {menusResult.data?.length > 0 && ( - <span className="ml-2 text-sm"> - 총 {menusResult.data.length}개의 메뉴 - </span> - )} - </CardDescription> - </CardHeader> - <CardContent> - <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}> - <MenuListTable - initialMenus={menusResult.data || []} - initialUsers={usersResult.data || []} - /> - </Suspense> - </CardContent> - </Card> - </React.Suspense> - </Shell> - - ); -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/payment-conditions/page.tsx b/app/[lng]/procurement/(procurement)/payment-conditions/page.tsx deleted file mode 100644 index d001a39d..00000000 --- a/app/[lng]/procurement/(procurement)/payment-conditions/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { getValidFilters } from "@/lib/data-table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { SearchParamsCache } from "@/lib/payment-terms/validations"; -import { getPaymentTerms } from "@/lib/payment-terms/service"; -import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-table"; - -interface IndexPageProps { - searchParams: Promise<SearchParams>; -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams; - const search = SearchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - const promises = Promise.all([ - getPaymentTerms({ - ...search, - filters: validFilters, - }), - ]); - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight">지급 조건 관리</h2> - {/* <p className="text-muted-foreground"> - 지급 조건(Payment Terms)을 등록, 수정, 삭제할 수 있습니다. - </p> */} - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <PaymentTermsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/po-rfq/page.tsx b/app/[lng]/procurement/(procurement)/po-rfq/page.tsx deleted file mode 100644 index 4a04d6a8..00000000 --- a/app/[lng]/procurement/(procurement)/po-rfq/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { getPORfqs } from "@/lib/procurement-rfqs/services" -import { searchParamsCache } from "@/lib/procurement-rfqs/validations" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface RfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: RfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 파라미터 파싱 - const search = searchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달 - const promises = Promise.all([ - getPORfqs({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/po/page.tsx b/app/[lng]/procurement/(procurement)/po/page.tsx deleted file mode 100644 index b4dd914f..00000000 --- a/app/[lng]/procurement/(procurement)/po/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 { getPOs } from "@/lib/po/service" -import { searchParamsCache } from "@/lib/po/validations" -import { PoListsTable } from "@/lib/po/table/po-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getPOs({ - ...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"> - PO 확인 및 전자서명 - </h2> - {/* <p className="text-muted-foreground"> - 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다. - - </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 - /> - } - > - <PoListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/poa/page.tsx b/app/[lng]/procurement/(procurement)/poa/page.tsx deleted file mode 100644 index 1c244991..00000000 --- a/app/[lng]/procurement/(procurement)/poa/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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 { getChangeOrders } from "@/lib/poa/service" -import { searchParamsCache } from "@/lib/poa/validations" -import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getChangeOrders({ - ...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"> - 변경 PO 확인 및 전자서명 - </h2> - {/* <p className="text-muted-foreground"> - 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다. - </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 - /> - } - > - <ChangeOrderListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx b/app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx deleted file mode 100644 index 15cb3bf3..00000000 --- a/app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { searchParamsCache } from "@/lib/pq/validations"
-import { getPQsByListId } from "@/lib/pq/service"
-import { PqsTable } from "@/lib/pq/pq-criteria/pq-table"
-import { notFound } from "next/navigation"
-
-interface PQDetailPageProps {
- params: Promise<{ pqListId: string }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function PQDetailPage(props: PQDetailPageProps) {
- const params = await props.params
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const pqListId = parseInt(params.pqListId)
- if (isNaN(pqListId)) {
- notFound()
- }
-
- // filters가 없는 경우를 처리
- const validFilters = getValidFilters(search.filters)
-
- // PQ 항목들 가져오기
- const promises = Promise.all([
- getPQsByListId(pqListId, {
- ...search,
- filters: validFilters,
- })
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- PQ 항목 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 선택한 PQ 목록의 세부 항목들을 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "20rem", "15rem", "10rem", "10rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PqsTable
- promises={promises}
- pqListId={pqListId}
- />
- </React.Suspense>
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/pq-criteria/page.tsx b/app/[lng]/procurement/(procurement)/pq-criteria/page.tsx deleted file mode 100644 index 1a337cc9..00000000 --- a/app/[lng]/procurement/(procurement)/pq-criteria/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { searchParamsCache } from "@/lib/pq/validations"
-import { getPQLists } from "@/lib/pq/service"
-import { PqListsTable } from "@/lib/pq/table/pq-lists-table"
-import { getProjects } from "@/lib/pq/service"
-
-interface ProjectPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function ProjectPage(props: ProjectPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- // filters가 없는 경우를 처리
- const validFilters = getValidFilters(search.filters)
-
- // // 프로젝트별 PQ 데이터 가져오기
- const promises = Promise.all([
- getPQLists({
- ...search,
- filters: validFilters,
- }),
- getProjects()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- PQ 리스트 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PqListsTable
- promises={promises}
- />
- </React.Suspense>
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx deleted file mode 100644 index b4b51363..00000000 --- a/app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import * as React from "react"
-import { Metadata } from "next"
-import Link from "next/link"
-import { notFound } from "next/navigation"
-import { ArrowLeft } from "lucide-react"
-import { Shell } from "@/components/shell"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Separator } from "@/components/ui/separator"
-import { getPQById, getPQDataByVendorId } from "@/lib/pq/service"
-import { unstable_noStore as noStore } from 'next/cache'
-import { PQReviewWrapper } from "@/components/pq-input/pq-review-wrapper"
-import { formatDate } from "@/lib/utils"
-
-export const metadata: Metadata = {
- title: "PQ 검토",
- description: "협력업체의 Pre-Qualification 답변을 검토합니다.",
-}
-
-// 페이지가 기본적으로 동적임을 나타냄
-export const dynamic = "force-dynamic"
-
-interface PQReviewPageProps {
- params: Promise<{
- vendorId: string;
- submissionId: string;
- }>
-}
-
-export default async function PQReviewPage(props: PQReviewPageProps) {
- // 캐시 비활성화
- noStore()
-
- const params = await props.params
- const vendorId = parseInt(params.vendorId, 10)
- const submissionId = parseInt(params.submissionId, 10)
-
- try {
- // PQ Submission 정보 조회
- const pqSubmission = await getPQById(submissionId, vendorId)
-
- // PQ 데이터 조회 (질문과 답변)
- const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined)
-
- // 프로젝트 정보 (프로젝트 PQ인 경우)
- const projectInfo = pqSubmission.projectId ? {
- id: pqSubmission.projectId,
- projectCode: pqSubmission.projectCode || '',
- projectName: pqSubmission.projectName || '',
- status: pqSubmission.status,
- submittedAt: pqSubmission.submittedAt,
- } : null
-
- // PQ 유형 및 상태 레이블
- const typeLabel = pqSubmission.type === "GENERAL" ? "일반 PQ" :
- pqSubmission.type === "PROJECT" ? "프로젝트 PQ" :
- pqSubmission.type === "NON_INSPECTION" ? "미실사 PQ" : "일반 PQ"
- const statusLabel = getStatusLabel(pqSubmission.status)
- const statusVariant = getStatusVariant(pqSubmission.status)
-
- // 수정 가능 여부 (SUBMITTED 상태일 때만 가능)
- const canReview = pqSubmission.status === "SUBMITTED"
-
- return (
- <Shell className="gap-6 max-w-5xl">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <Button variant="outline" size="sm" asChild>
- <Link href="/procurement/pq_new">
- <ArrowLeft className="w-4 h-4 mr-2" />
- 목록으로
- </Link>
- </Button>
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {pqSubmission.vendorName} - {typeLabel}
- </h2>
- <div className="flex items-center gap-2 mt-1">
- <Badge variant={statusVariant}>{statusLabel}</Badge>
- {projectInfo && (
- <span className="text-muted-foreground">
- {projectInfo.projectName} ({projectInfo.projectCode})
- </span>
- )}
- </div>
- </div>
- </div>
- </div>
-
- {/* 상태별 알림 */}
- {pqSubmission.status === "SUBMITTED" && (
- <Alert>
- <AlertTitle>제출 완료</AlertTitle>
- <AlertDescription>
- 협력업체가 {formatDate(pqSubmission.submittedAt, "kr")}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다.
- </AlertDescription>
- </Alert>
- )}
-
- {pqSubmission.status === "APPROVED" && (
- <Alert variant="success">
- <AlertTitle>승인됨</AlertTitle>
- <AlertDescription>
- {formatDate(pqSubmission.approvedAt, "kr")}에 승인되었습니다.
- </AlertDescription>
- </Alert>
- )}
-
- {pqSubmission.status === "REJECTED" && (
- <Alert variant="destructive">
- <AlertTitle>거부됨</AlertTitle>
- <AlertDescription>
- {formatDate(pqSubmission.rejectedAt, "kr")}에 거부되었습니다.
- {pqSubmission.rejectReason && (
- <div className="mt-2">
- <strong>사유:</strong> {pqSubmission.rejectReason}
- </div>
- )}
- </AlertDescription>
- </Alert>
- )}
-
- <Separator />
-
- {/* PQ 검토 컴포넌트 */}
- <Tabs defaultValue="review" className="w-full">
- <TabsList>
- <TabsTrigger value="review">PQ 검토</TabsTrigger>
- <TabsTrigger value="vendor-info">협력업체 정보</TabsTrigger>
- </TabsList>
-
- <TabsContent value="review" className="mt-4">
- <PQReviewWrapper
- pqData={pqData}
- vendorId={vendorId}
- pqSubmission={pqSubmission}
- canReview={canReview}
- />
- </TabsContent>
-
- <TabsContent value="vendor-info" className="mt-4">
- <div className="rounded-md border p-4">
- <h3 className="text-lg font-medium mb-4">협력업체 정보</h3>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <p className="text-sm font-medium text-muted-foreground">업체명</p>
- <p>{pqSubmission.vendorName}</p>
- </div>
- <div>
- <p className="text-sm font-medium text-muted-foreground">업체 코드</p>
- <p>{pqSubmission.vendorCode}</p>
- </div>
- <div>
- <p className="text-sm font-medium text-muted-foreground">상태</p>
- <p>{pqSubmission.vendorStatus}</p>
- </div>
- {/* 필요시 추가 정보 표시 */}
- </div>
- </div>
- </TabsContent>
- </Tabs>
- </Shell>
- )
- } catch (error) {
- console.error("Error loading PQ:", error)
- notFound()
- }
-}
-
-// 상태 레이블 함수
-function getStatusLabel(status: string): string {
- switch (status) {
- case "REQUESTED":
- return "요청됨";
- case "IN_PROGRESS":
- return "진행 중";
- case "SUBMITTED":
- return "제출됨";
- case "APPROVED":
- return "승인됨";
- case "REJECTED":
- return "거부됨";
- default:
- return status;
- }
-}
-
-// 상태별 Badge 스타일
-function getStatusVariant(status: string): "default" | "outline" | "secondary" | "destructive" | "success" {
- switch (status) {
- case "REQUESTED":
- return "outline";
- case "IN_PROGRESS":
- return "secondary";
- case "SUBMITTED":
- return "default";
- case "APPROVED":
- return "success";
- case "REJECTED":
- return "destructive";
- default:
- return "outline";
- }
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/pq_new/page.tsx b/app/[lng]/procurement/(procurement)/pq_new/page.tsx deleted file mode 100644 index 6a992ee5..00000000 --- a/app/[lng]/procurement/(procurement)/pq_new/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { searchParamsPQReviewCache } from "@/lib/pq/validations"
-import { getPQSubmissions } from "@/lib/pq/service"
-import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table"
-import { InformationButton } from "@/components/information/information-button"
-export const metadata: Metadata = {
- title: "협력업체 PQ/실사 현황",
- description: "",
-}
-
-interface PQReviewPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function PQReviewPage(props: PQReviewPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsPQReviewCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 디버깅 로그 추가
- console.log("=== PQ Page Debug ===");
- console.log("Raw searchParams:", searchParams);
- console.log("Raw basicFilters param:", searchParams.basicFilters);
- console.log("Raw pqBasicFilters param:", searchParams.pqBasicFilters);
- console.log("Parsed search:", search);
- console.log("search.filters:", search.filters);
- console.log("search.basicFilters:", search.basicFilters);
- console.log("search.pqBasicFilters:", search.pqBasicFilters);
- console.log("validFilters:", validFilters);
-
- // 기본 필터 처리 (통일된 이름 사용)
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- console.log("Using search.basicFilters:", basicFilters);
- } else if (search.pqBasicFilters && search.pqBasicFilters.length > 0) {
- // 하위 호환성을 위해 기존 이름도 지원
- basicFilters = search.pqBasicFilters
- console.log("Using search.pqBasicFilters:", basicFilters);
- } else {
- console.log("No basic filters found");
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- console.log("Final allFilters:", allFilters);
-
- // 조인 연산자도 통일된 이름 사용
- const joinOperator = search.basicJoinOperator || search.pqBasicJoinOperator || search.joinOperator || 'and';
- console.log("Final joinOperator:", joinOperator);
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getPQSubmissions({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- return (
- <Shell className="gap-4">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 PQ/실사 현황
- </h2>
- <InformationButton pagePath="evcp/pq_new" />
- </div>
- </div>
- </div>
- </div>
-
- {/* Items처럼 직접 테이블 렌더링 */}
- <React.Suspense
- key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PQSubmissionsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/project-gtc/page.tsx b/app/[lng]/procurement/(procurement)/project-gtc/page.tsx deleted file mode 100644 index 554f17b0..00000000 --- a/app/[lng]/procurement/(procurement)/project-gtc/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getProjectGtcList } from "@/lib/project-gtc/service" -import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations" -import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = projectGtcSearchParamsSchema.parse(searchParams) - - const promises = Promise.all([ - getProjectGtcList({ - page: search.page, - perPage: search.perPage, - search: search.search, - sort: search.sort, - }), - ]) - - 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"> - Project GTC 관리 - </h2> - {/* <p className="text-muted-foreground"> - 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다. - 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* 추가 기능이 필요하면 여기에 추가 */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]} - shrinkZero - /> - } - > - <ProjectGtcTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/project-vendors/page.tsx b/app/[lng]/procurement/(procurement)/project-vendors/page.tsx deleted file mode 100644 index 525cff07..00000000 --- a/app/[lng]/procurement/(procurement)/project-vendors/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table" -import { getProjecTAVL } from "@/lib/project-avl/service" -import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchProjectAVLParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getProjecTAVL({ - ...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"> - 프로젝트 AVL 리스트 - </h2> - {/* <p className="text-muted-foreground"> - 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <ProjectAVLTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/projects/page.tsx b/app/[lng]/procurement/(procurement)/projects/page.tsx deleted file mode 100644 index 8c332c6c..00000000 --- a/app/[lng]/procurement/(procurement)/projects/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 { ItemsTable } from "@/lib/items/table/items-table" -import { getProjectLists } from "@/lib/projects/service" -import { ProjectsTable } from "@/lib/projects/table/projects-table" -import { searchParamsProjectsCache } from "@/lib/projects/validation" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsProjectsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getProjectLists({ - ...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"> - 수행 프로젝트 리스트 from S-EDP - </h2> - {/* <p className="text-muted-foreground"> - S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <ProjectsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/report/page.tsx b/app/[lng]/procurement/(procurement)/report/page.tsx deleted file mode 100644 index 2782c3ac..00000000 --- a/app/[lng]/procurement/(procurement)/report/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Shell } from "@/components/shell"; -import { ErrorBoundary } from "@/components/error-boundary"; -import { getDashboardData } from "@/lib/dashboard/service"; -import { DashboardClient } from "@/lib/dashboard/dashboard-client"; - -// 데이터 fetch 시 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생. -// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리 -// getDashboardData 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨. -export const dynamic = 'force-dynamic' - -export default async function IndexPage() { - // domain을 명시적으로 전달 - const domain = "procurement"; - - try { - // 서버에서 직접 데이터 fetch - const dashboardData = await getDashboardData(domain); - - return ( - <Shell className="gap-2"> - <DashboardClient initialData={dashboardData} /> - </Shell> - ); - } catch (error) { - console.error("Dashboard data fetch error:", error); - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-center py-12"> - <div className="text-center space-y-2"> - <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> - <p className="text-muted-foregroucdnd text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> - </div> - </div> - </Shell> - ); - } -} - -function DashboardSkeleton() { - return ( - <div className="space-y-6"> - {/* 헤더 스켈레톤 */} - <div className="flex items-center justify-between"> - <div className="space-y-2"> - <Skeleton className="h-8 w-48" /> - <Skeleton className="h-4 w-72" /> - </div> - <Skeleton className="h-10 w-24" /> - </div> - - {/* 요약 카드 스켈레톤 */} - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> - {[...Array(4)].map((_, i) => ( - <div key={i} className="space-y-3 p-6 border rounded-lg"> - <div className="flex items-center justify-between"> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-4" /> - </div> - <Skeleton className="h-8 w-12" /> - <Skeleton className="h-3 w-20" /> - </div> - ))} - </div> - - {/* 차트 스켈레톤 */} - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - {[...Array(2)].map((_, i) => ( - <div key={i} className="space-y-4 p-6 border rounded-lg"> - <div className="space-y-2"> - <Skeleton className="h-6 w-32" /> - <Skeleton className="h-4 w-48" /> - </div> - <Skeleton className="h-[300px] w-full" /> - </div> - ))} - </div> - - {/* 탭 스켈레톤 */} - <div className="space-y-4"> - <Skeleton className="h-10 w-64" /> - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> - {[...Array(6)].map((_, i) => ( - <div key={i} className="space-y-4 p-6 border rounded-lg"> - <Skeleton className="h-6 w-32" /> - <div className="space-y-3"> - <div className="flex justify-between"> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-12" /> - </div> - <div className="flex gap-2"> - <Skeleton className="h-6 w-16" /> - <Skeleton className="h-6 w-16" /> - <Skeleton className="h-6 w-16" /> - </div> - <Skeleton className="h-2 w-full" /> - </div> - </div> - ))} - </div> - </div> - </div> - ); -} diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx deleted file mode 100644 index fb288a98..00000000 --- a/app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCBECache } from "@/lib/rfqs/validations" -import { getCBE } from "@/lib/rfqs/service" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx deleted file mode 100644 index 92817b4b..00000000 --- a/app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/rfq/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/rfq/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/rfq/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx deleted file mode 100644 index 1a9f4b18..00000000 --- a/app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx deleted file mode 100644 index 76eea302..00000000 --- a/app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq/page.tsx b/app/[lng]/procurement/(procurement)/rfq/page.tsx deleted file mode 100644 index 26f49cfb..00000000 --- a/app/[lng]/procurement/(procurement)/rfq/page.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.PURCHASE, - title = "RFQ", - description = "RFQ를 등록하고 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - 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"> - {title} - </h2> - {/* <p className="text-muted-foreground"> - {description} - </p> */} - </div> - </div> - </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 - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/settings/layout.tsx b/app/[lng]/procurement/(procurement)/settings/layout.tsx deleted file mode 100644 index 6c380919..00000000 --- a/app/[lng]/procurement/(procurement)/settings/layout.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "Settings", - // description: "Advanced form example using react-hook-form and Zod.", -} - - -interface SettingsLayoutProps { - children: React.ReactNode - params: { lng: string } -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - - const sidebarNavItems = [ - - { - title: "Account", - href: `/${lng}/evcp/settings`, - }, - { - title: "Preferences", - href: `/${lng}/evcp/settings/preferences`, - } - - - ] - - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">설정</h2> - {/* <p className="text-muted-foreground"> - Manage your account settings and preferences. - </p> */} - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - - - </> - ) -} diff --git a/app/[lng]/procurement/(procurement)/settings/page.tsx b/app/[lng]/procurement/(procurement)/settings/page.tsx deleted file mode 100644 index eba5e948..00000000 --- a/app/[lng]/procurement/(procurement)/settings/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AccountForm } from "@/components/settings/account-form" - -export default function SettingsAccountPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Account</h3> - {/* <p className="text-sm text-muted-foreground"> - Update your account settings. Set your preferred language and - timezone. - </p> */} - </div> - <Separator /> - <AccountForm /> - </div> - ) -} diff --git a/app/[lng]/procurement/(procurement)/settings/preferences/page.tsx b/app/[lng]/procurement/(procurement)/settings/preferences/page.tsx deleted file mode 100644 index e2a88021..00000000 --- a/app/[lng]/procurement/(procurement)/settings/preferences/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AppearanceForm } from "@/components/settings/appearance-form" - -export default function SettingsAppearancePage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Preference</h3> - <p className="text-sm text-muted-foreground"> - Customize the preference of the app. - </p> - </div> - <Separator /> - <AppearanceForm /> - </div> - ) -} diff --git a/app/[lng]/procurement/(procurement)/system/admin-users/page.tsx b/app/[lng]/procurement/(procurement)/system/admin-users/page.tsx deleted file mode 100644 index 11a9e9fb..00000000 --- a/app/[lng]/procurement/(procurement)/system/admin-users/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 { DateRangePicker } from "@/components/date-range-picker" -import { Separator } from "@/components/ui/separator" - -import { searchParamsCache } from "@/lib/admin-users/validations" -import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service" -import { AdmUserTable } from "@/lib/admin-users/table/ausers-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function UserTable(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getUsers({ - ...search, - filters: validFilters, - }), - getUserCountGroupByCompany(), - getUserCountGroupByRole(), - getAllCompanies(), - getAllRoles() - ]) - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Vendor Admin User Management</h3> - <p className="text-sm text-muted-foreground"> - 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다. - </p> - </div> - <Separator /> - <AdmUserTable promises={promises} /> - </div> - </React.Suspense> - - ) -} diff --git a/app/[lng]/procurement/(procurement)/system/layout.tsx b/app/[lng]/procurement/(procurement)/system/layout.tsx deleted file mode 100644 index 2776ed8b..00000000 --- a/app/[lng]/procurement/(procurement)/system/layout.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "System Setting", - // description: "Advanced form example using react-hook-form and Zod.", -} - - -interface SettingsLayoutProps { - children: React.ReactNode - params: { lng: string } -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - - const sidebarNavItems = [ - - { - title: "삼성중공업 사용자", - href: `/${lng}/evcp/system`, - }, - { - title: "Roles", - href: `/${lng}/evcp/system/roles`, - }, - { - title: "권한 통제", - href: `/${lng}/evcp/system/permissions`, - }, - { - title: "협력업체 사용자", - href: `/${lng}/evcp/system/admin-users`, - }, - - { - title: "비밀번호 정책", - href: `/${lng}/evcp/system/password-policy`, - }, - - ] - - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2> - {/* <p className="text-muted-foreground"> - 사용자, 롤, 접근 권한을 관리하세요. - </p> */} - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - - - </> - ) -} diff --git a/app/[lng]/procurement/(procurement)/system/page.tsx b/app/[lng]/procurement/(procurement)/system/page.tsx deleted file mode 100644 index fe0a262c..00000000 --- a/app/[lng]/procurement/(procurement)/system/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import * as React from "react" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCache } from "@/lib/admin-users/validations" -import { getAllRoles, getUsersEVCP } from "@/lib/users/service" -import { getUserCountGroupByRole } from "@/lib/admin-users/service" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { UserTable } from "@/lib/users/table/users-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function SystemUserPage(props: IndexPageProps) { - - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getUsersEVCP({ - ...search, - filters: validFilters, - }), - getUserCountGroupByRole(), - getAllRoles() - ]) - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "12rem", "12rem", "12rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">SHI Users</h3> - <p className="text-sm text-muted-foreground"> - 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <UserTable promises={promises} /> - </div> - </React.Suspense> - - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/system/password-policy/page.tsx b/app/[lng]/procurement/(procurement)/system/password-policy/page.tsx deleted file mode 100644 index 0f14fefe..00000000 --- a/app/[lng]/procurement/(procurement)/system/password-policy/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// app/admin/password-policy/page.tsx - -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Separator } from "@/components/ui/separator" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { AlertTriangle } from "lucide-react" -import SecuritySettingsTable from "@/components/system/passwordPolicy" -import { getSecuritySettings } from "@/lib/password-policy/service" - - -export default async function PasswordPolicyPage() { - try { - // 보안 설정 데이터 로드 - const securitySettings = await getSecuritySettings() - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={0} - filterableColumnCount={0} - cellWidths={["20rem", "30rem", "15rem", "10rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3> - <p className="text-sm text-muted-foreground"> - 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <SecuritySettingsTable initialSettings={securitySettings} /> - </div> - </React.Suspense> - ) - } catch (error) { - console.error('Failed to load security settings:', error) - - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3> - <p className="text-sm text-muted-foreground"> - 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <Alert variant="destructive"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요. - </AlertDescription> - </Alert> - </div> - ) - } -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/system/permissions/page.tsx b/app/[lng]/procurement/(procurement)/system/permissions/page.tsx deleted file mode 100644 index 6aa2b693..00000000 --- a/app/[lng]/procurement/(procurement)/system/permissions/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import PermissionsTree from "@/components/system/permissionsTree" -import { Separator } from "@/components/ui/separator" - -export default function PermissionsPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Permissions</h3> - <p className="text-sm text-muted-foreground"> - Set permissions to the menu by Role - </p> - </div> - <Separator /> - <PermissionsTree/> - </div> - ) -} diff --git a/app/[lng]/procurement/(procurement)/system/roles/page.tsx b/app/[lng]/procurement/(procurement)/system/roles/page.tsx deleted file mode 100644 index fe074600..00000000 --- a/app/[lng]/procurement/(procurement)/system/roles/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Separator } from "@/components/ui/separator" - -import { searchParamsCache } from "@/lib/roles/validations" -import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations" -import { RolesTable } from "@/lib/roles/table/roles-table" -import { getRolesWithCount } from "@/lib/roles/services" -import { getUsersAll } from "@/lib/users/service" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function UserTable(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - const search2 = searchParamsCache2.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRolesWithCount({ - ...search, - filters: validFilters, - }), - - - ]) - - - const promises2 = Promise.all([ - getUsersAll({ - ...search2, - filters: validFilters, - }, "evcp"), - ]) - - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Role Management</h3> - <p className="text-sm text-muted-foreground"> - 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다. - </p> - </div> - <Separator /> - <RolesTable promises={promises} promises2={promises2} /> - </div> - </React.Suspense> - - ) -} diff --git a/app/[lng]/procurement/(procurement)/tbe/page.tsx b/app/[lng]/procurement/(procurement)/tbe/page.tsx deleted file mode 100644 index 1a7fdf86..00000000 --- a/app/[lng]/procurement/(procurement)/tbe/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { AllTbeTable } from "@/lib/tbe/table/tbe-table" -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" - -interface IndexPageProps { - params: { - lng: string - } - searchParams: Promise<SearchParams> -} - -// 타입별 페이지 설명 구성 (Budgetary 제외) -const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = { - "purchase": { - title: "Purchase RFQ Technical Bid Evaluation", - description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.", - rfqType: RfqType.PURCHASE - }, - "purchase-budgetary": { - title: "Purchase Budgetary RFQ Technical Bid Evaluation", - description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.", - rfqType: RfqType.PURCHASE_BUDGETARY - } -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // URL 쿼리 파라미터에서 타입 추출 - const searchParams = await props.searchParams - // 기본값으로 'purchase' 사용 - const typeParam = searchParams?.type as string || 'purchase' - - // 유효한 타입인지 확인하고 기본값 설정 - const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase' - const rfqType = typeConfig[validType].rfqType - - // SearchParams 파싱 (Zod) - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 현재 선택된 타입의 데이터 로드 - const promises = Promise.all([ - getAllTBE({ - ...search, - filters: validFilters, - rfqType - }) - ]) - - // 페이지 경로 생성 함수 - 단순화 - const getTabUrl = (type: string) => { - return `/${lng}/evcp/tbe?type=${type}`; - } - - 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"> - Technical Bid Evaluation - </h2> - <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/> - 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - </div> - </div> - - {/* 타입 선택 탭 (Budgetary 제외) */} - <Tabs defaultValue={validType} value={validType} className="w-full"> - <TabsList className="grid grid-cols-2 w-full max-w-md"> - <TabsTrigger value="purchase" asChild> - <a href={getTabUrl('purchase')}>Purchase</a> - </TabsTrigger> - <TabsTrigger value="purchase-budgetary" asChild> - <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a> - </TabsTrigger> - </TabsList> - - <div className="mt-2"> - <p className="text-sm text-muted-foreground"> - {typeConfig[validType].description} - </p> - </div> - </Tabs> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx b/app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx deleted file mode 100644 index fb80cf64..00000000 --- a/app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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" -import { DateRangePicker } from "@/components/date-range-picker" - -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"> - 발굴업체 등록 관리 - </h2> - {/* <p className="text-muted-foreground"> - 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다. - </p> */} - </div> - </div> - </div> - - {/* 수집일 라벨과 DateRangePicker를 함께 배치 */} - <div className="flex items-center justify-start gap-2"> - {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */} - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <DateRangePicker - triggerSize="sm" - triggerClassName="w-56 sm:w-60" - align="end" - shallow={false} - showClearButton={true} - placeholder="수집일 날짜 범위를 고르세요" - /> - </React.Suspense> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorCandidateTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx b/app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx deleted file mode 100644 index e6f9ce82..00000000 --- a/app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation" -import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table" -import { getGeneralEvaluations } from "@/lib/general-check-list/service" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getGenralEvaluationsSchema.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getGeneralEvaluations({ - ...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"> - 협력업체 평가자료 문항 관리 - </h2> - {/* <p className="text-muted-foreground"> - 협력업체 평가에 사용되는 정기평가 체크리스트를 관리{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <GeneralEvaluationsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx b/app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx deleted file mode 100644 index af9f3e11..00000000 --- a/app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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"> - 협력업체 실사 관리 - </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]/procurement/(procurement)/vendor-type/page.tsx b/app/[lng]/procurement/(procurement)/vendor-type/page.tsx deleted file mode 100644 index 96169e8a..00000000 --- a/app/[lng]/procurement/(procurement)/vendor-type/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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/vendor-type/validations" -import { VendorTypesTable } from "@/lib/vendor-type/table/vendorTypes-table" -import { getVendorTypes } from "@/lib/vendor-type/service" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorTypes({ - ...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"> - 업체 유형 - </h2> - {/* <p className="text-muted-foreground"> - 업체 유형을 등록하고 관리할 수 있습니다.{" "} - - </p> */} - </div> - </div> - </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 - /> - } - > - <VendorTypesTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx deleted file mode 100644 index 5d5838c6..00000000 --- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorItems } from "@/lib/vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsItemCache } from "@/lib/vendors/validations" -import { VendorItemsTable } from "@/lib/vendors/items-table/item-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsItemCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorItems({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 공급품목(패키지) - </h3> - <p className="text-sm text-muted-foreground"> - {/* 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다. */} - </p> - </div> - <Separator /> - <div> - <VendorItemsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx deleted file mode 100644 index 7e2cd4f6..00000000 --- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정 -import { Vendor } from "@/db/schema/vendors" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import Link from "next/link" -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string , id: string} -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const vendor: Vendor | null = await findVendorById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "연락처", - href: `/${lng}/evcp/vendors/${id}/info`, - }, - { - title: "공급품목(패키지)", - href: `/${lng}/evcp/vendors/${id}/info/items`, - }, - { - title: "공급품목(자재그룹)", - href: `/${lng}/evcp/vendors/${id}/info/materials`, - }, - { - title: "견적 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/rfq-history`, - }, - { - title: "입찰 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/bid-history`, - }, - { - title: "계약 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/contract-history`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/vendors`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>협력업체 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} - </h2> - <p className="text-muted-foreground">협력업체 관련 상세사항을 확인하세요.</p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx deleted file mode 100644 index 0ebb66ba..00000000 --- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsMaterialCache } from "@/lib/vendors/validations" -import { getVendorMaterials } from "@/lib/vendors/service" -import { VendorMaterialsTable } from "@/lib/vendors/materials-table/item-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMaterialCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorMaterials({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 공급품목(자재 그룹) - </h3> - <p className="text-sm text-muted-foreground"> - {/* 딜리버리가 가능한 공급품목(자재 그룹)을 확인할 수 있습니다. */} - </p> - </div> - <Separator /> - <div> - <VendorMaterialsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx deleted file mode 100644 index 6279e924..00000000 --- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorContacts } from "@/lib/vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsContactCache } from "@/lib/vendors/validations" -import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsContactCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorContacts({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Contacts - </h3> - <p className="text-sm text-muted-foreground"> - 업무별 담당자 정보를 확인하세요. - </p> - </div> - <Separator /> - <div> - <VendorContactsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx deleted file mode 100644 index c7f8f8b6..00000000 --- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { getRfqHistory } from "@/lib/vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
-import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqHistoryPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqHistoryCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqHistory({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- RFQ History
- </h3>
- <p className="text-sm text-muted-foreground">
- 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div>
- <VendorRfqHistoryTable promises={promises} />
- </div>
- </div>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/vendors/page.tsx b/app/[lng]/procurement/(procurement)/vendors/page.tsx deleted file mode 100644 index 02616999..00000000 --- a/app/[lng]/procurement/(procurement)/vendors/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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/vendors/validations" -import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service" -import { VendorsTable } from "@/lib/vendors/table/vendors-table" -import { Ellipsis } from "lucide-react" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendors({ - ...search, - filters: validFilters, - }), - getVendorStatusCounts(), - ]) - - 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"> - 협력업체 관리 - </h2> - {/* <p className="text-muted-foreground"> - 협력업체에 대한 요약 정보를 확인하고{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <VendorsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/page.tsx b/app/[lng]/procurement/page.tsx deleted file mode 100644 index f9662cb7..00000000 --- a/app/[lng]/procurement/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Metadata } from "next" -import { Suspense } from "react" -import { LoginFormSkeleton } from "@/components/login/login-form-skeleton" -import { LoginFormSHI } from "@/components/login/login-form-shi" - -export const metadata: Metadata = { - title: "eVCP Portal", - description: "", -} - -export default function AuthenticationPage() { - - - return ( - <> - <Suspense fallback={<LoginFormSkeleton/>}> - <LoginFormSHI /> - </Suspense> - </> - ) -} diff --git a/app/[lng]/sales/(sales)/bid-projects/page.tsx b/app/[lng]/sales/(sales)/bid-projects/page.tsx deleted file mode 100644 index 38cbf91a..00000000 --- a/app/[lng]/sales/(sales)/bid-projects/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getBidProjectLists } from "@/lib/bidding-projects/service" -import { searchParamsBidProjectsCache } from "@/lib/bidding-projects/validation" -import { BidProjectsTable } from "@/lib/bidding-projects/table/projects-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsBidProjectsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getBidProjectLists({ - ...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"> - 견적 프로젝트 관리 - </h2> - {/* <p className="text-muted-foreground"> - SAP(S-ERP)로부터 수신한 견적 프로젝트 데이터입니다. 기술영업의 Budgetary RFQ에서 사용됩니다. - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <BidProjectsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/bqcbe/page.tsx b/app/[lng]/sales/(sales)/bqcbe/page.tsx deleted file mode 100644 index 30935645..00000000 --- a/app/[lng]/sales/(sales)/bqcbe/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllCBE } from "@/lib/rfqs/service" -import { searchParamsCBECache } from "@/lib/rfqs/validations" - -import { AllCbeTable } from "@/lib/cbe/table/cbe-table" - -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getAllCBE({ - ...search, - filters: validFilters, - rfqType - } - ) - ]) - - // 4) 렌더링 - 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"> - CBE 관리 - </h2> - {/* <p className="text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllCbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/bqtbe/page.tsx b/app/[lng]/sales/(sales)/bqtbe/page.tsx deleted file mode 100644 index 3e56cfaa..00000000 --- a/app/[lng]/sales/(sales)/bqtbe/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { AllTbeTable } from "@/lib/tbe/table/tbe-table" -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getAllTBE({ - ...search, - filters: validFilters, - rfqType - } - ) - ]) - - // 4) 렌더링 - 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"> - TBE 관리 - </h2> - {/* <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx deleted file mode 100644 index 956facd3..00000000 --- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBE, getTBE } from "@/lib/rfqs/service" -import { searchParamsCBECache, } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx deleted file mode 100644 index 2b80e64f..00000000 --- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/budgetary/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/budgetary/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/budgetary/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/budgetary-rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>Budgetary RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx deleted file mode 100644 index dd9df563..00000000 --- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" -import { RfqType } from "@/lib/rfqs/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx deleted file mode 100644 index ec894e1c..00000000 --- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx deleted file mode 100644 index f342bbff..00000000 --- a/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" -import { Ellipsis } from "lucide-react" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.PURCHASE_BUDGETARY, - title = "Budgetary Quote", - description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - 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"> - {title} - </h2> - {/* <p className="text-muted-foreground"> - {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx deleted file mode 100644 index b1be29db..00000000 --- a/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsHullCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesHullRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface HullRfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function HullRfqPage(props: HullRfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 해양 HULL용 파라미터 파싱 - const search = searchParamsHullCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 해양 Hull RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesHullRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-해양 Hull RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="HULL" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx deleted file mode 100644 index b7bf9d15..00000000 --- a/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsShipCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesShipRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface RfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: RfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 조선용 파라미터 파싱 - const search = searchParamsShipCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesShipRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-조선 RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="SHIP" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx deleted file mode 100644 index f84a9794..00000000 --- a/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsTopCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesTopRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface HullRfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function HullRfqPage(props: HullRfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 해양 TOP용 파라미터 파싱 - const search = searchParamsTopCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 해양 TOP RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesTopRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-해양 TOP RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="TOP" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx deleted file mode 100644 index 956facd3..00000000 --- a/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBE, getTBE } from "@/lib/rfqs/service" -import { searchParamsCBECache, } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx deleted file mode 100644 index d58d8363..00000000 --- a/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/budgetary/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/budgetary/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/budgetary/${id}/cbe`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/budgetary`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>Budgetary Quote 목록으로 돌아가기</span> - </Button> - </Link> - </div> - - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx deleted file mode 100644 index dd9df563..00000000 --- a/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" -import { RfqType } from "@/lib/rfqs/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx deleted file mode 100644 index ec894e1c..00000000 --- a/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/budgetary/page.tsx b/app/[lng]/sales/(sales)/budgetary/page.tsx deleted file mode 100644 index 15b4cdd4..00000000 --- a/app/[lng]/sales/(sales)/budgetary/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" -import { Ellipsis } from "lucide-react" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.BUDGETARY, - title = "Budgetary Quote", - description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - 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"> - {title} - </h2> - {/* <p className="text-muted-foreground"> - {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/dashboard/page.tsx b/app/[lng]/sales/(sales)/dashboard/page.tsx deleted file mode 100644 index 1d61dc16..00000000 --- a/app/[lng]/sales/(sales)/dashboard/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// app/invalid-access/page.tsx - -export default function InvalidAccessPage() { - return ( - <main style={{ padding: '40px', textAlign: 'center' }}> - <h1>부적절한 접근입니다</h1> - <p> - 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br /> - SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다. - </p> - <p> - <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong> - </p> - </main> - ); - } -
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/esg-check-list/page.tsx b/app/[lng]/sales/(sales)/esg-check-list/page.tsx deleted file mode 100644 index dd97c74c..00000000 --- a/app/[lng]/sales/(sales)/esg-check-list/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { getEsgEvaluations } from "@/lib/esg-check-list/service" -import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation" -import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getEsgEvaluationsSchema.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getEsgEvaluations({ - ...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"> - ESG 자가진단평가 문항 관리 - </h2> - {/* <p className="text-muted-foreground"> - 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <EsgEvaluationsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx b/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx deleted file mode 100644 index 34409524..00000000 --- a/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* IMPORT */
-import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
-import { getRegEvalCriteria } from '@/lib/evaluation-criteria/service';
-import { getValidFilters } from '@/lib/data-table';
-import RegEvalCriteriaTable from '@/lib/evaluation-criteria/table/reg-eval-criteria-table';
-import { searchParamsCache } from '@/lib/evaluation-criteria/validations';
-import { Shell } from '@/components/shell';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Suspense } from 'react';
-import { type SearchParams } from '@/types/table';
-
-// ----------------------------------------------------------------------------------------------------
-
-/* TYPES */
-interface EvaluationCriteriaPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* REGULAR EVALUATION CRITERIA PAGE */
-async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
- const searchParams = await props.searchParams;
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
- const promises = Promise.all([
- getRegEvalCriteria({
- ...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">
- 협력업체 평가기준표 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 평가기준표를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </Suspense>
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={11}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RegEvalCriteriaTable promises={promises} />
- </Suspense>
- </Shell>
- )
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* EXPORT */
-export default EvaluationCriteriaPage;
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx b/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx deleted file mode 100644 index 56b8ecef..00000000 --- a/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { HelpCircle } from "lucide-react" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" - -import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation" -import { getEvaluationTargets } from "@/lib/evaluation-target-list/service" -import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table" - -export const metadata: Metadata = { - title: "협력업체 평가 대상 관리", - description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.", -} - -interface EvaluationTargetsPageProps { - searchParams: Promise<SearchParams> -} - - - -export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) { - const searchParams = await props.searchParams - const search = searchParamsEvaluationTargetsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // 현재 평가년도 (필터에서 가져오거나 기본값 사용) - const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getEvaluationTargets({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - {/* 간소화된 헤더 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 평가 대상 관리 - </h2> - <Badge variant="outline" className="text-sm"> - {currentEvaluationYear}년도 - </Badge> - - </div> - </div> - </div> - - {/* 메인 테이블 (통계는 테이블 내부로 이동) */} - <React.Suspense - key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 - fallback={ - <DataTableSkeleton - columnCount={12} - searchableColumnCount={2} - filterableColumnCount={6} - cellWidths={[ - "3rem", // checkbox - "5rem", // 평가년도 - "4rem", // 구분 - "8rem", // 벤더코드 - "12rem", // 벤더명 - "4rem", // 내외자 - "6rem", // 자재구분 - "5rem", // 상태 - "5rem", // 의견일치 - "8rem", // 담당자현황 - "10rem", // 관리자의견 - "8rem" // actions - ]} - shrinkZero - /> - } - > - {currentEvaluationYear && - <EvaluationTargetsTable - promises={promises} - evaluationYear={currentEvaluationYear} - /> -} - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/evaluation/page.tsx b/app/[lng]/sales/(sales)/evaluation/page.tsx deleted file mode 100644 index 2d8cbed7..00000000 --- a/app/[lng]/sales/(sales)/evaluation/page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -// ================================================================ -// 4. PERIODIC EVALUATIONS PAGE -// ================================================================ - -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { HelpCircle } from "lucide-react" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table" -import { getPeriodicEvaluations } from "@/lib/evaluation/service" -import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation" - -export const metadata: Metadata = { - title: "협력업체 정기평가", - description: "협력업체 정기평가 진행 현황을 관리합니다.", -} - -interface PeriodicEvaluationsPageProps { - searchParams: Promise<SearchParams> -} - -// 프로세스 안내 팝오버 컴포넌트 -function ProcessGuidePopover() { - return ( - <Popover> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon" className="h-6 w-6"> - <HelpCircle className="h-4 w-4 text-muted-foreground" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-96" align="start"> - <div className="space-y-3"> - <div className="space-y-1"> - <h4 className="font-medium">정기평가 프로세스</h4> - {/* <p className="text-sm text-muted-foreground"> - 확정된 평가 대상 업체들에 대한 정기평가 절차입니다. - </p> */} - </div> - <div className="space-y-3 text-sm"> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 1 - </div> - <div> - <p className="font-medium">평가 대상 확정</p> - <p className="text-muted-foreground">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 2 - </div> - <div> - <p className="font-medium">업체 자료 제출</p> - <p className="text-muted-foreground">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 3 - </div> - <div> - <p className="font-medium">평가자 검토</p> - <p className="text-muted-foreground">지정된 평가자들이 평가표를 기반으로 점수를 매기고 검토합니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 4 - </div> - <div> - <p className="font-medium">최종 확정</p> - <p className="text-muted-foreground">모든 평가가 완료되면 최종 점수와 등급이 확정됩니다.</p> - </div> - </div> - </div> - </div> - </PopoverContent> - </Popover> - ) -} - -// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함 -function getDefaultEvaluationYear() { - return new Date().getFullYear() -} - - - -export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) { - const searchParams = await props.searchParams - const search = searchParamsEvaluationsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters || []) - - // 기본 필터 처리 - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // 현재 평가년도 - const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getPeriodicEvaluations({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - {/* 헤더 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 정기평가 - </h2> - <Badge variant="outline" className="text-sm"> - {currentEvaluationYear}년도 - </Badge> - </div> - </div> - </div> - - {/* 메인 테이블 */} - <React.Suspense - key={JSON.stringify(searchParams)} - fallback={ - <DataTableSkeleton - columnCount={15} - searchableColumnCount={2} - filterableColumnCount={8} - cellWidths={[ - "3rem", // checkbox - "5rem", // 평가년도 - "5rem", // 평가기간 - "4rem", // 구분 - "8rem", // 벤더코드 - "12rem", // 벤더명 - "4rem", // 내외자 - "6rem", // 자재구분 - "5rem", // 문서제출 - "4rem", // 제출일 - "4rem", // 마감일 - "4rem", // 총점 - "4rem", // 등급 - "5rem", // 진행상태 - "8rem" // actions - ]} - shrinkZero - /> - } - > - <PeriodicEvaluationsTable - promises={promises} - evaluationYear={currentEvaluationYear} - /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/faq/manage/actions.ts b/app/[lng]/sales/(sales)/faq/manage/actions.ts deleted file mode 100644 index bc443a8a..00000000 --- a/app/[lng]/sales/(sales)/faq/manage/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use server';
-
-import { promises as fs } from 'fs';
-import path from 'path';
-import { FaqCategory } from '@/components/faq/FaqCard';
-import { fallbackLng } from '@/i18n/settings';
-
-const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
-
-export async function updateFaqData(lng: string, newData: FaqCategory[]) {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- const updatedData = {
- ...allData,
- [lng]: newData
- };
-
- const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
- await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
-
- return { success: true };
- } catch (error) {
- console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
- return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
- }
-}
-
-export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- return { data: allData[lng] || allData[fallbackLng] || [] };
- } catch (error) {
- console.error('FAQ 데이터 읽기 중 오류 발생:', error);
- return { data: [] };
- }
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/faq/manage/page.tsx b/app/[lng]/sales/(sales)/faq/manage/page.tsx deleted file mode 100644 index 011bbfa4..00000000 --- a/app/[lng]/sales/(sales)/faq/manage/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FaqManager } from '@/components/faq/FaqManager';
-import { getFaqData, updateFaqData } from './actions';
-import { revalidatePath } from 'next/cache';
-import { FaqCategory } from '@/components/faq/FaqCard';
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqManagePage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const { data } = await getFaqData(lng);
-
- async function handleSave(newData: FaqCategory[]) {
- 'use server';
- await updateFaqData(lng, newData);
- revalidatePath(`/${lng}/evcp/faq`);
- }
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
- <p className="text-muted-foreground">
- Manage FAQ categories and items for {lng.toUpperCase()} language.
- </p>
- </div>
- <FaqManager initialData={data} onSave={handleSave} lng={lng} />
- </div>
- </section>
- </div>
- );
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/faq/page.tsx b/app/[lng]/sales/(sales)/faq/page.tsx deleted file mode 100644 index 00956591..00000000 --- a/app/[lng]/sales/(sales)/faq/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { faqCategories } from "@/config/faqDataConfig"
-import { FaqCard } from "@/components/faq/FaqCard"
-import { Button } from "@/components/ui/button"
-import { Settings } from "lucide-react"
-import Link from "next/link"
-import { fallbackLng } from "@/i18n/settings"
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqPage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="flex justify-between items-center">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ</h2>
- {/* <p className="text-muted-foreground">
- Find answers to common questions about using the EVCP system.
- </p> */}
- </div>
- <Link href={`/${lng}/evcp/faq/manage`}>
- <Button variant="outline">
- <Settings className="w-4 h-4 mr-2" />
- FAQ 관리
- </Button>
- </Link>
- </div>
- <Separator className="my-6" />
-
- <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
- <TabsList>
- {localizedFaqCategories.map((category) => (
- <TabsTrigger key={category.label} value={category.label}>
- {category.label}
- </TabsTrigger>
- ))}
- </TabsList>
-
- {localizedFaqCategories.map((category) => (
- <TabsContent key={category.label} value={category.label} className="space-y-4">
- {category.items.map((item, index) => (
- <FaqCard key={index} item={item} />
- ))}
- </TabsContent>
- ))}
- </Tabs>
- </div>
- </section>
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/items-tech/layout.tsx b/app/[lng]/sales/(sales)/items-tech/layout.tsx deleted file mode 100644 index d375059b..00000000 --- a/app/[lng]/sales/(sales)/items-tech/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"
-import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default function ItemsShipLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // 아이템 타입 정의
- const itemTypes = [
- { id: "ship", name: "조선 아이템" },
- { id: "top", name: "해양 TOP" },
- { id: "hull", name: "해양 HULL" },
- ]
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ItemTechContainer itemTypes={itemTypes}>
- {children}
- </ItemTechContainer>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/items-tech/page.tsx b/app/[lng]/sales/(sales)/items-tech/page.tsx deleted file mode 100644 index 55ac9c63..00000000 --- a/app/[lng]/sales/(sales)/items-tech/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { shipbuildingSearchParamsCache, offshoreTopSearchParamsCache, offshoreHullSearchParamsCache } from "@/lib/items-tech/validations"
-import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
-import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
-import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
-
-// 대소문자 문제 해결 - 실제 파일명에 맞게 import
-import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage({ searchParams }: IndexPageProps) {
- const params = await searchParams
- const shipbuildingSearch = shipbuildingSearchParamsCache.parse(params)
- const offshoreTopSearch = offshoreTopSearchParamsCache.parse(params)
- const offshoreHullSearch = offshoreHullSearchParamsCache.parse(params)
- const validShipbuildingFilters = getValidFilters(shipbuildingSearch.filters || [])
- const validOffshoreTopFilters = getValidFilters(offshoreTopSearch.filters || [])
- const validOffshoreHullFilters = getValidFilters(offshoreHullSearch.filters || [])
-
-
- // URL에서 아이템 타입 가져오기
- const itemType = params.type || "ship"
-
- return (
- <div>
- {itemType === "ship" && (
- <ItemsShipTable
- promises={Promise.all([
- getShipbuildingItems({
- ...shipbuildingSearch,
- filters: validShipbuildingFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "top" && (
- <OffshoreTopTable
- promises={Promise.all([
- getOffshoreTopItems({
- ...offshoreTopSearch,
- filters: validOffshoreTopFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "hull" && (
- <OffshoreHullTable
- promises={Promise.all([
- getOffshoreHullItems({
- ...offshoreHullSearch,
- filters: validOffshoreHullFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/items/page.tsx b/app/[lng]/sales/(sales)/items/page.tsx deleted file mode 100644 index f8d9a5b1..00000000 --- a/app/[lng]/sales/(sales)/items/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// app/items/page.tsx (업데이트) -import * as React from "react" -import { type SearchParams } from "@/types/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/items/validations" -import { getItems } from "@/lib/items/service" -import { ItemsTable } from "@/lib/items/table/items-table" -import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // pageSize 기반으로 모드 자동 결정 - const isInfiniteMode = search.perPage >= 1_000_000 - - // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 - // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 - const promises = isInfiniteMode - ? undefined - : Promise.all([ - getItems(search), // searchParamsCache의 결과를 그대로 사용 - ]) - - 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"> - 패키지 넘버 - </h2> - {/* <p className="text-muted-foreground"> - S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다. - </p> */} - </div> - </div> - - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker 등 추가 컴포넌트 */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - {/* 통합된 ItemsTable 컴포넌트 사용 */} - <ItemsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/layout.tsx b/app/[lng]/sales/(sales)/layout.tsx deleted file mode 100644 index 82b53307..00000000 --- a/app/[lng]/sales/(sales)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ReactNode } from 'react'; -import { Header } from '@/components/layout/Header'; -import { SiteFooter } from '@/components/layout/Footer'; - -export default function EvcpLayout({ children }: { children: ReactNode }) { - return ( - <div className="relative flex min-h-svh flex-col bg-background"> - {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */} - <Header /> - <main className="flex flex-1 flex-col"> - <div className='container-wrapper'> - {children} - </div> - </main> - <SiteFooter/> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/project-gtc/page.tsx b/app/[lng]/sales/(sales)/project-gtc/page.tsx deleted file mode 100644 index d5cb467a..00000000 --- a/app/[lng]/sales/(sales)/project-gtc/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getProjectGtcList } from "@/lib/project-gtc/service" -import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations" -import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = projectGtcSearchParamsSchema.parse(searchParams) - - const promises = Promise.all([ - getProjectGtcList({ - page: search.page, - perPage: search.perPage, - search: search.search, - sort: search.sort, - }), - ]) - - 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"> - 프로젝트 GTC 관리 - </h2> - {/* <p className="text-muted-foreground"> - 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다. - 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다. - </p> */} - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* 추가 기능이 필요하면 여기에 추가 */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]} - shrinkZero - /> - } - > - <ProjectGtcTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/project-vendors/page.tsx b/app/[lng]/sales/(sales)/project-vendors/page.tsx deleted file mode 100644 index 525cff07..00000000 --- a/app/[lng]/sales/(sales)/project-vendors/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table" -import { getProjecTAVL } from "@/lib/project-avl/service" -import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchProjectAVLParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getProjecTAVL({ - ...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"> - 프로젝트 AVL 리스트 - </h2> - {/* <p className="text-muted-foreground"> - 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <ProjectAVLTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/projects/page.tsx b/app/[lng]/sales/(sales)/projects/page.tsx deleted file mode 100644 index 649dd56f..00000000 --- a/app/[lng]/sales/(sales)/projects/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 { ItemsTable } from "@/lib/items/table/items-table" -import { getProjectLists } from "@/lib/projects/service" -import { ProjectsTable } from "@/lib/projects/table/projects-table" -import { searchParamsProjectsCache } from "@/lib/projects/validation" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsProjectsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getProjectLists({ - ...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"> - 수행 프로젝트 리스트 from S-EDP - </h2> - {/* <p className="text-muted-foreground"> - S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. - </p> */} - </div> - </div> - </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 - /> - } - > - <ProjectsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/report/page.tsx b/app/[lng]/sales/(sales)/report/page.tsx deleted file mode 100644 index 152721cf..00000000 --- a/app/[lng]/sales/(sales)/report/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Shell } from "@/components/shell"; -import { ErrorBoundary } from "@/components/error-boundary"; -import { getDashboardData } from "@/lib/dashboard/service"; -import { DashboardClient } from "@/lib/dashboard/dashboard-client"; - -// 데이터 fetch 시 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생. -// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리 -// getDashboardData 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨. -export const dynamic = 'force-dynamic' - -export default async function IndexPage() { - // domain을 명시적으로 전달 - const domain = "sales"; - - try { - // 서버에서 직접 데이터 fetch - const dashboardData = await getDashboardData(domain); - - return ( - <Shell className="gap-2"> - <DashboardClient initialData={dashboardData} /> - </Shell> - ); - } catch (error) { - console.error("Dashboard data fetch error:", error); - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-center py-12"> - <div className="text-center space-y-2"> - <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> - <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> - </div> - </div> - </Shell> - ); - } -} - -function DashboardSkeleton() { - return ( - <div className="space-y-6"> - {/* 헤더 스켈레톤 */} - <div className="flex items-center justify-between"> - <div className="space-y-2"> - <Skeleton className="h-8 w-48" /> - <Skeleton className="h-4 w-72" /> - </div> - <Skeleton className="h-10 w-24" /> - </div> - - {/* 요약 카드 스켈레톤 */} - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> - {[...Array(4)].map((_, i) => ( - <div key={i} className="space-y-3 p-6 border rounded-lg"> - <div className="flex items-center justify-between"> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-4" /> - </div> - <Skeleton className="h-8 w-12" /> - <Skeleton className="h-3 w-20" /> - </div> - ))} - </div> - - {/* 차트 스켈레톤 */} - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - {[...Array(2)].map((_, i) => ( - <div key={i} className="space-y-4 p-6 border rounded-lg"> - <div className="space-y-2"> - <Skeleton className="h-6 w-32" /> - <Skeleton className="h-4 w-48" /> - </div> - <Skeleton className="h-[300px] w-full" /> - </div> - ))} - </div> - - {/* 탭 스켈레톤 */} - <div className="space-y-4"> - <Skeleton className="h-10 w-64" /> - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> - {[...Array(6)].map((_, i) => ( - <div key={i} className="space-y-4 p-6 border rounded-lg"> - <Skeleton className="h-6 w-32" /> - <div className="space-y-3"> - <div className="flex justify-between"> - <Skeleton className="h-4 w-16" /> - <Skeleton className="h-4 w-12" /> - </div> - <div className="flex gap-2"> - <Skeleton className="h-6 w-16" /> - <Skeleton className="h-6 w-16" /> - <Skeleton className="h-6 w-16" /> - </div> - <Skeleton className="h-2 w-full" /> - </div> - </div> - ))} - </div> - </div> - </div> - ); -} diff --git a/app/[lng]/sales/(sales)/settings/layout.tsx b/app/[lng]/sales/(sales)/settings/layout.tsx deleted file mode 100644 index 6c380919..00000000 --- a/app/[lng]/sales/(sales)/settings/layout.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "Settings", - // description: "Advanced form example using react-hook-form and Zod.", -} - - -interface SettingsLayoutProps { - children: React.ReactNode - params: { lng: string } -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - - const sidebarNavItems = [ - - { - title: "Account", - href: `/${lng}/evcp/settings`, - }, - { - title: "Preferences", - href: `/${lng}/evcp/settings/preferences`, - } - - - ] - - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">설정</h2> - {/* <p className="text-muted-foreground"> - Manage your account settings and preferences. - </p> */} - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - - - </> - ) -} diff --git a/app/[lng]/sales/(sales)/settings/page.tsx b/app/[lng]/sales/(sales)/settings/page.tsx deleted file mode 100644 index eba5e948..00000000 --- a/app/[lng]/sales/(sales)/settings/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AccountForm } from "@/components/settings/account-form" - -export default function SettingsAccountPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Account</h3> - {/* <p className="text-sm text-muted-foreground"> - Update your account settings. Set your preferred language and - timezone. - </p> */} - </div> - <Separator /> - <AccountForm /> - </div> - ) -} diff --git a/app/[lng]/sales/(sales)/settings/preferences/page.tsx b/app/[lng]/sales/(sales)/settings/preferences/page.tsx deleted file mode 100644 index e2a88021..00000000 --- a/app/[lng]/sales/(sales)/settings/preferences/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AppearanceForm } from "@/components/settings/appearance-form" - -export default function SettingsAppearancePage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Preference</h3> - <p className="text-sm text-muted-foreground"> - Customize the preference of the app. - </p> - </div> - <Separator /> - <AppearanceForm /> - </div> - ) -} diff --git a/app/[lng]/sales/(sales)/system/admin-users/page.tsx b/app/[lng]/sales/(sales)/system/admin-users/page.tsx deleted file mode 100644 index 11a9e9fb..00000000 --- a/app/[lng]/sales/(sales)/system/admin-users/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 { DateRangePicker } from "@/components/date-range-picker" -import { Separator } from "@/components/ui/separator" - -import { searchParamsCache } from "@/lib/admin-users/validations" -import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service" -import { AdmUserTable } from "@/lib/admin-users/table/ausers-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function UserTable(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getUsers({ - ...search, - filters: validFilters, - }), - getUserCountGroupByCompany(), - getUserCountGroupByRole(), - getAllCompanies(), - getAllRoles() - ]) - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Vendor Admin User Management</h3> - <p className="text-sm text-muted-foreground"> - 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다. - </p> - </div> - <Separator /> - <AdmUserTable promises={promises} /> - </div> - </React.Suspense> - - ) -} diff --git a/app/[lng]/sales/(sales)/system/layout.tsx b/app/[lng]/sales/(sales)/system/layout.tsx deleted file mode 100644 index 2776ed8b..00000000 --- a/app/[lng]/sales/(sales)/system/layout.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "System Setting", - // description: "Advanced form example using react-hook-form and Zod.", -} - - -interface SettingsLayoutProps { - children: React.ReactNode - params: { lng: string } -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - - const sidebarNavItems = [ - - { - title: "삼성중공업 사용자", - href: `/${lng}/evcp/system`, - }, - { - title: "Roles", - href: `/${lng}/evcp/system/roles`, - }, - { - title: "권한 통제", - href: `/${lng}/evcp/system/permissions`, - }, - { - title: "협력업체 사용자", - href: `/${lng}/evcp/system/admin-users`, - }, - - { - title: "비밀번호 정책", - href: `/${lng}/evcp/system/password-policy`, - }, - - ] - - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2> - {/* <p className="text-muted-foreground"> - 사용자, 롤, 접근 권한을 관리하세요. - </p> */} - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - - - </> - ) -} diff --git a/app/[lng]/sales/(sales)/system/page.tsx b/app/[lng]/sales/(sales)/system/page.tsx deleted file mode 100644 index fe0a262c..00000000 --- a/app/[lng]/sales/(sales)/system/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import * as React from "react" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCache } from "@/lib/admin-users/validations" -import { getAllRoles, getUsersEVCP } from "@/lib/users/service" -import { getUserCountGroupByRole } from "@/lib/admin-users/service" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { UserTable } from "@/lib/users/table/users-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function SystemUserPage(props: IndexPageProps) { - - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getUsersEVCP({ - ...search, - filters: validFilters, - }), - getUserCountGroupByRole(), - getAllRoles() - ]) - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "12rem", "12rem", "12rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">SHI Users</h3> - <p className="text-sm text-muted-foreground"> - 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <UserTable promises={promises} /> - </div> - </React.Suspense> - - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/system/password-policy/page.tsx b/app/[lng]/sales/(sales)/system/password-policy/page.tsx deleted file mode 100644 index 0f14fefe..00000000 --- a/app/[lng]/sales/(sales)/system/password-policy/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// app/admin/password-policy/page.tsx - -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Separator } from "@/components/ui/separator" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { AlertTriangle } from "lucide-react" -import SecuritySettingsTable from "@/components/system/passwordPolicy" -import { getSecuritySettings } from "@/lib/password-policy/service" - - -export default async function PasswordPolicyPage() { - try { - // 보안 설정 데이터 로드 - const securitySettings = await getSecuritySettings() - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={0} - filterableColumnCount={0} - cellWidths={["20rem", "30rem", "15rem", "10rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3> - <p className="text-sm text-muted-foreground"> - 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <SecuritySettingsTable initialSettings={securitySettings} /> - </div> - </React.Suspense> - ) - } catch (error) { - console.error('Failed to load security settings:', error) - - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3> - <p className="text-sm text-muted-foreground"> - 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <Alert variant="destructive"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요. - </AlertDescription> - </Alert> - </div> - ) - } -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/system/permissions/page.tsx b/app/[lng]/sales/(sales)/system/permissions/page.tsx deleted file mode 100644 index 6aa2b693..00000000 --- a/app/[lng]/sales/(sales)/system/permissions/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import PermissionsTree from "@/components/system/permissionsTree" -import { Separator } from "@/components/ui/separator" - -export default function PermissionsPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Permissions</h3> - <p className="text-sm text-muted-foreground"> - Set permissions to the menu by Role - </p> - </div> - <Separator /> - <PermissionsTree/> - </div> - ) -} diff --git a/app/[lng]/sales/(sales)/system/roles/page.tsx b/app/[lng]/sales/(sales)/system/roles/page.tsx deleted file mode 100644 index fe074600..00000000 --- a/app/[lng]/sales/(sales)/system/roles/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Separator } from "@/components/ui/separator" - -import { searchParamsCache } from "@/lib/roles/validations" -import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations" -import { RolesTable } from "@/lib/roles/table/roles-table" -import { getRolesWithCount } from "@/lib/roles/services" -import { getUsersAll } from "@/lib/users/service" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function UserTable(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - const search2 = searchParamsCache2.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRolesWithCount({ - ...search, - filters: validFilters, - }), - - - ]) - - - const promises2 = Promise.all([ - getUsersAll({ - ...search2, - filters: validFilters, - }, "evcp"), - ]) - - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Role Management</h3> - <p className="text-sm text-muted-foreground"> - 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다. - </p> - </div> - <Separator /> - <RolesTable promises={promises} promises2={promises2} /> - </div> - </React.Suspense> - - ) -} diff --git a/app/[lng]/sales/(sales)/tbe/page.tsx b/app/[lng]/sales/(sales)/tbe/page.tsx deleted file mode 100644 index 211cf376..00000000 --- a/app/[lng]/sales/(sales)/tbe/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { AllTbeTable } from "@/lib/tbe/table/tbe-table" -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" - -interface IndexPageProps { - params: { - lng: string - } - searchParams: Promise<SearchParams> -} - -// 타입별 페이지 설명 구성 (Budgetary 제외) -const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = { - "purchase": { - title: "Purchase RFQ Technical Bid Evaluation", - description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.", - rfqType: RfqType.PURCHASE - }, - "purchase-budgetary": { - title: "Purchase Budgetary RFQ Technical Bid Evaluation", - description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.", - rfqType: RfqType.PURCHASE_BUDGETARY - } -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // URL 쿼리 파라미터에서 타입 추출 - const searchParams = await props.searchParams - // 기본값으로 'purchase' 사용 - const typeParam = searchParams?.type as string || 'purchase' - - // 유효한 타입인지 확인하고 기본값 설정 - const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase' - const rfqType = typeConfig[validType].rfqType - - // SearchParams 파싱 (Zod) - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 현재 선택된 타입의 데이터 로드 - const promises = Promise.all([ - getAllTBE({ - ...search, - filters: validFilters, - rfqType - }) - ]) - - // 페이지 경로 생성 함수 - 단순화 - const getTabUrl = (type: string) => { - return `/${lng}/evcp/tbe?type=${type}`; - } - - 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"> - TBE 관리 - </h2> - {/* <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/> - 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> */} - </div> - </div> - </div> - - {/* 타입 선택 탭 (Budgetary 제외) */} - <Tabs defaultValue={validType} value={validType} className="w-full"> - <TabsList className="grid grid-cols-2 w-full max-w-md"> - <TabsTrigger value="purchase" asChild> - <a href={getTabUrl('purchase')}>Purchase</a> - </TabsTrigger> - <TabsTrigger value="purchase-budgetary" asChild> - <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a> - </TabsTrigger> - </TabsList> - - <div className="mt-2"> - <p className="text-sm text-muted-foreground"> - {typeConfig[validType].description} - </p> - </div> - </Tabs> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx b/app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx deleted file mode 100644 index 5bc36790..00000000 --- a/app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Suspense } from "react"
-import { SearchParams } from "@/types/table"
-import { Shell } from "@/components/shell"
-import { ContactPossibleItemsTable } from "@/lib/contact-possible-items/table/contact-possible-items-table"
-import { getContactPossibleItems } from "@/lib/contact-possible-items/service"
-import { searchParamsCache } from "@/lib/contact-possible-items/validations"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-
-interface ContactPossibleItemsPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function ContactPossibleItemsPage({
- searchParams,
-}: ContactPossibleItemsPageProps) {
- const resolvedSearchParams = await searchParams
- const search = searchParamsCache.parse(resolvedSearchParams)
-
- const contactPossibleItemsPromise = getContactPossibleItems(search)
-
- 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">
- 담당자별 자재 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 기술영업 담당자별 자재를 관리합니다.
- </p> */}
- </div>
- </div>
- </div>
-
-
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "10rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ContactPossibleItemsTable
- contactPossibleItemsPromise={contactPossibleItemsPromise}
- />
- </Suspense>
-
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tech-project-avl/page.tsx b/app/[lng]/sales/(sales)/tech-project-avl/page.tsx deleted file mode 100644 index 4ce018cd..00000000 --- a/app/[lng]/sales/(sales)/tech-project-avl/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import * as React from "react"
-import { redirect } from "next/navigation"
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { SearchParams } from "@/types/table"
-import { searchParamsCache } from "@/lib/tech-project-avl/validations"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Shell } from "@/components/shell"
-import { AcceptedQuotationsTable } from "@/lib/tech-project-avl/table/accepted-quotations-table"
-import { getAcceptedTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Ellipsis } from "lucide-react"
-import { InformationButton } from "@/components/information/information-button"
-export interface PageProps {
- params: Promise<{ lng: string }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function AcceptedQuotationsPage({
- params,
- searchParams,
-}: PageProps) {
- const { lng } = await params
-
- const session = await getServerSession(authOptions)
- if (!session) {
- redirect(`/${lng}/auth/signin`)
- }
-
- const search = await searchParams
- const { page, perPage, sort, filters, search: searchText } = searchParamsCache.parse(search)
- const validFilters = getValidFilters(filters ?? [])
-
- const { data, pageCount } = await getAcceptedTechSalesVendorQuotations({
- page,
- perPage: perPage ?? 10,
- sort,
- search: searchText,
- 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>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 견적 Result 전송
- </h2>
- <InformationButton pagePath="evcp/tech-project-avl" />
- </div>
- {/* <p className="text-muted-foreground">
- 기술영업 승인 견적서에 대한 요약 정보를 확인하고{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 RFQ 코드, 설명, 업체명, 업체 코드 등의 상세 정보를 확인할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* Date range picker can be added here if needed */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={4}
- cellWidths={["10rem", "15rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem", "10rem", "8rem", "10rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <AcceptedQuotationsTable
- data={data}
- pageCount={pageCount}
- />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx deleted file mode 100644 index 291cd630..00000000 --- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { findTechVendorById } from "@/lib/tech-vendors/service"
-import { TechVendor } from "@/db/schema/techVendors"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-import Link from "next/link"
-export const metadata: Metadata = {
- title: "Tech Vendor Detail",
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string , id: string}
-}) {
-
- // 1) URL 파라미터에서 id 추출, Number로 변환
- const resolvedParams = await params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- // 2) DB에서 해당 협력업체 정보 조회
- const vendor: TechVendor | null = await findTechVendorById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "연락처",
- href: `/${lng}/evcp/tech-vendors/${id}/info`,
- },
- {
- title: "RFQ 히스토리",
- href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`,
- },
- {
- title: "자재 리스트",
- href: `/${lng}/evcp/tech-vendors/${id}/info/possible-items`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/tech-vendors`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>기술영업 벤더 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {vendor
- ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
- : "Loading Vendor..."}
- </h2>
- <p className="text-muted-foreground">기술영업 벤더 관련 상세사항을 확인하세요.</p>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx deleted file mode 100644 index 9969a801..00000000 --- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { getTechVendorContacts } from "@/lib/tech-vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsContactCache } from "@/lib/tech-vendors/validations"
-import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsContactCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getTechVendorContacts({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Contacts
- </h3>
- <p className="text-sm text-muted-foreground">
- 업무별 담당자 정보를 확인하세요.
- </p>
- </div>
- <Separator />
- <div>
- <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/>
- </div>
- </div>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx deleted file mode 100644 index 642c6e32..00000000 --- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { getTechVendorPossibleItems } from "@/lib/tech-vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsPossibleItemsCache } from "@/lib/tech-vendors/validations"
-import { TechVendorPossibleItemsTable } from "@/lib/tech-vendors/possible-items/possible-items-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: Promise<{
- lng: string
- id: string
- }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function TechVendorPossibleItemsPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- console.log(idAsNumber)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 possible items 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsPossibleItemsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTechVendorPossibleItems({
- ...search,
- filters: validFilters,
- }, idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- 공급가능 아이템 목록
- </h3>
- <p className="text-sm text-muted-foreground">
- 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div>
- <TechVendorPossibleItemsTable promises={promises} vendorId={idAsNumber} />
- </div>
- </div>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx deleted file mode 100644 index 9122d524..00000000 --- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table"
-import { getTechVendorRfqHistory } from "@/lib/tech-vendors/service"
-import { searchParamsRfqHistoryCache } from "@/lib/tech-vendors/validations"
-import { Separator } from "@/components/ui/separator"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqHistoryCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getTechVendorRfqHistory({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- RFQ 히스토리
- </h3>
- <p className="text-sm text-muted-foreground">
- 벤더가 참여한 기술영업 RFQ 목록입니다.
- </p>
- </div>
- <Separator />
- <div>
- <TechVendorRfqHistoryTable promises={promises} />
- </div>
- </div>
-
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tech-vendors/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/page.tsx deleted file mode 100644 index e49ba79e..00000000 --- a/app/[lng]/sales/(sales)/tech-vendors/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-
-import { searchParamsCache } from "@/lib/tech-vendors/validations"
-import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
-import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTechVendors({
- ...search,
- filters: validFilters,
- }),
- getTechVendorStatusCounts(),
- ])
-
- return (
- <Shell className="gap-4">
- <div className="flex items-center justify-between">
- {/* 왼쪽: 타이틀 & 설명 */}
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 협력업체 관리</h2>
- {/* InformationButton은 필요시 추가 */}
- {/* <InformationButton pagePath="evcp/tech-vendors" /> */}
- </div>
- {/* <p className="text-muted-foreground">
- 기술영업 벤더에 대한 요약 정보를 확인하고 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TechVendorsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/vendor-candidates/page.tsx b/app/[lng]/sales/(sales)/vendor-candidates/page.tsx deleted file mode 100644 index f4bee95b..00000000 --- a/app/[lng]/sales/(sales)/vendor-candidates/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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" -import { DateRangePicker } from "@/components/date-range-picker" - -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"> - 발굴업체 등록 관리 - </h2> - {/* <p className="text-muted-foreground"> - 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다. - </p> */} - </div> - </div> - </div> - - {/* 수집일 라벨과 DateRangePicker를 함께 배치 */} - <div className="flex items-center justify-start gap-2"> - {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */} - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <DateRangePicker - triggerSize="sm" - triggerClassName="w-56 sm:w-60" - align="end" - shallow={false} - showClearButton={true} - placeholder="수집일 날짜 범위를 고르세요" - /> - </React.Suspense> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorCandidateTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/page.tsx b/app/[lng]/sales/page.tsx deleted file mode 100644 index f9662cb7..00000000 --- a/app/[lng]/sales/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Metadata } from "next" -import { Suspense } from "react" -import { LoginFormSkeleton } from "@/components/login/login-form-skeleton" -import { LoginFormSHI } from "@/components/login/login-form-shi" - -export const metadata: Metadata = { - title: "eVCP Portal", - description: "", -} - -export default function AuthenticationPage() { - - - return ( - <> - <Suspense fallback={<LoginFormSkeleton/>}> - <LoginFormSHI /> - </Suspense> - </> - ) -} diff --git a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts deleted file mode 100644 index 51430118..00000000 --- a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts +++ /dev/null @@ -1,145 +0,0 @@ -// app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts -import { NextRequest, NextResponse } from "next/server" - -import db from '@/db/db'; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -import { procurementRfqComments, procurementRfqAttachments } from "@/db/schema" -import { revalidateTag } from "next/cache" - -// 파일 저장을 위한 유틸리티 -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' -import crypto from 'crypto' - -/** - * 코멘트 생성 API 엔드포인트 - */ -export async function POST( - request: NextRequest, - { params }: { params: { rfqId: string; vendorId: string } } -) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json( - { success: false, message: "인증이 필요합니다" }, - { status: 401 } - ) - } - - const rfqId = parseInt(params.rfqId) - const vendorId = parseInt(params.vendorId) - - // 유효성 검사 - if (isNaN(rfqId) || isNaN(vendorId)) { - return NextResponse.json( - { success: false, message: "유효하지 않은 매개변수입니다" }, - { status: 400 } - ) - } - - // FormData 파싱 - const formData = await request.formData() - const content = formData.get("content") as string - const isVendorComment = formData.get("isVendorComment") === "true" - const files = formData.getAll("attachments") as File[] - - if (!content && files.length === 0) { - return NextResponse.json( - { success: false, message: "내용이나 첨부파일이 필요합니다" }, - { status: 400 } - ) - } - - // 코멘트 생성 - const [comment] = await db - .insert(procurementRfqComments) - .values({ - rfqId, - vendorId, - userId: parseInt(session.user.id), - content, - isVendorComment, - isRead: !isVendorComment, // 본인 메시지는 읽음 처리 - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - // 첨부파일 처리 - const attachments = [] - if (files.length > 0) { - // 디렉토리 생성 - const uploadDir = join(process.cwd(), "public", `rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`) - await mkdir(uploadDir, { recursive: true }) - - // 각 파일 저장 - for (const file of files) { - const buffer = Buffer.from(await file.arrayBuffer()) - const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}` - const filePath = join(uploadDir, filename) - - // 파일 쓰기 - await writeFile(filePath, buffer) - - // DB에 첨부파일 정보 저장 - const [attachment] = await db - .insert(procurementRfqAttachments) - .values({ - rfqId, - commentId: comment.id, - fileName: file.name, - fileSize: file.size, - fileType: file.type, - filePath: `/rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`, - isVendorUpload: isVendorComment, - uploadedBy: parseInt(session.user.id), - vendorId, - uploadedAt: new Date(), - }) - .returning() - - attachments.push({ - id: attachment.id, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - fileType: attachment.fileType, - filePath: attachment.filePath, - uploadedAt: attachment.uploadedAt - }) - } - } - - // 캐시 무효화 - revalidateTag(`rfq-${rfqId}-comments`) - - // 응답 데이터 구성 - const responseData = { - id: comment.id, - rfqId: comment.rfqId, - vendorId: comment.vendorId, - userId: comment.userId, - content: comment.content, - isVendorComment: comment.isVendorComment, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - userName: session.user.name, - attachments, - isRead: comment.isRead - } - - return NextResponse.json({ - success: true, - data: { comment: responseData } - }) - } catch (error) { - console.error("코멘트 생성 오류:", error) - return NextResponse.json( - { success: false, message: "코멘트 생성 중 오류가 발생했습니다" }, - { status: 500 } - ) - } -}
\ No newline at end of file diff --git a/app/api/rfq-attachments/download/route.ts b/app/api/rfq-attachments/download/route.ts deleted file mode 100644 index 5a07bc0b..00000000 --- a/app/api/rfq-attachments/download/route.ts +++ /dev/null @@ -1,474 +0,0 @@ -// app/api/rfq-attachments/download/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants, stat } from 'fs/promises'; -import { join, normalize, resolve } from 'path'; -import db from '@/db/db'; -import { bRfqAttachmentRevisions, vendorResponseAttachmentsB } from '@/db/schema'; -import { eq } from 'drizzle-orm'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { createFileDownloadLog } from '@/lib/file-download-log/service'; -import rateLimit from '@/lib/rate-limit'; -import { z } from 'zod'; -import { getRequestInfo } from '@/lib/network/get-client-ip'; - -// 허용된 파일 확장자 -const ALLOWED_EXTENSIONS = new Set([ - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', - 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', - 'dwg', 'dxf', 'zip', 'rar', '7z' -]); - -// 최대 파일 크기 (50MB) -const MAX_FILE_SIZE = 50 * 1024 * 1024; - -// 다운로드 요청 검증 스키마 -const downloadRequestSchema = z.object({ - path: z.string().min(1, 'File path is required'), - type: z.enum(['client', 'vendor']).optional(), - revisionId: z.string().optional(), - responseFileId: z.string().optional(), -}); - -// 파일 정보 타입 -interface FileRecord { - id: number; - fileName: string; - originalFileName?: string; - filePath: string; - fileSize: number; - fileType?: string; -} - -// 강화된 파일 경로 검증 함수 -function validateFilePath(filePath: string): boolean { - // null, undefined, 빈 문자열 체크 - if (!filePath || typeof filePath !== 'string') { - return false; - } - - // 위험한 패턴 체크 - const dangerousPatterns = [ - /\.\./, // 상위 디렉토리 접근 - /\/\//, // 이중 슬래시 - /[<>:"'|?*]/, // 특수문자 - /[\x00-\x1f]/, // 제어문자 - /\\+/ // 백슬래시 - ]; - - if (dangerousPatterns.some(pattern => pattern.test(filePath))) { - return false; - } - - // 시스템 파일 접근 방지 - const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; - for (const dangerousPath of dangerousPaths) { - if (filePath.toLowerCase().startsWith(dangerousPath)) { - return false; - } - } - - return true; -} - -// 파일 확장자 검증 -function validateFileExtension(fileName: string): boolean { - const extension = fileName.split('.').pop()?.toLowerCase() || ''; - return ALLOWED_EXTENSIONS.has(extension); -} - -// 안전한 파일명 생성 -function sanitizeFileName(fileName: string): string { - return fileName - .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 - .replace(/\s+/g, '_') // 공백을 언더스코어로 - .substring(0, 255); // 파일명 길이 제한 -} - -export async function GET(request: NextRequest) { - const startTime = Date.now(); - const requestInfo = getRequestInfo(request); - let fileRecord: FileRecord | null = null; - - try { - // Rate limiting 체크 - const limiterResult = await rateLimit(request); - if (!limiterResult.success) { - console.warn('🚨 Rate limit 초과:', { - ip: requestInfo.ip, - userAgent: requestInfo.userAgent - }); - - return NextResponse.json( - { error: "Too many requests" }, - { status: 429 } - ); - } - - // 세션 확인 - const session = await getServerSession(authOptions); - if (!session?.user) { - console.warn('🚨 인증되지 않은 다운로드 시도:', { - ip: requestInfo.ip, - userAgent: requestInfo.userAgent, - path: request.nextUrl.searchParams.get("path") - }); - - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); - } - - // 파라미터 검증 - const searchParams = { - path: request.nextUrl.searchParams.get("path"), - type: request.nextUrl.searchParams.get("type"), - revisionId: request.nextUrl.searchParams.get("revisionId"), - responseFileId: request.nextUrl.searchParams.get("responseFileId"), - }; - - const validatedParams = downloadRequestSchema.parse(searchParams); - const { path, type, revisionId, responseFileId } = validatedParams; - - // 파일 경로 보안 검증 - if (!validateFilePath(path)) { - console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { - userId: session.user.id, - ip: requestInfo.ip, - userAgent: requestInfo.userAgent - }); - - return NextResponse.json( - { error: "Invalid file path" }, - { status: 400 } - ); - } - - // 경로 정규화 - const normalizedPath = normalize(path.replace(/^\/+/, "")); - - // DB에서 파일 정보 조회 - let dbRecord: FileRecord | null = null; - - if (type === "client" && revisionId) { - // 발주처 첨부파일 리비전 - const [record] = await db - .select({ - id: bRfqAttachmentRevisions.id, - fileName: bRfqAttachmentRevisions.fileName, - originalFileName: bRfqAttachmentRevisions.originalFileName, - filePath: bRfqAttachmentRevisions.filePath, - fileSize: bRfqAttachmentRevisions.fileSize, - fileType: bRfqAttachmentRevisions.fileType, - }) - .from(bRfqAttachmentRevisions) - .where(eq(bRfqAttachmentRevisions.id, Number(revisionId))); - - dbRecord = record; - - } else if (type === "vendor" && responseFileId) { - // 벤더 응답 파일 - const [record] = await db - .select({ - id: vendorResponseAttachmentsB.id, - fileName: vendorResponseAttachmentsB.fileName, - originalFileName: vendorResponseAttachmentsB.originalFileName, - filePath: vendorResponseAttachmentsB.filePath, - fileSize: vendorResponseAttachmentsB.fileSize, - fileType: vendorResponseAttachmentsB.fileType, - }) - .from(vendorResponseAttachmentsB) - .where(eq(vendorResponseAttachmentsB.id, Number(responseFileId))); - - dbRecord = record; - - } else { - // filePath로 직접 검색 (fallback) - 정규화된 경로로 검색 - const [clientRecord] = await db - .select({ - id: bRfqAttachmentRevisions.id, - fileName: bRfqAttachmentRevisions.fileName, - originalFileName: bRfqAttachmentRevisions.originalFileName, - filePath: bRfqAttachmentRevisions.filePath, - fileSize: bRfqAttachmentRevisions.fileSize, - fileType: bRfqAttachmentRevisions.fileType, - }) - .from(bRfqAttachmentRevisions) - .where(eq(bRfqAttachmentRevisions.filePath, normalizedPath)); - - if (clientRecord) { - dbRecord = clientRecord; - } else { - // 벤더 파일에서도 검색 - const [vendorRecord] = await db - .select({ - id: vendorResponseAttachmentsB.id, - fileName: vendorResponseAttachmentsB.fileName, - originalFileName: vendorResponseAttachmentsB.originalFileName, - filePath: vendorResponseAttachmentsB.filePath, - fileSize: vendorResponseAttachmentsB.fileSize, - fileType: vendorResponseAttachmentsB.fileType, - }) - .from(vendorResponseAttachmentsB) - .where(eq(vendorResponseAttachmentsB.filePath, normalizedPath)); - - dbRecord = vendorRecord; - } - } - - // DB에서 파일 정보를 찾지 못한 경우 - if (!dbRecord) { - console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { - path, - normalizedPath, - userId: session.user.id, - ip: requestInfo.ip - }); - - return NextResponse.json( - { error: "File not found in database" }, - { status: 404 } - ); - } - - fileRecord = dbRecord; - - // 파일명 설정 - const fileName = dbRecord.originalFileName || dbRecord.fileName; - - // 파일 확장자 검증 - if (!validateFileExtension(fileName)) { - console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { - userId: session.user.id, - ip: requestInfo.ip - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File type not allowed', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: 0, - } - }); - - return NextResponse.json( - { error: "File type not allowed" }, - { status: 403 } - ); - } - - // 안전한 파일 경로 구성 - const allowedDirs = ["public", "uploads", "storage"]; - let actualPath: string | null = null; - let baseDir: string | null = null; - - // 각 허용된 디렉터리에서 파일 찾기 - for (const dir of allowedDirs) { - baseDir = resolve(process.cwd(), dir); - const testPath = resolve(baseDir, normalizedPath); - - // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 - if (!testPath.startsWith(baseDir)) { - continue; - } - - try { - await access(testPath, constants.R_OK); - actualPath = testPath; - console.log("✅ 파일 발견:", testPath); - break; - } catch (err) { - // 조용히 다음 디렉터리 시도 - } - } - - if (!actualPath || !baseDir) { - console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { - normalizedPath, - userId: session.user.id, - requestedPath: path - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File not found on server', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: dbRecord.fileSize || 0, - } - }); - - return NextResponse.json( - { error: "File not found on server" }, - { status: 404 } - ); - } - - // 파일 크기 확인 - const stats = await stat(actualPath); - if (stats.size > MAX_FILE_SIZE) { - console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, { - userId: session.user.id, - ip: requestInfo.ip - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File too large', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: stats.size, - } - }); - - return NextResponse.json( - { error: "File too large" }, - { status: 413 } - ); - } - - // 파일 읽기 - const fileBuffer = await readFile(actualPath); - - // MIME 타입 결정 - const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - let contentType = dbRecord.fileType || 'application/octet-stream'; - - // 확장자에 따른 MIME 타입 매핑 (fallback) - if (!contentType || contentType === 'application/octet-stream') { - const mimeTypes: Record<string, string> = { - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain; charset=utf-8', - 'csv': 'text/csv; charset=utf-8', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'bmp': 'image/bmp', - 'svg': 'image/svg+xml', - 'dwg': 'application/acad', - 'dxf': 'application/dxf', - 'zip': 'application/zip', - 'rar': 'application/x-rar-compressed', - '7z': 'application/x-7z-compressed', - }; - - contentType = mimeTypes[fileExtension] || 'application/octet-stream'; - } - - // 안전한 파일명 생성 - const safeFileName = sanitizeFileName(fileName); - - // 보안 헤더와 다운로드용 헤더 설정 - const headers = new Headers(); - headers.set('Content-Type', contentType); - headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); - headers.set('Content-Length', fileBuffer.length.toString()); - - // 보안 헤더 - headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); - headers.set('Pragma', 'no-cache'); - headers.set('Expires', '0'); - headers.set('X-Content-Type-Options', 'nosniff'); - headers.set('X-Frame-Options', 'DENY'); - headers.set('X-XSS-Protection', '1; mode=block'); - headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - // 성공 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: true, - requestId: requestInfo.requestId, - fileInfo: { - fileName: safeFileName, - filePath: path, - fileSize: fileBuffer.length, - } - }); - - console.log("✅ 파일 다운로드 성공:", { - fileName: safeFileName, - contentType, - size: fileBuffer.length, - actualPath, - userId: session.user.id, - ip: requestInfo.ip, - downloadDurationMs: Date.now() - startTime - }); - - return new NextResponse(fileBuffer, { - status: 200, - headers, - }); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - console.error('❌ RFQ 첨부파일 다운로드 오류:', { - error: errorMessage, - userId: (await getServerSession(authOptions))?.user?.id, - ip: requestInfo.ip, - path: request.nextUrl.searchParams.get("path"), - downloadDurationMs: Date.now() - startTime - }); - - // 에러 로그 기록 - if (fileRecord?.id) { - try { - await createFileDownloadLog({ - fileId: fileRecord.id, - success: false, - errorMessage, - requestId: requestInfo.requestId, - fileInfo: { - fileName: fileRecord.fileName || 'unknown', - filePath: request.nextUrl.searchParams.get("path") || '', - fileSize: fileRecord.fileSize || 0, - } - }); - } catch (logError) { - console.error('로그 기록 실패:', logError); - } - } - - // Zod 검증 에러 처리 - if (error instanceof z.ZodError) { - return NextResponse.json( - { - error: 'Invalid request parameters', - details: error.errors.map(e => e.message).join(', ') - }, - { status: 400 } - ); - } - - // 에러 정보 최소화 (정보 노출 방지) - return NextResponse.json( - { - error: 'Internal server error', - details: process.env.NODE_ENV === 'development' ? errorMessage : undefined - }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/tbe-download/route.ts b/app/api/tbe-download/route.ts deleted file mode 100644 index 93eb62db..00000000 --- a/app/api/tbe-download/route.ts +++ /dev/null @@ -1,417 +0,0 @@ -// app/api/tbe-download/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants, stat } from 'fs/promises'; -import { join, normalize, resolve } from 'path'; -import db from '@/db/db'; -import { rfqAttachments, vendorResponseAttachments } from '@/db/schema/rfq'; -import { eq } from 'drizzle-orm'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { createFileDownloadLog } from '@/lib/file-download-log/service'; -import rateLimit from '@/lib/rate-limit'; -import { z } from 'zod'; -import { getRequestInfo } from '@/lib/network/get-client-ip'; - -// 허용된 파일 확장자 -const ALLOWED_EXTENSIONS = new Set([ - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', - 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', - 'dwg', 'dxf', 'zip', 'rar', '7z' -]); - -// 최대 파일 크기 (50MB) -const MAX_FILE_SIZE = 50 * 1024 * 1024; - -// 다운로드 요청 검증 스키마 -const downloadRequestSchema = z.object({ - path: z.string().min(1, 'File path is required'), -}); - -// 파일 정보 타입 -interface FileRecord { - id: number; - fileName: string; - filePath: string; - fileSize?: number; - fileType?: string; -} - - -// 강화된 파일 경로 검증 함수 -function validateFilePath(filePath: string): boolean { - // null, undefined, 빈 문자열 체크 - if (!filePath || typeof filePath !== 'string') { - return false; - } - - // 위험한 패턴 체크 - const dangerousPatterns = [ - /\.\./, // 상위 디렉토리 접근 - /\/\//, // 이중 슬래시 - /[<>:"'|?*]/, // 특수문자 - /[\x00-\x1f]/, // 제어문자 - /\\+/ // 백슬래시 - ]; - - if (dangerousPatterns.some(pattern => pattern.test(filePath))) { - return false; - } - - // 시스템 파일 접근 방지 - const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; - for (const dangerousPath of dangerousPaths) { - if (filePath.toLowerCase().startsWith(dangerousPath)) { - return false; - } - } - - return true; -} - -// 파일 확장자 검증 -function validateFileExtension(fileName: string): boolean { - const extension = fileName.split('.').pop()?.toLowerCase() || ''; - return ALLOWED_EXTENSIONS.has(extension); -} - -// 안전한 파일명 생성 -function sanitizeFileName(fileName: string): string { - return fileName - .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 - .replace(/\s+/g, '_') // 공백을 언더스코어로 - .substring(0, 255); // 파일명 길이 제한 -} - -export async function GET(request: NextRequest) { - const startTime = Date.now(); - const requestInfo = getRequestInfo(request); - let fileRecord: FileRecord | null = null; - - try { - // Rate limiting 체크 - const limiterResult = await rateLimit(request); - if (!limiterResult.success) { - console.warn('🚨 Rate limit 초과:', { - ip: requestInfo.ip, - userAgent: requestInfo.userAgent - }); - - return NextResponse.json( - { error: "Too many requests" }, - { status: 429 } - ); - } - - // 세션 확인 - const session = await getServerSession(authOptions); - if (!session?.user) { - console.warn('🚨 인증되지 않은 다운로드 시도:', { - ip: requestInfo.ip, - userAgent: requestInfo.userAgent, - path: request.nextUrl.searchParams.get("path") - }); - - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); - } - - // 파라미터 검증 - const searchParams = { - path: request.nextUrl.searchParams.get("path"), - }; - - const validatedParams = downloadRequestSchema.parse(searchParams); - const { path } = validatedParams; - - // 파일 경로 보안 검증 - if (!validateFilePath(path)) { - console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { - userId: session.user.id, - ip: requestInfo.ip, - userAgent: requestInfo.userAgent - }); - - return NextResponse.json( - { error: "Invalid file path" }, - { status: 400 } - ); - } - - // 경로 정규화 - const normalizedPath = normalize(path.replace(/^\/+/, "")); - - // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) - const [dbRecord] = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - fileType: vendorResponseAttachments.fileType, - }) - .from(vendorResponseAttachments) - .where(eq(vendorResponseAttachments.filePath, normalizedPath)); - - // DB에서 파일 정보를 찾지 못한 경우 - if (!dbRecord) { - console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { - path, - normalizedPath, - userId: session.user.id, - ip: requestInfo.ip - }); - - return NextResponse.json( - { error: "File not found in database" }, - { status: 404 } - ); - } - - fileRecord = dbRecord; - - // 파일명 설정 - const fileName = dbRecord.fileName; - - // 파일 확장자 검증 - if (!validateFileExtension(fileName)) { - console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { - userId: session.user.id, - ip: requestInfo.ip - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File type not allowed', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: 0, - } - }); - - return NextResponse.json( - { error: "File type not allowed" }, - { status: 403 } - ); - } - - // 안전한 파일 경로 구성 - const allowedDirs = ["public", "uploads", "storage"]; - let actualPath: string | null = null; - let baseDir: string | null = null; - - // 각 허용된 디렉터리에서 파일 찾기 - for (const dir of allowedDirs) { - baseDir = resolve(process.cwd(), dir); - const testPath = resolve(baseDir, normalizedPath); - - // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 - if (!testPath.startsWith(baseDir)) { - continue; - } - - try { - await access(testPath, constants.R_OK); - actualPath = testPath; - console.log("✅ 파일 발견:", testPath); - break; - } catch (err) { - console.log("❌ 경로에 파일 없음:", testPath); - } - } - - if (!actualPath || !baseDir) { - console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { - normalizedPath, - userId: session.user.id, - requestedPath: path, - triedDirs: allowedDirs - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File not found on server', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: dbRecord.fileSize || 0, - } - }); - - return NextResponse.json( - { - error: "File not found on server", - details: { - path: path, - fileName: fileName, - } - }, - { status: 404 } - ); - } - - // 파일 크기 확인 - const stats = await stat(actualPath); - if (stats.size > MAX_FILE_SIZE) { - console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, { - userId: session.user.id, - ip: requestInfo.ip - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File too large', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: stats.size, - } - }); - - return NextResponse.json( - { error: "File too large" }, - { status: 413 } - ); - } - - // 파일 읽기 - const fileBuffer = await readFile(actualPath); - - // MIME 타입 결정 - const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - let contentType = dbRecord.fileType || 'application/octet-stream'; - - // 확장자에 따른 MIME 타입 매핑 (fallback) - if (!contentType || contentType === 'application/octet-stream') { - const mimeTypes: Record<string, string> = { - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain; charset=utf-8', - 'csv': 'text/csv; charset=utf-8', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'bmp': 'image/bmp', - 'svg': 'image/svg+xml', - 'dwg': 'application/acad', - 'dxf': 'application/dxf', - 'zip': 'application/zip', - 'rar': 'application/x-rar-compressed', - '7z': 'application/x-7z-compressed', - }; - - contentType = mimeTypes[fileExtension] || 'application/octet-stream'; - } - - // 안전한 파일명 생성 - const safeFileName = sanitizeFileName(fileName); - - // 보안 헤더와 다운로드용 헤더 설정 - const headers = new Headers(); - headers.set('Content-Type', contentType); - headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); - headers.set('Content-Length', fileBuffer.length.toString()); - - // 보안 헤더 - headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); - headers.set('Pragma', 'no-cache'); - headers.set('Expires', '0'); - headers.set('X-Content-Type-Options', 'nosniff'); - headers.set('X-Frame-Options', 'DENY'); - headers.set('X-XSS-Protection', '1; mode=block'); - headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - // 성공 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: true, - requestId: requestInfo.requestId, - fileInfo: { - fileName: safeFileName, - filePath: path, - fileSize: fileBuffer.length, - } - }); - - console.log("✅ TBE 파일 다운로드 성공:", { - fileName: safeFileName, - contentType, - size: fileBuffer.length, - actualPath, - userId: session.user.id, - ip: requestInfo.ip, - downloadDurationMs: Date.now() - startTime - }); - - return new NextResponse(fileBuffer, { - status: 200, - headers, - }); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - console.error('❌ TBE 파일 다운로드 오류:', { - error: errorMessage, - userId: (await getServerSession(authOptions))?.user?.id, - ip: requestInfo.ip, - path: request.nextUrl.searchParams.get("path"), - downloadDurationMs: Date.now() - startTime - }); - - // 에러 로그 기록 - if (fileRecord?.id) { - try { - await createFileDownloadLog({ - fileId: fileRecord.id, - success: false, - errorMessage, - requestId: requestInfo.requestId, - fileInfo: { - fileName: fileRecord.fileName || 'unknown', - filePath: request.nextUrl.searchParams.get("path") || '', - fileSize: fileRecord.fileSize || 0, - } - }); - } catch (logError) { - console.error('로그 기록 실패:', logError); - } - } - - // Zod 검증 에러 처리 - if (error instanceof z.ZodError) { - return NextResponse.json( - { - error: 'Invalid request parameters', - details: error.errors.map(e => e.message).join(', ') - }, - { status: 400 } - ); - } - - // 에러 정보 최소화 (정보 노출 방지) - return NextResponse.json( - { - error: 'Internal server error', - details: process.env.NODE_ENV === 'development' ? errorMessage : undefined - }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/update-comment/route.ts b/app/api/vendor-responses/update-comment/route.ts deleted file mode 100644 index f1e4c487..00000000 --- a/app/api/vendor-responses/update-comment/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -// app/api/vendor-responses/update-comment/route.ts -import { NextRequest, NextResponse } from "next/server"; -import db from "@/db/db"; -import { vendorAttachmentResponses } from "@/db/schema"; - -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { eq } from "drizzle-orm"; - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const body = await request.json(); - const { responseId, responseComment, vendorComment } = body; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - // 코멘트만 업데이트 - const [updatedResponse] = await db - .update(vendorAttachmentResponses) - .set({ - responseComment, - vendorComment, - updatedAt: new Date(), - updatedBy:Number(session?.user.id) - }) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .returning(); - - if (!updatedResponse) { - return NextResponse.json( - { message: "응답을 찾을 수 없습니다." }, - { status: 404 } - ); - } - - return NextResponse.json({ - message: "코멘트가 성공적으로 업데이트되었습니다.", - response: updatedResponse, - }); - - } catch (error) { - console.error("Comment update error:", error); - return NextResponse.json( - { message: "코멘트 업데이트 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/update/route.ts b/app/api/vendor-responses/update/route.ts deleted file mode 100644 index cf7e551c..00000000 --- a/app/api/vendor-responses/update/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -// app/api/vendor-responses/update/route.ts -import { NextRequest, NextResponse } from "next/server"; -import db from "@/db/db"; -import { vendorAttachmentResponses } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -// 리비전 번호를 증가시키는 헬퍼 함수 -function getNextRevision(currentRevision?: string): string { - if (!currentRevision) { - return "Rev.0"; // 첫 번째 응답 - } - - // "Rev.1" -> 1, "Rev.2" -> 2 형태로 숫자 추출 - const match = currentRevision.match(/Rev\.(\d+)/); - if (match) { - const currentNumber = parseInt(match[1]); - return `Rev.${currentNumber + 1}`; - } - - // 형식이 다르면 기본값 반환 - return "Rev.0"; -} - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const body = await request.json(); - const { - responseId, - responseStatus, - responseComment, - vendorComment, - respondedAt, - } = body; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - // 1. 기존 응답 정보 조회 (현재 respondedRevision 확인) - const existingResponse = await db - .select() - .from(vendorAttachmentResponses) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .limit(1); - - if (!existingResponse || existingResponse.length === 0) { - return NextResponse.json( - { message: "응답을 찾을 수 없습니다." }, - { status: 404 } - ); - } - - const currentResponse = existingResponse[0]; - - // 2. 벤더 응답 리비전 결정 - let nextRespondedRevision: string; - - - if (responseStatus === "RESPONDED") { - - // 첫 응답이거나 수정 요청 후 재응답인 경우 리비전 증가 - nextRespondedRevision = getNextRevision(currentResponse.respondedRevision); - - } else { - // WAIVED 등 다른 상태는 기존 리비전 유지 - nextRespondedRevision = currentResponse.respondedRevision || ""; - } - - // 3. vendor response 업데이트 - const [updatedResponse] = await db - .update(vendorAttachmentResponses) - .set({ - responseStatus, - respondedRevision: nextRespondedRevision, - responseComment, - vendorComment, - respondedAt: respondedAt ? new Date(respondedAt) : null, - updatedAt: new Date(), - updatedBy:Number(session?.user.id) - }) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .returning(); - - if (!updatedResponse) { - return NextResponse.json( - { message: "응답 업데이트에 실패했습니다." }, - { status: 500 } - ); - } - - return NextResponse.json({ - message: "응답이 성공적으로 업데이트되었습니다.", - response: updatedResponse, - newRevision: nextRespondedRevision, // 새로운 리비전 정보 반환 - }); - - } catch (error) { - console.error("Response update error:", error); - return NextResponse.json( - { message: "응답 업데이트 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/upload/route.ts b/app/api/vendor-responses/upload/route.ts deleted file mode 100644 index 111e4bd4..00000000 --- a/app/api/vendor-responses/upload/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -// app/api/vendor-response-attachments/upload/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { writeFile, mkdir } from "fs/promises"; -import { existsSync } from "fs"; -import path from "path"; -import db from "@/db/db"; -import { vendorResponseAttachmentsB } from "@/db/schema"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const formData = await request.formData(); - const responseId = formData.get("responseId") as string; - const file = formData.get("file") as File; - const description = formData.get("description") as string; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - if (!file) { - return NextResponse.json( - { message: "파일이 선택되지 않았습니다." }, - { status: 400 } - ); - } - - // 파일 크기 검증 (10MB) - if (file.size > 10 * 1024 * 1024) { - return NextResponse.json( - { message: "파일이 너무 큽니다. (최대 10MB)" }, - { status: 400 } - ); - } - - // 업로드 디렉토리 생성 - const uploadDir = path.join( - process.cwd(), - "public", - "uploads", - "vendor-responses", - responseId - ); - - if (!existsSync(uploadDir)) { - await mkdir(uploadDir, { recursive: true }); - } - - // 고유한 파일명 생성 - const timestamp = Date.now(); - const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); - const fileName = `${timestamp}_${sanitizedName}`; - const filePath = `/uploads/vendor-responses/${responseId}/${fileName}`; - const fullPath = path.join(uploadDir, fileName); - - // 파일 저장 - const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(fullPath, buffer); - - // DB에 파일 정보 저장 - const [insertedFile] = await db - .insert(vendorResponseAttachmentsB) - .values({ - vendorResponseId: parseInt(responseId), - fileName, - originalFileName: file.name, - filePath, - fileSize: file.size, - fileType: file.type || path.extname(file.name).slice(1), - description: description || null, - uploadedBy: parseInt(session.user.id), - }) - .returning(); - - return NextResponse.json({ - id: insertedFile.id, - fileName, - originalFileName: file.name, - filePath, - fileSize: file.size, - fileType: file.type || path.extname(file.name).slice(1), - message: "파일이 성공적으로 업로드되었습니다.", - }); - - } catch (error) { - console.error("File upload error:", error); - return NextResponse.json( - { message: "파일 업로드 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/waive/route.ts b/app/api/vendor-responses/waive/route.ts deleted file mode 100644 index e732e8d2..00000000 --- a/app/api/vendor-responses/waive/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -// app/api/vendor-responses/waive/route.ts -import { NextRequest, NextResponse } from "next/server"; -import db from "@/db/db"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { eq } from "drizzle-orm"; -import { vendorAttachmentResponses } from "@/db/schema"; - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const body = await request.json(); - const { responseId, responseComment, vendorComment } = body; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - if (!responseComment) { - return NextResponse.json( - { message: "포기 사유를 입력해주세요." }, - { status: 400 } - ); - } - - // vendor response를 WAIVED 상태로 업데이트 - const [updatedResponse] = await db - .update(vendorAttachmentResponses) - .set({ - responseStatus: "WAIVED", - responseComment, - vendorComment, - respondedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .returning(); - - if (!updatedResponse) { - return NextResponse.json( - { message: "응답을 찾을 수 없습니다." }, - { status: 404 } - ); - } - - return NextResponse.json({ - message: "응답이 성공적으로 포기 처리되었습니다.", - response: updatedResponse, - }); - - } catch (error) { - console.error("Waive response error:", error); - return NextResponse.json( - { message: "응답 포기 처리 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx index 58fa2c23..45963d88 100644 --- a/components/ProjectSelector.tsx +++ b/components/ProjectSelector.tsx @@ -6,7 +6,14 @@ import { Button } from "@/components/ui/button" import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" import { cn } from "@/lib/utils" -import { getProjects, type Project } from "@/lib/rfqs/service" +import { getProjects } from "@/lib/projects/service" + +export type Project = { + id: number; + projectCode: string; + projectName: string; + type: string; +} interface ProjectSelectorProps { selectedProjectId?: number | null; diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx index a87c8dce..8a4b85af 100644 --- a/components/bidding/ProjectSelectorBid.tsx +++ b/components/bidding/ProjectSelectorBid.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" import { cn } from "@/lib/utils" -import { getProjects, type Project } from "@/lib/rfqs/service" +import { getProjects } from "@/lib/projects/service" interface ProjectSelectorProps { selectedProjectId?: number | null; @@ -16,6 +16,13 @@ interface ProjectSelectorProps { disabled?: boolean; } +export type Project = { + id: number; + projectCode: string; + projectName: string; + type: string; +} + export function ProjectSelector({ selectedProjectId, onProjectSelect, diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 0c83e858..2752948a 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -123,7 +123,60 @@ export function Header() { }, [pathname]); // 도메인별 메뉴 및 브랜딩 정보 가져오기 - const getDomainConfig = (pathname: string | null) => { + // session.user.domain이 있으면 그것을 우선적으로 따르고, 없으면 pathname을 따릅니다. + const userDomain = (session?.user as { domain?: string } | undefined)?.domain; + + const getDomainConfig = (pathname: string | null, domain?: string) => { + // 1. 세션 도메인 기반 설정 + if (domain) { + if (domain === "partners") { + return { + main: mainNavVendor, + additional: additionalNavVendor, + logoHref: `/${lng}/partners`, + brandNameKey: domainBrandingKeys.partners, + basePath: `/${lng}/partners` + }; + } + if (domain === "procurement") { + return { + main: procurementNav, + additional: additional2Nav, + logoHref: `/${lng}/procurement`, + brandNameKey: domainBrandingKeys.procurement, + basePath: `/${lng}/procurement` + }; + } + if (domain === "sales") { + return { + main: salesNav, + additional: additional2Nav, + logoHref: `/${lng}/sales`, + brandNameKey: domainBrandingKeys.sales, + basePath: `/${lng}/sales` + }; + } + if (domain === "engineering") { + return { + main: engineeringNav, + additional: additional2Nav, + logoHref: `/${lng}/engineering`, + brandNameKey: domainBrandingKeys.engineering, + basePath: `/${lng}/engineering` + }; + } + if (domain === "evcp") { + return { + main: mainNav, + additional: additionalNav, + logoHref: `/${lng}/evcp`, + brandNameKey: domainBrandingKeys.evcp, + basePath: `/${lng}/evcp` + }; + } + } + + // 2. 경로 기반 설정 (Fallback) if (pathname?.includes("/partners")) { return { main: mainNavVendor, @@ -174,7 +227,7 @@ export function Header() { }; }; - const { main: originalMain, additional: originalAdditional, logoHref, brandNameKey, basePath } = getDomainConfig(pathname); + const { main: originalMain, additional: originalAdditional, logoHref, brandNameKey, basePath } = getDomainConfig(pathname, userDomain); // partners 도메인 여부 확인 const isPartners = pathname?.includes("/partners") ?? false; diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 860c2a88..76f1302e 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -221,24 +221,6 @@ export const mainNav: MenuSection[] = [ href: '/evcp/avl', descriptionKey: 'menu.vendor_management.avl_management_desc', }, - // 기존 project avl - // { - // titleKey: "menu.vendor_management.project_avl", - // href: "/evcp/project-vendors", - // descriptionKey: "menu.vendor_management.project_avl_desc", - // }, - { - titleKey: 'menu.vendor_management.legalReview', - href: '/evcp/legal-review', - // descriptionKey: "menu.vendor_management.legalReview_desc", - groupKey: 'groups.legal', - }, - { - titleKey: 'menu.vendor_management.legalResponse', - href: '/evcp/legal-response', - // descriptionKey: "menu.vendor_management.legalResponse_desc", - groupKey: 'groups.legal', - }, { titleKey: 'menu.vendor_management.risk_by_agency', href: '/evcp/risk-management', diff --git a/lib/b-rfq/attachment/add-attachment-dialog.tsx b/lib/b-rfq/attachment/add-attachment-dialog.tsx deleted file mode 100644 index 665e0f88..00000000 --- a/lib/b-rfq/attachment/add-attachment-dialog.tsx +++ /dev/null @@ -1,355 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Plus ,X} from "lucide-react" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { addRfqAttachmentRecord } from "../service" - -// 첨부파일 추가 폼 스키마 (단일 파일) -const addAttachmentSchema = z.object({ - attachmentType: z.enum(["구매", "설계"], { - required_error: "문서 타입을 선택해주세요.", - }), - description: z.string().optional(), - file: z.instanceof(File, { - message: "파일을 선택해주세요.", - }), -}) - -type AddAttachmentFormData = z.infer<typeof addAttachmentSchema> - -interface AddAttachmentDialogProps { - rfqId: number -} - -export function AddAttachmentDialog({ rfqId }: AddAttachmentDialogProps) { - const [open, setOpen] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [uploadProgress, setUploadProgress] = React.useState<number>(0) - - const form = useForm<AddAttachmentFormData>({ - resolver: zodResolver(addAttachmentSchema), - defaultValues: { - attachmentType: undefined, - description: "", - file: undefined, - }, - }) - - const selectedFile = form.watch("file") - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && !isSubmitting) { - form.reset() - } - setOpen(newOpen) - } - - // 파일 선택 처리 - const handleFileChange = (files: File[]) => { - if (files.length === 0) return - - const file = files[0] // 첫 번째 파일만 사용 - - // 파일 크기 검증 - const maxFileSize = 10 * 1024 * 1024 // 10MB - if (file.size > maxFileSize) { - toast.error(`파일이 너무 큽니다. (최대 10MB)`) - return - } - - form.setValue("file", file) - form.clearErrors("file") - } - - // 파일 제거 - const removeFile = () => { - form.resetField("file") - } - - // 파일 업로드 API 호출 - const uploadFile = async (file: File): Promise<{ - fileName: string - originalFileName: string - filePath: string - fileSize: number - fileType: string - }> => { - const formData = new FormData() - formData.append("rfqId", rfqId.toString()) - formData.append("file", file) - - const response = await fetch("/api/upload/rfq-attachment", { - method: "POST", - body: formData, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || "파일 업로드 실패") - } - - return response.json() - } - - // 폼 제출 - const onSubmit = async (data: AddAttachmentFormData) => { - setIsSubmitting(true) - setUploadProgress(0) - - try { - // 1단계: 파일 업로드 - setUploadProgress(30) - const uploadedFile = await uploadFile(data.file) - - // 2단계: DB 레코드 생성 (시리얼 번호 자동 생성) - setUploadProgress(70) - const attachmentRecord = { - rfqId, - attachmentType: data.attachmentType, - description: data.description, - fileName: uploadedFile.fileName, - originalFileName: uploadedFile.originalFileName, - filePath: uploadedFile.filePath, - fileSize: uploadedFile.fileSize, - fileType: uploadedFile.fileType, - } - - const result = await addRfqAttachmentRecord(attachmentRecord) - - setUploadProgress(100) - - if (result.success) { - toast.success(result.message) - form.reset() - handleOpenChange(false) - } else { - toast.error(result.message) - } - - } catch (error) { - console.error("Upload error:", error) - toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - setUploadProgress(0) - } - } - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">새 첨부</span> - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle>새 첨부파일 추가</DialogTitle> - <DialogDescription> - RFQ에 첨부할 문서를 업로드합니다. 시리얼 번호는 자동으로 부여됩니다. - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 문서 타입 선택 */} - <FormField - control={form.control} - name="attachmentType" - render={({ field }) => ( - <FormItem> - <FormLabel>문서 타입</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="문서 타입을 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="구매">구매</SelectItem> - <SelectItem value="설계">설계</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>설명 (선택)</FormLabel> - <FormControl> - <Textarea - placeholder="첨부파일에 대한 설명을 입력하세요" - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 선택 - Dropzone (단일 파일) */} - <FormField - control={form.control} - name="file" - render={({ field }) => ( - <FormItem> - <FormLabel>파일 선택</FormLabel> - <FormControl> - <div className="space-y-3"> - <Dropzone - onDrop={(acceptedFiles) => { - handleFileChange(acceptedFiles) - }} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'application/zip': ['.zip'], - 'application/x-rar-compressed': ['.rar'] - }} - maxSize={10 * 1024 * 1024} // 10MB - multiple={false} // 단일 파일만 - disabled={isSubmitting} - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle> - <DropzoneDescription> - PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개) - </DropzoneDescription> - <DropzoneInput /> - </DropzoneZone> - </Dropzone> - - {/* 선택된 파일 표시 */} - {selectedFile && ( - <div className="space-y-2"> - <FileListHeader> - 선택된 파일 - </FileListHeader> - <FileList> - <FileListItem className="flex items-center justify-between gap-3"> - <FileListIcon /> - <FileListInfo> - <FileListName>{selectedFile.name}</FileListName> - <FileListDescription> - <FileListSize>{selectedFile.size}</FileListSize> - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={removeFile} - disabled={isSubmitting} - > - <X className="h-4 w-4" /> - </FileListAction> - </FileListItem> - </FileList> - </div> - )} - - {/* 업로드 진행률 */} - {isSubmitting && uploadProgress > 0 && ( - <div className="space-y-2"> - <div className="flex justify-between text-sm"> - <span>업로드 진행률</span> - <span>{uploadProgress}%</span> - </div> - <div className="w-full bg-gray-200 rounded-full h-2"> - <div - className="bg-blue-600 h-2 rounded-full transition-all duration-300" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="submit" disabled={isSubmitting || !selectedFile}> - {isSubmitting ? "업로드 중..." : "업로드"} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/add-revision-dialog.tsx b/lib/b-rfq/attachment/add-revision-dialog.tsx deleted file mode 100644 index 1abefb02..00000000 --- a/lib/b-rfq/attachment/add-revision-dialog.tsx +++ /dev/null @@ -1,336 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Upload } from "lucide-react" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { addRevisionToAttachment } from "../service" - -// 리비전 추가 폼 스키마 -const addRevisionSchema = z.object({ - revisionComment: z.string().optional(), - file: z.instanceof(File, { - message: "파일을 선택해주세요.", - }), -}) - -type AddRevisionFormData = z.infer<typeof addRevisionSchema> - -interface AddRevisionDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - attachmentId: number - currentRevision: string - originalFileName: string - onSuccess?: () => void -} - -export function AddRevisionDialog({ - open, - onOpenChange, - attachmentId, - currentRevision, - originalFileName, - onSuccess -}: AddRevisionDialogProps) { - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [uploadProgress, setUploadProgress] = React.useState<number>(0) - - const form = useForm<AddRevisionFormData>({ - resolver: zodResolver(addRevisionSchema), - defaultValues: { - revisionComment: "", - file: undefined, - }, - }) - - const selectedFile = form.watch("file") - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && !isSubmitting) { - form.reset() - } - onOpenChange(newOpen) - } - - // 파일 선택 처리 - const handleFileChange = (files: File[]) => { - if (files.length === 0) return - - const file = files[0] - - // 파일 크기 검증 - const maxFileSize = 10 * 1024 * 1024 // 10MB - if (file.size > maxFileSize) { - toast.error(`파일이 너무 큽니다. (최대 10MB)`) - return - } - - form.setValue("file", file) - form.clearErrors("file") - } - - // 파일 제거 - const removeFile = () => { - form.resetField("file") - } - - // 파일 업로드 API 호출 - const uploadFile = async (file: File): Promise<{ - fileName: string - originalFileName: string - filePath: string - fileSize: number - fileType: string - }> => { - const formData = new FormData() - formData.append("attachmentId", attachmentId.toString()) - formData.append("file", file) - formData.append("isRevision", "true") - - const response = await fetch("/api/upload/rfq-attachment-revision", { - method: "POST", - body: formData, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || "파일 업로드 실패") - } - - return response.json() - } - - // 폼 제출 - const onSubmit = async (data: AddRevisionFormData) => { - setIsSubmitting(true) - setUploadProgress(0) - - try { - // 1단계: 파일 업로드 - setUploadProgress(30) - const uploadedFile = await uploadFile(data.file) - - // 2단계: DB 리비전 레코드 생성 - setUploadProgress(70) - const result = await addRevisionToAttachment(attachmentId, { - fileName: uploadedFile.fileName, - originalFileName: uploadedFile.originalFileName, - filePath: uploadedFile.filePath, - fileSize: uploadedFile.fileSize, - fileType: uploadedFile.fileType, - revisionComment: data.revisionComment, - }) - - setUploadProgress(100) - - if (result.success) { - toast.success(result.message) - form.reset() - handleOpenChange(false) - onSuccess?.() - } else { - toast.error(result.message) - } - - } catch (error) { - console.error("Upload error:", error) - toast.error(error instanceof Error ? error.message : "리비전 추가 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - setUploadProgress(0) - } - } - - // 다음 리비전 번호 계산 - const getNextRevision = (current: string) => { - const match = current.match(/Rev\.(\d+)/) - if (match) { - const num = parseInt(match[1]) + 1 - return `Rev.${num}` - } - return "Rev.1" - } - - const nextRevision = getNextRevision(currentRevision) - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Upload className="h-5 w-5" /> - 새 리비전 추가 - </DialogTitle> - <DialogDescription> - "{originalFileName}"의 새 버전을 업로드합니다. - 현재 {currentRevision} → {nextRevision} - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 리비전 코멘트 */} - <FormField - control={form.control} - name="revisionComment" - render={({ field }) => ( - <FormItem> - <FormLabel>리비전 코멘트 (선택)</FormLabel> - <FormControl> - <Textarea - placeholder={`${nextRevision} 업데이트 내용을 입력하세요`} - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 선택 - Dropzone (단일 파일) */} - <FormField - control={form.control} - name="file" - render={({ field }) => ( - <FormItem> - <FormLabel>새 파일 선택</FormLabel> - <FormControl> - <div className="space-y-3"> - <Dropzone - onDrop={(acceptedFiles) => { - handleFileChange(acceptedFiles) - }} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'application/zip': ['.zip'], - 'application/x-rar-compressed': ['.rar'] - }} - maxSize={10 * 1024 * 1024} // 10MB - multiple={false} - disabled={isSubmitting} - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle> - <DropzoneDescription> - PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개) - </DropzoneDescription> - <DropzoneInput /> - </DropzoneZone> - </Dropzone> - - {/* 선택된 파일 표시 */} - {selectedFile && ( - <div className="space-y-2"> - <FileListHeader> - 선택된 파일 ({nextRevision}) - </FileListHeader> - <FileList> - <FileListItem> - <FileListIcon /> - <FileListInfo> - <FileListName>{selectedFile.name}</FileListName> - <FileListDescription> - <FileListSize>{selectedFile.size}</FileListSize> - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={removeFile} - disabled={isSubmitting} - /> - </FileListItem> - </FileList> - </div> - )} - - {/* 업로드 진행률 */} - {isSubmitting && uploadProgress > 0 && ( - <div className="space-y-2"> - <div className="flex justify-between text-sm"> - <span>업로드 진행률</span> - <span>{uploadProgress}%</span> - </div> - <div className="w-full bg-gray-200 rounded-full h-2"> - <div - className="bg-blue-600 h-2 rounded-full transition-all duration-300" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="submit" disabled={isSubmitting || !selectedFile}> - {isSubmitting ? "업로드 중..." : `${nextRevision} 추가`} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/attachment-columns.tsx b/lib/b-rfq/attachment/attachment-columns.tsx deleted file mode 100644 index b726ebc8..00000000 --- a/lib/b-rfq/attachment/attachment-columns.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { - Ellipsis, FileText, Download, Eye, - MessageSquare, Upload -} from "lucide-react" - -import { formatDate, formatBytes } 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, - DropdownMenuSeparator, DropdownMenuTrigger -} from "@/components/ui/dropdown-menu" -import { Progress } from "@/components/ui/progress" -import { RevisionDialog } from "./revision-dialog" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { AddRevisionDialog } from "./add-revision-dialog" - -interface GetAttachmentColumnsProps { - onSelectAttachment: (attachment: any) => void -} - -export function getAttachmentColumns({ - onSelectAttachment -}: GetAttachmentColumnsProps): ColumnDef<any>[] { - - return [ - /** ───────────── 체크박스 ───────────── */ - { - 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, - }, - - /** ───────────── 문서 정보 ───────────── */ - { - accessorKey: "serialNo", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="시리얼 번호" /> - ), - cell: ({ row }) => ( - <Button - variant="link" - className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800" - onClick={() => onSelectAttachment(row.original)} - > - {row.getValue("serialNo") as string} - </Button> - ), - size: 100, - }, - { - accessorKey: "attachmentType", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="문서 타입" /> - ), - cell: ({ row }) => { - const type = row.getValue("attachmentType") as string - return ( - <Badge variant={type === "구매" ? "default" : "secondary"}> - {type} - </Badge> - ) - }, - size:100 - }, - { - accessorKey: "originalFileName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="파일명" /> - ), - cell: ({ row }) => { - const fileName = row.getValue("originalFileName") as string - return ( - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <div className="min-w-0 flex-1"> - <div className="truncate font-medium" title={fileName}> - {fileName} - </div> - </div> - </div> - ) - }, - size:250 - }, - { - id: "currentRevision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="리비전" /> - ), - cell: ({ row }) => ( - <RevisionDialog - attachmentId={row.original.id} - currentRevision={row.original.currentRevision} - originalFileName={row.original.originalFileName} - /> - ), - size: 100, - }, - { - accessorKey: "description", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="설명" /> - ), - cell: ({ row }) => { - const description = row.getValue("description") as string - return description - ? <div className="max-w-[200px] truncate" title={description}>{description}</div> - : <span className="text-muted-foreground">-</span> - }, - }, - - /** ───────────── 파일 정보 ───────────── */ - // { - // accessorKey: "fileSize", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="파일 크기" /> - // ), - // cell: ({ row }) => { - // const size = row.getValue("fileSize") as number - // return size ? formatBytes(size) : "-" - // }, - // }, - { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등록일" /> - ), - cell: ({ row }) => { - const created = row.getValue("createdAt") as Date - const updated = row.original.updatedAt as Date - return ( - <div> - <div>{formatDate(created, "KR")}</div> - <div className="text-xs text-muted-foreground"> - {row.original.createdByName} - </div> - {updated && new Date(updated) > new Date(created) && ( - <div className="text-xs text-blue-600"> - 수정: {formatDate(updated, "KR")} - </div> - )} - </div> - ) - }, - maxSize:150 - }, - - /** ───────────── 벤더 응답 현황 ───────────── */ - { - id: "vendorCount", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 수" /> - ), - cell: ({ row }) => { - const stats = row.original.responseStats - return stats - ? ( - <div className="text-center"> - <div className="font-medium">{stats.totalVendors}</div> - <div className="text-xs text-muted-foreground"> - 활성: {stats.totalVendors - stats.waivedCount} - </div> - </div> - ) - : <span className="text-muted-foreground">-</span> - }, - }, - { - id: "responseStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="응답 현황" /> - ), - cell: ({ row }) => { - const stats = row.original.responseStats - return stats - ? ( - <div className="space-y-1"> - <div className="flex items-center gap-2"> - <div className="flex-1"> - <Progress value={stats.responseRate} className="h-2" /> - </div> - <span className="text-sm font-medium"> - {stats.responseRate}% - </span> - </div> - <div className="flex gap-2 text-xs"> - <span className="text-green-600"> - 응답: {stats.respondedCount} - </span> - <span className="text-orange-600"> - 대기: {stats.pendingCount} - </span> - {stats.waivedCount > 0 && ( - <span className="text-gray-500"> - 면제: {stats.waivedCount} - </span> - )} - </div> - </div> - ) - : <span className="text-muted-foreground">-</span> - }, - }, - - /** ───────────── 액션 ───────────── */ - { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false) - - 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-48"> - <DropdownMenuItem onClick={() => onSelectAttachment(row.original)}> - <MessageSquare className="mr-2 h-4 w-4" /> - 벤더 응답 보기 - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => row.original.filePath && window.open(row.original.filePath, "_blank")} - > - <Download className="mr-2 h-4 w-4" /> - 다운로드 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => setIsAddRevisionOpen(true)}> - <Upload className="mr-2 h-4 w-4" /> - 새 리비전 추가 - </DropdownMenuItem> - <DropdownMenuItem className="text-red-600"> - 삭제 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - <AddRevisionDialog - open={isAddRevisionOpen} - onOpenChange={setIsAddRevisionOpen} - attachmentId={row.original.id} - currentRevision={row.original.currentRevision} - originalFileName={row.original.originalFileName} - onSuccess={() => window.location.reload()} - /> - </> - ) - }, - size: 40, - }, - ] -} diff --git a/lib/b-rfq/attachment/attachment-table.tsx b/lib/b-rfq/attachment/attachment-table.tsx deleted file mode 100644 index 4c547000..00000000 --- a/lib/b-rfq/attachment/attachment-table.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { VendorResponsesPanel } from "./vendor-responses-panel" -import { Separator } from "@/components/ui/separator" -import { FileText } from "lucide-react" -import { getRfqAttachments, getVendorResponsesForAttachment } from "../service" -import { getAttachmentColumns } from "./attachment-columns" -import { RfqAttachmentsTableToolbarActions } from "./attachment-toolbar-action" - -interface RfqAttachmentsTableProps { - promises: Promise<Awaited<ReturnType<typeof getRfqAttachments>>> - rfqId: number -} - -export function RfqAttachmentsTable({ promises, rfqId }: RfqAttachmentsTableProps) { - const { data, pageCount } = React.use(promises) - - // 선택된 첨부파일과 벤더 응답 데이터 - const [selectedAttachment, setSelectedAttachment] = React.useState<any>(null) - const [vendorResponses, setVendorResponses] = React.useState<any[]>([]) - const [isLoadingResponses, setIsLoadingResponses] = React.useState(false) - - const columns = React.useMemo( - () => getAttachmentColumns({ - onSelectAttachment: setSelectedAttachment - }), - [] - ) - - // 첨부파일 선택 시 벤더 응답 데이터 로드 - React.useEffect(() => { - if (!selectedAttachment) { - setVendorResponses([]) - return - } - - const loadVendorResponses = async () => { - setIsLoadingResponses(true) - try { - const responses = await getVendorResponsesForAttachment( - selectedAttachment.id, - 'INITIAL' // 또는 현재 RFQ 상태에 따라 결정 - ) - setVendorResponses(responses) - } catch (error) { - console.error('Failed to load vendor responses:', error) - setVendorResponses([]) - } finally { - setIsLoadingResponses(false) - } - } - - loadVendorResponses() - }, [selectedAttachment]) - - /** - * 필터 필드 정의 - */ - const filterFields: DataTableFilterField<any>[] = [ - { - id: "fileName", - label: "파일명", - placeholder: "파일명으로 검색...", - }, - { - id: "attachmentType", - label: "문서 타입", - options: [ - { label: "구매 문서", value: "구매", count: 0 }, - { label: "설계 문서", value: "설계", count: 0 }, - ], - }, - { - id: "fileType", - label: "파일 형식", - options: [ - { label: "PDF", value: "pdf", count: 0 }, - { label: "Excel", value: "xlsx", count: 0 }, - { label: "Word", value: "docx", count: 0 }, - { label: "기타", value: "기타", count: 0 }, - ], - }, - ] - - /** - * 고급 필터 필드 - */ - const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { - id: "fileName", - label: "파일명", - type: "text", - }, - { - id: "originalFileName", - label: "원본 파일명", - type: "text", - }, - { - id: "serialNo", - label: "시리얼 번호", - type: "text", - }, - { - id: "description", - label: "설명", - type: "text", - }, - { - id: "attachmentType", - label: "문서 타입", - type: "multi-select", - options: [ - { label: "구매 문서", value: "구매" }, - { label: "설계 문서", value: "설계" }, - ], - }, - { - id: "fileType", - label: "파일 형식", - type: "multi-select", - options: [ - { label: "PDF", value: "pdf" }, - { label: "Excel", value: "xlsx" }, - { label: "Word", value: "docx" }, - { label: "기타", value: "기타" }, - ], - }, - { - id: "createdAt", - label: "등록일", - type: "date", - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => originalRow.id.toString(), - shallow: false, - clearOnDefault: true, - }) - - return ( - <div className="space-y-6"> - {/* 메인 테이블 */} - <div className="h-full w-full"> - <DataTable table={table} className="h-full"> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqAttachmentsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - </div> - - {/* 벤더 응답 현황 패널 */} - {selectedAttachment && ( - <> - <Separator /> - <VendorResponsesPanel - attachment={selectedAttachment} - responses={vendorResponses} - isLoading={isLoadingResponses} - onRefresh={() => { - // 새로고침 로직 - if (selectedAttachment) { - setSelectedAttachment({ ...selectedAttachment }) - } - }} - /> - </> - )} - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/attachment-toolbar-action.tsx b/lib/b-rfq/attachment/attachment-toolbar-action.tsx deleted file mode 100644 index e078ea66..00000000 --- a/lib/b-rfq/attachment/attachment-toolbar-action.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" - -import { AddAttachmentDialog } from "./add-attachment-dialog" -import { ConfirmDocumentsDialog } from "./confirm-documents-dialog" -import { TbeRequestDialog } from "./tbe-request-dialog" -import { DeleteAttachmentsDialog } from "./delete-attachment-dialog" - -interface RfqAttachmentsTableToolbarActionsProps { - table: Table<any> - rfqId: number -} - -export function RfqAttachmentsTableToolbarActions({ - table, - rfqId -}: RfqAttachmentsTableToolbarActionsProps) { - - // 선택된 행들 가져오기 - const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedAttachments = selectedRows.map((row) => row.original) - const selectedCount = selectedRows.length - - return ( - <div className="flex items-center gap-2"> - {/** 선택된 로우가 있으면 삭제 다이얼로그 */} - {selectedCount > 0 && ( - <DeleteAttachmentsDialog - attachments={selectedAttachments} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - )} - - {/** 새 첨부 추가 다이얼로그 */} - <AddAttachmentDialog rfqId={rfqId} /> - - {/** 문서 확정 다이얼로그 */} - <ConfirmDocumentsDialog - rfqId={rfqId} - onSuccess={() => { - // 성공 후 필요한 작업 (예: 페이지 새로고침) - window.location.reload() - }} - /> - - {/** TBE 요청 다이얼로그 (선택된 행이 있을 때만 활성화) */} - <TbeRequestDialog - rfqId={rfqId} - attachments={selectedAttachments} - onSuccess={() => { - // 선택 해제 및 페이지 새로고침 - table.toggleAllRowsSelected(false) - window.location.reload() - }} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/confirm-documents-dialog.tsx b/lib/b-rfq/attachment/confirm-documents-dialog.tsx deleted file mode 100644 index fccb4123..00000000 --- a/lib/b-rfq/attachment/confirm-documents-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client" - -import * as React from "react" -import { Loader, FileCheck } 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 { confirmDocuments } from "../service" - -interface ConfirmDocumentsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - showTrigger?: boolean - onSuccess?: () => void -} - -export function ConfirmDocumentsDialog({ - rfqId, - showTrigger = true, - onSuccess, - ...props -}: ConfirmDocumentsDialogProps) { - const [isConfirmPending, startConfirmTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onConfirm() { - startConfirmTransition(async () => { - const result = await confirmDocuments(rfqId) - - if (!result.success) { - toast.error(result.message) - return - } - - props.onOpenChange?.(false) - toast.success(result.message) - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <FileCheck className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">문서 확정</span> - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>문서를 확정하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다. - 확정 후에는 문서 수정이 제한될 수 있습니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Confirm documents" - onClick={onConfirm} - disabled={isConfirmPending} - > - {isConfirmPending && ( - <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"> - <FileCheck className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">문서 확정</span> - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>문서를 확정하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다. - 확정 후에는 문서 수정이 제한될 수 있습니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Confirm documents" - onClick={onConfirm} - disabled={isConfirmPending} - > - {isConfirmPending && ( - <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/b-rfq/attachment/delete-attachment-dialog.tsx b/lib/b-rfq/attachment/delete-attachment-dialog.tsx deleted file mode 100644 index b5471520..00000000 --- a/lib/b-rfq/attachment/delete-attachment-dialog.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"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 { deleteRfqAttachments } from "../service" - - -// 첨부파일 타입 (실제 타입에 맞게 조정 필요) -type RfqAttachment = { - id: number - serialNo: string - originalFileName: string - attachmentType: string - currentRevision: string -} - -interface DeleteAttachmentsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - attachments: Row<RfqAttachment>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteAttachmentsDialog({ - attachments, - showTrigger = true, - onSuccess, - ...props -}: DeleteAttachmentsDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startDeleteTransition(async () => { - const result = await deleteRfqAttachments({ - ids: attachments.map((attachment) => attachment.id), - }) - - if (!result.success) { - toast.error(result.message) - return - } - - props.onOpenChange?.(false) - toast.success(result.message) - onSuccess?.() - }) - } - - const attachmentText = attachments.length === 1 ? "첨부파일" : "첨부파일들" - const deleteWarning = `선택된 ${attachments.length}개의 ${attachmentText}과 모든 리비전이 영구적으로 삭제됩니다.` - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({attachments.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription className="space-y-2"> - <div>이 작업은 되돌릴 수 없습니다.</div> - <div>{deleteWarning}</div> - {attachments.length <= 3 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">삭제될 파일:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Delete selected attachments" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <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"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({attachments.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription className="space-y-2"> - <div>이 작업은 되돌릴 수 없습니다.</div> - <div>{deleteWarning}</div> - {attachments.length <= 3 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">삭제될 파일:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Delete selected attachments" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <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/b-rfq/attachment/request-revision-dialog.tsx b/lib/b-rfq/attachment/request-revision-dialog.tsx deleted file mode 100644 index 90d5b543..00000000 --- a/lib/b-rfq/attachment/request-revision-dialog.tsx +++ /dev/null @@ -1,205 +0,0 @@ -// components/rfq/request-revision-dialog.tsx -"use client"; - -import { useState, useTransition } from "react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Textarea } from "@/components/ui/textarea"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { AlertTriangle, Loader2 } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { requestRevision } from "../service"; - -const revisionFormSchema = z.object({ - revisionReason: z - .string() - .min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요") - .max(500, "수정 요청 사유는 500자를 초과할 수 없습니다"), -}); - -type RevisionFormData = z.infer<typeof revisionFormSchema>; - -interface RequestRevisionDialogProps { - responseId: number; - attachmentType: string; - serialNo: string; - vendorName?: string; - currentRevision: string; - trigger?: React.ReactNode; - onSuccess?: () => void; -} - -export function RequestRevisionDialog({ - responseId, - attachmentType, - serialNo, - vendorName, - currentRevision, - trigger, - onSuccess, -}: RequestRevisionDialogProps) { - const [open, setOpen] = useState(false); - const [isPending, startTransition] = useTransition(); - const { toast } = useToast(); - - const form = useForm<RevisionFormData>({ - resolver: zodResolver(revisionFormSchema), - defaultValues: { - revisionReason: "", - }, - }); - - const handleOpenChange = (newOpen: boolean) => { - setOpen(newOpen); - // 다이얼로그가 닫힐 때 form 리셋 - if (!newOpen) { - form.reset(); - } - }; - - const handleCancel = () => { - form.reset(); - setOpen(false); - }; - - const onSubmit = async (data: RevisionFormData) => { - startTransition(async () => { - try { - const result = await requestRevision(responseId, data.revisionReason); - - if (!result.success) { - throw new Error(result.message); - } - - toast({ - title: "수정 요청 완료", - description: result.message, - }); - - setOpen(false); - form.reset(); - onSuccess?.(); - - } catch (error) { - console.error("Request revision error:", error); - toast({ - title: "수정 요청 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", - variant: "destructive", - }); - } - }); - }; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - {trigger || ( - <Button size="sm" variant="outline"> - <AlertTriangle className="h-3 w-3 mr-1" /> - 수정요청 - </Button> - )} - </DialogTrigger> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <AlertTriangle className="h-5 w-5 text-orange-600" /> - 수정 요청 - </DialogTitle> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <Badge variant="outline">{serialNo}</Badge> - <span>{attachmentType}</span> - <Badge variant="secondary">{currentRevision}</Badge> - {vendorName && ( - <> - <span>•</span> - <span>{vendorName}</span> - </> - )} - </div> - </DialogHeader> - - <div className="space-y-4"> - <div className="bg-orange-50 border border-orange-200 rounded-lg p-4"> - <div className="flex items-start gap-2"> - <AlertTriangle className="h-4 w-4 text-orange-600 mt-0.5 flex-shrink-0" /> - <div className="text-sm text-orange-800"> - <p className="font-medium mb-1">수정 요청 안내</p> - <p> - 벤더에게 현재 제출된 응답에 대한 수정을 요청합니다. - 수정 요청 후 벤더는 새로운 파일을 다시 제출할 수 있습니다. - </p> - </div> - </div> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - <FormField - control={form.control} - name="revisionReason" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-base font-medium"> - 수정 요청 사유 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Textarea - placeholder="수정이 필요한 구체적인 사유를 입력해주세요... 예: 제출된 도면에서 치수 정보가 누락되었습니다." - className="resize-none" - rows={4} - disabled={isPending} - {...field} - /> - </FormControl> - <div className="flex justify-between text-xs text-muted-foreground"> - <FormMessage /> - <span>{field.value?.length || 0}/500</span> - </div> - </FormItem> - )} - /> - - <div className="flex justify-end gap-2 pt-2"> - <Button - type="button" - variant="outline" - onClick={handleCancel} - disabled={isPending} - > - 취소 - </Button> - <Button - type="submit" - disabled={isPending} - // className="bg-orange-600 hover:bg-orange-700" - > - {isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} - {isPending ? "요청 중..." : "수정 요청"} - </Button> - </div> - </form> - </Form> - </div> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/revision-dialog.tsx b/lib/b-rfq/attachment/revision-dialog.tsx deleted file mode 100644 index d26abedb..00000000 --- a/lib/b-rfq/attachment/revision-dialog.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client" - -import * as React from "react" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { History, Download, Upload } from "lucide-react" -import { formatDate, formatBytes } from "@/lib/utils" -import { getAttachmentRevisions } from "../service" -import { AddRevisionDialog } from "./add-revision-dialog" - -interface RevisionDialogProps { - attachmentId: number - currentRevision: string - originalFileName: string -} - -export function RevisionDialog({ - attachmentId, - currentRevision, - originalFileName -}: RevisionDialogProps) { - const [open, setOpen] = React.useState(false) - const [revisions, setRevisions] = React.useState<any[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false) - - // 리비전 목록 로드 - const loadRevisions = async () => { - setIsLoading(true) - try { - const result = await getAttachmentRevisions(attachmentId) - - if (result.success) { - setRevisions(result.revisions) - } else { - console.error("Failed to load revisions:", result.message) - } - } catch (error) { - console.error("Failed to load revisions:", error) - } finally { - setIsLoading(false) - } - } - - React.useEffect(() => { - if (open) { - loadRevisions() - } - }, [open, attachmentId]) - - return ( - <> - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button variant="ghost" size="sm" className="gap-2"> - <History className="h-4 w-4" /> - {currentRevision} - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[800px]" style={{maxWidth:800}}> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <History className="h-5 w-5" /> - 리비전 히스토리: {originalFileName} - </DialogTitle> - <DialogDescription> - 이 문서의 모든 버전을 확인하고 관리할 수 있습니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 새 리비전 추가 버튼 */} - <div className="flex justify-end"> - <Button - onClick={() => setIsAddRevisionOpen(true)} - className="gap-2" - > - <Upload className="h-4 w-4" /> - 새 리비전 추가 - </Button> - </div> - - {/* 리비전 목록 */} - {isLoading ? ( - <div className="text-center py-8">리비전을 불러오는 중...</div> - ) : ( - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead>리비전</TableHead> - <TableHead>파일명</TableHead> - <TableHead>크기</TableHead> - <TableHead>업로드 일시</TableHead> - <TableHead>업로드자</TableHead> - <TableHead>코멘트</TableHead> - <TableHead>액션</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {revisions.map((revision) => ( - <TableRow key={revision.id}> - <TableCell> - <div className="flex items-center gap-2"> - <Badge - variant={revision.isLatest ? "default" : "outline"} - > - {revision.revisionNo} - </Badge> - {revision.isLatest && ( - <Badge variant="secondary" className="text-xs"> - 최신 - </Badge> - )} - </div> - </TableCell> - - <TableCell> - <div> - <div className="font-medium">{revision.originalFileName}</div> - </div> - </TableCell> - - <TableCell> - {formatBytes(revision.fileSize)} - </TableCell> - - <TableCell> - {formatDate(revision.createdAt, "KR")} - </TableCell> - - <TableCell> - {revision.createdByName || "-"} - </TableCell> - - <TableCell> - <div className="max-w-[200px] truncate" title={revision.revisionComment}> - {revision.revisionComment || "-"} - </div> - </TableCell> - - <TableCell> - <Button - variant="ghost" - size="sm" - className="gap-2" - onClick={() => { - // 파일 다운로드 - window.open(revision.filePath, '_blank') - }} - > - <Download className="h-4 w-4" /> - 다운로드 - </Button> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - )} - </div> - </DialogContent> - </Dialog> - - {/* 새 리비전 추가 다이얼로그 */} - <AddRevisionDialog - open={isAddRevisionOpen} - onOpenChange={setIsAddRevisionOpen} - attachmentId={attachmentId} - currentRevision={currentRevision} - originalFileName={originalFileName} - onSuccess={() => { - loadRevisions() // 리비전 목록 새로고침 - }} - /> - </> - ) - }
\ No newline at end of file diff --git a/lib/b-rfq/attachment/tbe-request-dialog.tsx b/lib/b-rfq/attachment/tbe-request-dialog.tsx deleted file mode 100644 index 80b20e6f..00000000 --- a/lib/b-rfq/attachment/tbe-request-dialog.tsx +++ /dev/null @@ -1,200 +0,0 @@ -"use client" - -import * as React from "react" -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 { requestTbe } from "../service" - -// 첨부파일 타입 -type RfqAttachment = { - id: number - serialNo: string - originalFileName: string - attachmentType: string - currentRevision: string -} - -interface TbeRequestDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - attachments: RfqAttachment[] - showTrigger?: boolean - onSuccess?: () => void -} - -export function TbeRequestDialog({ - rfqId, - attachments, - showTrigger = true, - onSuccess, - ...props -}: TbeRequestDialogProps) { - const [isRequestPending, startRequestTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onRequest() { - startRequestTransition(async () => { - const attachmentIds = attachments.map(attachment => attachment.id) - const result = await requestTbe(rfqId, attachmentIds) - - if (!result.success) { - toast.error(result.message) - return - } - - props.onOpenChange?.(false) - toast.success(result.message) - onSuccess?.() - }) - } - - const attachmentCount = attachments.length - const attachmentText = attachmentCount === 1 ? "문서" : "문서들" - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={attachmentCount === 0} - > - <Send className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`} - </span> - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>TBE 요청을 전송하시겠습니까?</DialogTitle> - <DialogDescription className="space-y-2"> - <div> - 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해 - 벤더들에게 기술평가(TBE) 요청을 전송합니다. - </div> - <div>RFQ 상태가 "TBE started"로 변경됩니다.</div> - {attachmentCount <= 5 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">TBE 요청 대상:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Request TBE" - onClick={onRequest} - disabled={isRequestPending} - > - {isRequestPending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - TBE 요청 전송 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={attachmentCount === 0} - > - <Send className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`} - </span> - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>TBE 요청을 전송하시겠습니까?</DrawerTitle> - <DrawerDescription className="space-y-2"> - <div> - 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해 - 벤더들에게 기술평가(TBE) 요청을 전송합니다. - </div> - <div>RFQ 상태가 "TBE started"로 변경됩니다.</div> - {attachmentCount <= 5 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">TBE 요청 대상:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Request TBE" - onClick={onRequest} - disabled={isRequestPending} - > - {isRequestPending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - TBE 요청 전송 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/vendor-responses-panel.tsx b/lib/b-rfq/attachment/vendor-responses-panel.tsx deleted file mode 100644 index 0cbe2a08..00000000 --- a/lib/b-rfq/attachment/vendor-responses-panel.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - RefreshCw, - Download, - MessageSquare, - Clock, - CheckCircle2, - XCircle, - AlertCircle, - FileText, - Files, - AlertTriangle -} from "lucide-react" -import { formatDate, formatFileSize } from "@/lib/utils" -import { RequestRevisionDialog } from "./request-revision-dialog" - -interface VendorResponsesPanelProps { - attachment: any - responses: any[] - isLoading: boolean - onRefresh: () => void -} - -// 파일 다운로드 핸들러 -async function handleFileDownload(filePath: string, fileName: string, fileId: number) { - try { - const params = new URLSearchParams({ - path: filePath, - type: "vendor", - responseFileId: fileId.toString(), - }); - - const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Download failed: ${response.status}`); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - - console.log("✅ 파일 다운로드 성공:", fileName); - } catch (error) { - console.error("❌ 파일 다운로드 실패:", error); - alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); - } -} - -// 파일 목록 컴포넌트 -function FilesList({ files }: { files: any[] }) { - if (files.length === 0) { - return ( - <div className="text-center py-4 text-muted-foreground text-sm"> - 업로드된 파일이 없습니다. - </div> - ); - } - - return ( - <div className="space-y-2 max-h-64 overflow-y-auto"> - {files.map((file, index) => ( - <div key={file.id} className="flex items-center justify-between p-3 border rounded-lg bg-green-50 border-green-200"> - <div className="flex items-center gap-2 flex-1 min-w-0"> - <FileText className="h-4 w-4 text-green-600 flex-shrink-0" /> - <div className="min-w-0 flex-1"> - <div className="font-medium text-sm truncate" title={file.originalFileName}> - {file.originalFileName} - </div> - <div className="text-xs text-muted-foreground"> - {formatFileSize(file.fileSize)} • {formatDate(file.uploadedAt)} - </div> - {file.description && ( - <div className="text-xs text-muted-foreground italic mt-1" title={file.description}> - {file.description} - </div> - )} - </div> - </div> - <Button - size="sm" - variant="ghost" - onClick={() => handleFileDownload(file.filePath, file.originalFileName, file.id)} - className="flex-shrink-0 ml-2" - title="파일 다운로드" - > - <Download className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - ); -} - -export function VendorResponsesPanel({ - attachment, - responses, - isLoading, - onRefresh -}: VendorResponsesPanelProps) { - - console.log(responses) - - const getStatusIcon = (status: string) => { - switch (status) { - case 'RESPONDED': - return <CheckCircle2 className="h-4 w-4 text-green-600" /> - case 'NOT_RESPONDED': - return <Clock className="h-4 w-4 text-orange-600" /> - case 'WAIVED': - return <XCircle className="h-4 w-4 text-gray-500" /> - case 'REVISION_REQUESTED': - return <AlertCircle className="h-4 w-4 text-yellow-600" /> - default: - return <Clock className="h-4 w-4 text-gray-400" /> - } - } - - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case 'RESPONDED': - return 'default' - case 'NOT_RESPONDED': - return 'secondary' - case 'WAIVED': - return 'outline' - case 'REVISION_REQUESTED': - return 'destructive' - default: - return 'secondary' - } - } - - if (isLoading) { - return ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Skeleton className="h-6 w-48" /> - <Skeleton className="h-9 w-24" /> - </div> - <div className="space-y-3"> - {Array.from({ length: 3 }).map((_, i) => ( - <Skeleton key={i} className="h-12 w-full" /> - ))} - </div> - </div> - ) - } - - return ( - <div className="space-y-4"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div className="space-y-1"> - <h3 className="text-lg font-medium flex items-center gap-2"> - <MessageSquare className="h-5 w-5" /> - 벤더 응답 현황: {attachment.originalFileName} - </h3> - <div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> - <Badge variant="outline"> - {attachment.attachmentType} - </Badge> - <span>시리얼: {attachment.serialNo}</span> - <span>등록: {formatDate(attachment.createdAt)}</span> - {attachment.responseStats && ( - <Badge variant="secondary"> - 응답률: {attachment.responseStats.responseRate}% - </Badge> - )} - </div> - </div> - <Button - variant="outline" - size="sm" - onClick={onRefresh} - className="flex items-center gap-2" - > - <RefreshCw className="h-4 w-4" /> - 새로고침 - </Button> - </div> - - {/* 테이블 */} - {responses.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground border rounded-lg"> - 이 문서에 대한 벤더 응답 정보가 없습니다. - </div> - ) : ( - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead>벤더</TableHead> - <TableHead>국가</TableHead> - <TableHead>응답 상태</TableHead> - <TableHead>리비전</TableHead> - <TableHead>요청일</TableHead> - <TableHead>응답일</TableHead> - <TableHead>응답 파일</TableHead> - <TableHead>코멘트</TableHead> - <TableHead className="w-[100px]">액션</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {responses.map((response) => ( - <TableRow key={response.id}> - <TableCell className="font-medium"> - <div> - <div>{response.vendorName}</div> - <div className="text-xs text-muted-foreground"> - {response.vendorCode} - </div> - </div> - </TableCell> - - <TableCell> - {response.vendorCountry} - </TableCell> - - <TableCell> - <div className="flex items-center gap-2"> - {getStatusIcon(response.responseStatus)} - <Badge variant={getStatusBadgeVariant(response.responseStatus)}> - {response.responseStatus} - </Badge> - </div> - </TableCell> - - <TableCell> - <div className="text-sm"> - <div>현재: {response.currentRevision}</div> - {response.respondedRevision && ( - <div className="text-muted-foreground"> - 응답: {response.respondedRevision} - </div> - )} - </div> - </TableCell> - - <TableCell> - {formatDate(response.requestedAt)} - </TableCell> - - <TableCell> - {response.respondedAt ? formatDate(response.respondedAt) : '-'} - </TableCell> - - {/* 응답 파일 컬럼 */} - <TableCell> - {response.totalFiles > 0 ? ( - <div className="flex items-center gap-2"> - <Badge variant="secondary" className="text-xs"> - {response.totalFiles}개 - </Badge> - {response.totalFiles === 1 ? ( - // 파일이 1개면 바로 다운로드 - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - onClick={() => { - const file = response.files[0]; - handleFileDownload(file.filePath, file.originalFileName, file.id); - }} - title={response.latestFile?.originalFileName} - > - <Download className="h-4 w-4" /> - </Button> - ) : ( - // 파일이 여러 개면 Popover로 목록 표시 - <Popover> - <PopoverTrigger asChild> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - title="파일 목록 보기" - > - <Files className="h-4 w-4" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-96" align="start"> - <div className="space-y-2"> - <div className="font-medium text-sm"> - 응답 파일 목록 ({response.totalFiles}개) - </div> - <FilesList files={response.files} /> - </div> - </PopoverContent> - </Popover> - )} - </div> - ) : ( - <span className="text-muted-foreground text-sm">-</span> - )} - </TableCell> - - <TableCell> - <div className="space-y-1 max-w-[200px]"> - {/* 벤더 응답 코멘트 */} - {response.responseComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" title="벤더 응답 코멘트"></div> - <div className="text-xs text-blue-600 truncate" title={response.responseComment}> - {response.responseComment} - </div> - </div> - )} - - {/* 수정 요청 사유 */} - {response.revisionRequestComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="수정 요청 사유"></div> - <div className="text-xs text-red-600 truncate" title={response.revisionRequestComment}> - {response.revisionRequestComment} - </div> - </div> - )} - - {!response.responseComment && !response.revisionRequestComment && ( - <span className="text-muted-foreground text-sm">-</span> - )} - </div> - </TableCell> - - {/* 액션 컬럼 - 수정 요청 기능으로 변경 */} - <TableCell> - <div className="flex items-center gap-1"> - {response.responseStatus === 'RESPONDED' && ( - <RequestRevisionDialog - responseId={response.id} - attachmentType={attachment.attachmentType} - serialNo={attachment.serialNo} - vendorName={response.vendorName} - currentRevision={response.currentRevision} - onSuccess={onRefresh} - trigger={ - <Button - variant="outline" - size="sm" - className="h-8 px-2" - title="수정 요청" - > - <AlertTriangle className="h-3 w-3 mr-1" /> - 수정요청 - </Button> - } - /> - )} - - {response.responseStatus === 'REVISION_REQUESTED' && ( - <Badge variant="secondary" className="text-xs"> - 수정 요청됨 - </Badge> - )} - - {(response.responseStatus === 'NOT_RESPONDED' || response.responseStatus === 'WAIVED') && ( - <span className="text-muted-foreground text-xs">-</span> - )} - </div> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - )} - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/final/final-rfq-detail-columns.tsx b/lib/b-rfq/final/final-rfq-detail-columns.tsx deleted file mode 100644 index 88d62765..00000000 --- a/lib/b-rfq/final/final-rfq-detail-columns.tsx +++ /dev/null @@ -1,589 +0,0 @@ -// final-rfq-detail-columns.tsx -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { type Row } from "@tanstack/react-table" -import { - Ellipsis, Building, Eye, Edit, - MessageSquare, Settings, CheckCircle2, XCircle, DollarSign, Calendar -} from "lucide-react" - -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, - DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { FinalRfqDetailView } from "@/db/schema" - -// RowAction 타입 정의 -export interface DataTableRowAction<TData> { - row: Row<TData> - type: "update" -} - -interface GetFinalRfqDetailColumnsProps { - onSelectDetail?: (detail: any) => void - setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<FinalRfqDetailView> | null>> -} - -export function getFinalRfqDetailColumns({ - onSelectDetail, - setRowAction -}: GetFinalRfqDetailColumnsProps = {}): ColumnDef<FinalRfqDetailView>[] { - - return [ - /** ───────────── 체크박스 ───────────── */ - { - 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, - }, - - /** 1. RFQ Status */ - { - accessorKey: "finalRfqStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최종 RFQ Status" /> - ), - cell: ({ row }) => { - const status = row.getValue("finalRfqStatus") as string - const getFinalStatusColor = (status: string) => { - switch (status) { - case "DRAFT": return "outline" - case "Final RFQ Sent": return "default" - case "Quotation Received": return "success" - case "Vendor Selected": return "default" - default: return "secondary" - } - } - return ( - <Badge variant={getFinalStatusColor(status) as any}> - {status} - </Badge> - ) - }, - size: 120 - }, - - /** 2. RFQ No. */ - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ No." /> - ), - cell: ({ row }) => ( - <div className="text-sm font-medium"> - {row.getValue("rfqCode") as string} - </div> - ), - size: 120, - }, - - /** 3. Rev. */ - { - accessorKey: "returnRevision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Rev." /> - ), - cell: ({ row }) => { - const revision = row.getValue("returnRevision") as number - return revision > 0 ? ( - <Badge variant="outline"> - Rev. {revision} - </Badge> - ) : ( - <Badge variant="outline"> - Rev. 0 - </Badge> - ) - }, - size: 80, - }, - - /** 4. Vendor Code */ - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> - ), - cell: ({ row }) => ( - <div className="text-sm font-medium"> - {row.original.vendorCode} - </div> - ), - size: 100, - }, - - /** 5. Vendor Name */ - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> - ), - cell: ({ row }) => ( - <div className="text-sm font-medium"> - {row.original.vendorName} - </div> - ), - size: 150, - }, - - /** 6. 업체분류 */ - { - id: "vendorClassification", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="업체분류" /> - ), - cell: ({ row }) => { - const vendorCode = row.original.vendorCode as string - return vendorCode ? ( - <Badge variant="success" className="text-xs"> - 정규업체 - </Badge> - ) : ( - <Badge variant="secondary" className="text-xs"> - 잠재업체 - </Badge> - ) - }, - size: 100, - }, - - /** 7. CP 현황 */ - { - accessorKey: "cpRequestYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="CP 현황" /> - ), - cell: ({ row }) => { - const cpRequest = row.getValue("cpRequestYn") as boolean - return cpRequest ? ( - <Badge variant="success" className="text-xs"> - 신청 - </Badge> - ) : ( - <Badge variant="outline" className="text-xs"> - 미신청 - </Badge> - ) - }, - size: 80, - }, - - /** 8. GTC현황 */ - { - id: "gtcStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="GTC현황" /> - ), - cell: ({ row }) => { - const gtc = row.original.gtc as string - const gtcValidDate = row.original.gtcValidDate as string - const prjectGtcYn = row.original.prjectGtcYn as boolean - - if (prjectGtcYn || gtc) { - return ( - <div className="space-y-1"> - <Badge variant="success" className="text-xs"> - 보유 - </Badge> - {gtcValidDate && ( - <div className="text-xs text-muted-foreground"> - {gtcValidDate} - </div> - )} - </div> - ) - } - return ( - <Badge variant="outline" className="text-xs"> - 미보유 - </Badge> - ) - }, - size: 100, - }, - - /** 9. TBE 결과 (스키마에 없어서 placeholder) */ - { - id: "tbeResult", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="TBE 결과" /> - ), - cell: ({ row }) => { - // TODO: TBE 결과 로직 구현 필요 - return ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 80, - }, - - /** 10. 최종 선정 */ - { - id: "finalSelection", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최종 선정" /> - ), - cell: ({ row }) => { - const status = row.original.finalRfqStatus as string - return status === "Vendor Selected" ? ( - <Badge variant="success" className="text-xs"> - <CheckCircle2 className="h-3 w-3 mr-1" /> - 선정 - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 80, - }, - - /** 11. Currency */ - { - accessorKey: "currency", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Currency" /> - ), - cell: ({ row }) => { - const currency = row.getValue("currency") as string - return currency ? ( - <Badge variant="outline" className="text-xs"> - {/* <DollarSign className="h-3 w-3 mr-1" /> */} - {currency} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 80, - }, - - /** 12. Terms of Payment */ - { - accessorKey: "paymentTermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Terms of Payment" /> - ), - cell: ({ row }) => { - const paymentTermsCode = row.getValue("paymentTermsCode") as string - return paymentTermsCode ? ( - <Badge variant="secondary" className="text-xs"> - {paymentTermsCode} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** 13. Payment Desc. */ - { - accessorKey: "paymentTermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Payment Desc." /> - ), - cell: ({ row }) => { - const description = row.getValue("paymentTermsDescription") as string - return description ? ( - <div className="text-xs max-w-[150px] truncate" title={description}> - {description} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 150, - }, - - /** 14. TAX */ - { - accessorKey: "taxCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="TAX" /> - ), - cell: ({ row }) => { - const taxCode = row.getValue("taxCode") as string - return taxCode ? ( - <Badge variant="outline" className="text-xs"> - {taxCode} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 80, - }, - - /** 15. Delivery Date* */ - { - accessorKey: "deliveryDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Delivery Date*" /> - ), - cell: ({ row }) => { - const deliveryDate = row.getValue("deliveryDate") as Date - return deliveryDate ? ( - <div className="text-sm"> - {formatDate(deliveryDate, "KR")} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** 16. Country */ - { - accessorKey: "vendorCountry", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Country" /> - ), - cell: ({ row }) => { - const country = row.getValue("vendorCountry") as string - const countryDisplay = country === "KR" ? "D" : "F" - return ( - <Badge variant="outline" className="text-xs"> - {countryDisplay} - </Badge> - ) - }, - size: 80, - }, - - /** 17. Place of Shipping */ - { - accessorKey: "placeOfShipping", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Place of Shipping" /> - ), - cell: ({ row }) => { - const placeOfShipping = row.getValue("placeOfShipping") as string - return placeOfShipping ? ( - <div className="text-xs max-w-[120px] truncate" title={placeOfShipping}> - {placeOfShipping} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** 18. Place of Destination */ - { - accessorKey: "placeOfDestination", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Place of Destination" /> - ), - cell: ({ row }) => { - const placeOfDestination = row.getValue("placeOfDestination") as string - return placeOfDestination ? ( - <div className="text-xs max-w-[120px] truncate" title={placeOfDestination}> - {placeOfDestination} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** 19. 초도 여부* */ - { - accessorKey: "firsttimeYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="초도 여부*" /> - ), - cell: ({ row }) => { - const firsttime = row.getValue("firsttimeYn") as boolean - return firsttime ? ( - <Badge variant="success" className="text-xs"> - 초도 - </Badge> - ) : ( - <Badge variant="outline" className="text-xs"> - 재구매 - </Badge> - ) - }, - size: 80, - }, - - /** 20. 연동제 적용* */ - { - accessorKey: "materialPriceRelatedYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="연동제 적용*" /> - ), - cell: ({ row }) => { - const materialPrice = row.getValue("materialPriceRelatedYn") as boolean - return materialPrice ? ( - <Badge variant="success" className="text-xs"> - 적용 - </Badge> - ) : ( - <Badge variant="outline" className="text-xs"> - 미적용 - </Badge> - ) - }, - size: 100, - }, - - /** 21. Business Size */ - { - id: "businessSizeDisplay", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Business Size" /> - ), - cell: ({ row }) => { - const businessSize = row.original.vendorBusinessSize as string - return businessSize ? ( - <Badge variant="outline" className="text-xs"> - {businessSize} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 100, - }, - - /** 22. 최종 Update일 */ - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최종 Update일" /> - ), - cell: ({ row }) => { - const updated = row.getValue("updatedAt") as Date - return updated ? ( - <div className="text-sm"> - {formatDate(updated, "KR")} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** 23. 최종 Update담당자 (스키마에 없어서 placeholder) */ - { - id: "updatedByUser", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최종 Update담당자" /> - ), - cell: ({ row }) => { - // TODO: updatedBy 사용자 정보 조인 필요 - return ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 120, - }, - - /** 24. Vendor 설명 */ - { - accessorKey: "vendorRemark", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Vendor 설명" /> - ), - cell: ({ row }) => { - const vendorRemark = row.getValue("vendorRemark") as string - return vendorRemark ? ( - <div className="text-xs max-w-[150px] truncate" title={vendorRemark}> - {vendorRemark} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 150, - }, - - /** 25. 비고 */ - { - accessorKey: "remark", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="비고" /> - ), - cell: ({ row }) => { - const remark = row.getValue("remark") as string - return remark ? ( - <div className="text-xs max-w-[150px] truncate" title={remark}> - {remark} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 150, - }, - - /** ───────────── 액션 ───────────── */ - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - 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-48"> - <DropdownMenuItem> - <MessageSquare className="mr-2 h-4 w-4" /> - 벤더 견적 보기 - </DropdownMenuItem> - <DropdownMenuSeparator /> - {setRowAction && ( - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - <Edit className="mr-2 h-4 w-4" /> - 수정 - </DropdownMenuItem> - )} - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - }, - ] -}
\ No newline at end of file diff --git a/lib/b-rfq/final/final-rfq-detail-table.tsx b/lib/b-rfq/final/final-rfq-detail-table.tsx deleted file mode 100644 index 8ae42e7e..00000000 --- a/lib/b-rfq/final/final-rfq-detail-table.tsx +++ /dev/null @@ -1,297 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { getFinalRfqDetail } from "../service" // 앞서 만든 서버 액션 -import { - getFinalRfqDetailColumns, - type DataTableRowAction -} from "./final-rfq-detail-columns" -import { FinalRfqDetailTableToolbarActions } from "./final-rfq-detail-toolbar-actions" -import { UpdateFinalRfqSheet } from "./update-final-rfq-sheet" -import { FinalRfqDetailView } from "@/db/schema" - -interface FinalRfqDetailTableProps { - promises: Promise<Awaited<ReturnType<typeof getFinalRfqDetail>>> - rfqId?: number -} - -export function FinalRfqDetailTable({ promises, rfqId }: FinalRfqDetailTableProps) { - const { data, pageCount } = React.use(promises) - - // 선택된 상세 정보 - const [selectedDetail, setSelectedDetail] = React.useState<any>(null) - - // Row action 상태 (update만) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<FinalRfqDetailView> | null>(null) - - const columns = React.useMemo( - () => getFinalRfqDetailColumns({ - onSelectDetail: setSelectedDetail, - setRowAction: setRowAction - }), - [] - ) - - /** - * 필터 필드 정의 - */ - const filterFields: DataTableFilterField<any>[] = [ - { - id: "rfqCode", - label: "RFQ 코드", - placeholder: "RFQ 코드로 검색...", - }, - { - id: "vendorName", - label: "벤더명", - placeholder: "벤더명으로 검색...", - }, - { - id: "rfqStatus", - label: "RFQ 상태", - options: [ - { label: "Draft", value: "DRAFT", count: 0 }, - { label: "문서 접수", value: "Doc. Received", count: 0 }, - { label: "담당자 배정", value: "PIC Assigned", count: 0 }, - { label: "문서 확정", value: "Doc. Confirmed", count: 0 }, - { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 }, - { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 }, - { label: "TBE 시작", value: "TBE started", count: 0 }, - { label: "TBE 완료", value: "TBE finished", count: 0 }, - { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 }, - { label: "견적 접수", value: "Quotation Received", count: 0 }, - { label: "벤더 선정", value: "Vendor Selected", count: 0 }, - ], - }, - { - id: "finalRfqStatus", - label: "최종 RFQ 상태", - options: [ - { label: "초안", value: "DRAFT", count: 0 }, - { label: "발송", value: "Final RFQ Sent", count: 0 }, - { label: "견적 접수", value: "Quotation Received", count: 0 }, - { label: "벤더 선정", value: "Vendor Selected", count: 0 }, - ], - }, - { - id: "vendorCountry", - label: "벤더 국가", - options: [ - { label: "한국", value: "KR", count: 0 }, - { label: "중국", value: "CN", count: 0 }, - { label: "일본", value: "JP", count: 0 }, - { label: "미국", value: "US", count: 0 }, - { label: "독일", value: "DE", count: 0 }, - ], - }, - { - id: "currency", - label: "통화", - options: [ - { label: "USD", value: "USD", count: 0 }, - { label: "EUR", value: "EUR", count: 0 }, - { label: "KRW", value: "KRW", count: 0 }, - { label: "JPY", value: "JPY", count: 0 }, - { label: "CNY", value: "CNY", count: 0 }, - ], - }, - ] - - /** - * 고급 필터 필드 - */ - const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { - id: "rfqCode", - label: "RFQ 코드", - type: "text", - }, - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "vendorCountry", - label: "벤더 국가", - type: "multi-select", - options: [ - { label: "한국", value: "KR" }, - { label: "중국", value: "CN" }, - { label: "일본", value: "JP" }, - { label: "미국", value: "US" }, - { label: "독일", value: "DE" }, - ], - }, - { - id: "rfqStatus", - label: "RFQ 상태", - type: "multi-select", - options: [ - { label: "Draft", value: "DRAFT" }, - { label: "문서 접수", value: "Doc. Received" }, - { label: "담당자 배정", value: "PIC Assigned" }, - { label: "문서 확정", value: "Doc. Confirmed" }, - { label: "초기 RFQ 발송", value: "Init. RFQ Sent" }, - { label: "초기 RFQ 응답", value: "Init. RFQ Answered" }, - { label: "TBE 시작", value: "TBE started" }, - { label: "TBE 완료", value: "TBE finished" }, - { label: "최종 RFQ 발송", value: "Final RFQ Sent" }, - { label: "견적 접수", value: "Quotation Received" }, - { label: "벤더 선정", value: "Vendor Selected" }, - ], - }, - { - id: "finalRfqStatus", - label: "최종 RFQ 상태", - type: "multi-select", - options: [ - { label: "초안", value: "DRAFT" }, - { label: "발송", value: "Final RFQ Sent" }, - { label: "견적 접수", value: "Quotation Received" }, - { label: "벤더 선정", value: "Vendor Selected" }, - ], - }, - { - id: "vendorBusinessSize", - label: "벤더 규모", - type: "multi-select", - options: [ - { label: "대기업", value: "LARGE" }, - { label: "중기업", value: "MEDIUM" }, - { label: "소기업", value: "SMALL" }, - { label: "스타트업", value: "STARTUP" }, - ], - }, - { - id: "incotermsCode", - label: "Incoterms", - type: "text", - }, - { - id: "paymentTermsCode", - label: "Payment Terms", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "multi-select", - options: [ - { label: "USD", value: "USD" }, - { label: "EUR", value: "EUR" }, - { label: "KRW", value: "KRW" }, - { label: "JPY", value: "JPY" }, - { label: "CNY", value: "CNY" }, - ], - }, - { - id: "dueDate", - label: "마감일", - type: "date", - }, - { - id: "validDate", - label: "유효일", - type: "date", - }, - { - id: "deliveryDate", - label: "납기일", - type: "date", - }, - { - id: "shortList", - label: "Short List", - type: "boolean", - }, - { - id: "returnYn", - label: "Return 여부", - type: "boolean", - }, - { - id: "cpRequestYn", - label: "CP Request 여부", - type: "boolean", - }, - { - id: "prjectGtcYn", - label: "Project GTC 여부", - type: "boolean", - }, - { - id: "firsttimeYn", - label: "First Time 여부", - type: "boolean", - }, - { - id: "materialPriceRelatedYn", - label: "Material Price Related 여부", - type: "boolean", - }, - { - id: "classification", - label: "분류", - type: "text", - }, - { - id: "sparepart", - label: "예비부품", - type: "text", - }, - { - id: "createdAt", - label: "등록일", - type: "date", - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => originalRow.finalRfqId ? originalRow.finalRfqId.toString() : "1", - shallow: false, - clearOnDefault: true, - }) - - return ( - <div className="space-y-6"> - {/* 메인 테이블 */} - <div className="h-full w-full"> - <DataTable table={table} className="h-full"> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <FinalRfqDetailTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - </div> - - {/* Update Sheet */} - <UpdateFinalRfqSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - finalRfq={rowAction?.type === "update" ? rowAction.row.original : null} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx deleted file mode 100644 index d8be4f7b..00000000 --- a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { useRouter } from "next/navigation" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { - Mail, - CheckCircle2, - Loader, - Award, - RefreshCw -} from "lucide-react" -import { FinalRfqDetailView } from "@/db/schema" - -interface FinalRfqDetailTableToolbarActionsProps { - table: Table<FinalRfqDetailView> - rfqId?: number - onRefresh?: () => void // 데이터 새로고침 콜백 -} - -export function FinalRfqDetailTableToolbarActions({ - table, - rfqId, - onRefresh -}: FinalRfqDetailTableToolbarActionsProps) { - const router = useRouter() - - // 선택된 행들 가져오기 - const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedDetails = selectedRows.map((row) => row.original) - const selectedCount = selectedRows.length - - // 상태 관리 - const [isEmailSending, setIsEmailSending] = React.useState(false) - const [isSelecting, setIsSelecting] = React.useState(false) - - // RFQ 발송 핸들러 (로직 없음) - const handleBulkRfqSend = async () => { - if (selectedCount === 0) { - toast.error("발송할 RFQ를 선택해주세요.") - return - } - - setIsEmailSending(true) - - try { - // TODO: 실제 RFQ 발송 로직 구현 - await new Promise(resolve => setTimeout(resolve, 2000)) // 임시 딜레이 - - toast.success(`${selectedCount}개의 최종 RFQ가 발송되었습니다.`) - - // 선택 해제 - table.toggleAllRowsSelected(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } - - } catch (error) { - console.error("RFQ sending error:", error) - toast.error("최종 RFQ 발송 중 오류가 발생했습니다.") - } finally { - setIsEmailSending(false) - } - } - - // 최종 선정 핸들러 (로직 없음) - const handleFinalSelection = async () => { - if (selectedCount === 0) { - toast.error("최종 선정할 벤더를 선택해주세요.") - return - } - - if (selectedCount > 1) { - toast.error("최종 선정은 1개의 벤더만 가능합니다.") - return - } - - setIsSelecting(true) - - try { - // TODO: 실제 최종 선정 로직 구현 - await new Promise(resolve => setTimeout(resolve, 1500)) // 임시 딜레이 - - const selectedVendor = selectedDetails[0] - toast.success(`${selectedVendor.vendorName}이(가) 최종 선정되었습니다.`) - - // 선택 해제 - table.toggleAllRowsSelected(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } - - // 계약서 페이지로 이동 (필요시) - if (rfqId) { - setTimeout(() => { - toast.info("계약서 작성 페이지로 이동합니다.") - // router.push(`/evcp/contracts/${rfqId}`) - }, 1500) - } - - } catch (error) { - console.error("Final selection error:", error) - toast.error("최종 선정 중 오류가 발생했습니다.") - } finally { - setIsSelecting(false) - } - } - - // 발송 가능한 RFQ 필터링 (DRAFT 상태) - const sendableRfqs = selectedDetails.filter( - detail => detail.finalRfqStatus === "DRAFT" - ) - const sendableCount = sendableRfqs.length - - // 선정 가능한 벤더 필터링 (견적 접수 상태) - const selectableVendors = selectedDetails.filter( - detail => detail.finalRfqStatus === "Quotation Received" - ) - const selectableCount = selectableVendors.length - - // 전체 벤더 중 견적 접수 완료된 벤더 수 - const allVendors = table.getRowModel().rows.map(row => row.original) - const quotationReceivedCount = allVendors.filter( - vendor => vendor.finalRfqStatus === "Quotation Received" - ).length - - return ( - <div className="flex items-center gap-2"> - {/** 선택된 항목이 있을 때만 표시되는 액션들 */} - {selectedCount > 0 && ( - <> - {/* RFQ 발송 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleBulkRfqSend} - className="h-8" - disabled={isEmailSending || sendableCount === 0} - title={sendableCount === 0 ? "발송 가능한 RFQ가 없습니다 (DRAFT 상태만 가능)" : `${sendableCount}개의 최종 RFQ 발송`} - > - {isEmailSending ? ( - <Loader className="mr-2 h-4 w-4 animate-spin" /> - ) : ( - <Mail className="mr-2 h-4 w-4" /> - )} - 최종 RFQ 발송 ({sendableCount}/{selectedCount}) - </Button> - - {/* 최종 선정 버튼 */} - <Button - variant="default" - size="sm" - onClick={handleFinalSelection} - className="h-8" - disabled={isSelecting || selectedCount !== 1 || selectableCount === 0} - title={ - selectedCount !== 1 - ? "최종 선정은 1개의 벤더만 선택해주세요" - : selectableCount === 0 - ? "견적 접수가 완료된 벤더만 선정 가능합니다" - : "선택된 벤더를 최종 선정" - } - > - {isSelecting ? ( - <Loader className="mr-2 h-4 w-4 animate-spin" /> - ) : ( - <Award className="mr-2 h-4 w-4" /> - )} - 최종 선정 - </Button> - </> - )} - - {/* 정보 표시 (선택이 없을 때) */} - {selectedCount === 0 && quotationReceivedCount > 0 && ( - <div className="text-sm text-muted-foreground"> - 견적 접수 완료: {quotationReceivedCount}개 벤더 - </div> - )} - - {/* 새로고침 버튼 */} - {onRefresh && ( - <Button - variant="ghost" - size="sm" - onClick={onRefresh} - className="h-8" - title="데이터 새로고침" - > - <RefreshCw className="h-4 w-4" /> - </Button> - )} - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/final/update-final-rfq-sheet.tsx b/lib/b-rfq/final/update-final-rfq-sheet.tsx deleted file mode 100644 index 65e23a92..00000000 --- a/lib/b-rfq/final/update-final-rfq-sheet.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client" - -import * as React from "react" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { FinalRfqDetailView } from "@/db/schema" - -interface UpdateFinalRfqSheetProps { - open: boolean - onOpenChange: (open: boolean) => void - finalRfq: FinalRfqDetailView | null -} - -export function UpdateFinalRfqSheet({ - open, - onOpenChange, - finalRfq -}: UpdateFinalRfqSheetProps) { - return ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="sm:max-w-md"> - <SheetHeader> - <SheetTitle>최종 RFQ 수정</SheetTitle> - <SheetDescription> - 최종 RFQ 정보를 수정합니다. - </SheetDescription> - </SheetHeader> - - <div className="py-6"> - {finalRfq && ( - <div className="space-y-4"> - <div> - <h4 className="font-medium">RFQ 정보</h4> - <p className="text-sm text-muted-foreground"> - RFQ Code: {finalRfq.rfqCode} - </p> - <p className="text-sm text-muted-foreground"> - 벤더: {finalRfq.vendorName} - </p> - <p className="text-sm text-muted-foreground"> - 상태: {finalRfq.finalRfqStatus} - </p> - </div> - - {/* TODO: 실제 업데이트 폼 구현 */} - <div className="text-center text-muted-foreground"> - 업데이트 폼이 여기에 구현됩니다. - </div> - </div> - )} - </div> - - <div className="flex justify-end gap-2"> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={() => onOpenChange(false)}> - 저장 - </Button> - </div> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx deleted file mode 100644 index 58a091ac..00000000 --- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx +++ /dev/null @@ -1,584 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } from "lucide-react" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Checkbox } from "@/components/ui/checkbox" -import { cn, formatDate } from "@/lib/utils" -import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service" -import { Calendar } from "@/components/ui/calendar" -import { InitialRfqDetailView } from "@/db/schema" - -// Initial RFQ 추가 폼 스키마 -const addInitialRfqSchema = z.object({ - vendorId: z.number({ - required_error: "벤더를 선택해주세요.", - }), - initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], { - required_error: "초기 RFQ 상태를 선택해주세요.", - }).default("DRAFT"), - dueDate: z.date({ - required_error: "마감일을 선택해주세요.", - }), - validDate: z.date().optional(), - incotermsCode: z.string().optional(), - gtc: z.string().optional(), - gtcValidDate: z.string().optional(), - classification: z.string().optional(), - sparepart: z.string().optional(), - shortList: z.boolean().default(false), - returnYn: z.boolean().default(false), - cpRequestYn: z.boolean().default(false), - prjectGtcYn: z.boolean().default(false), - returnRevision: z.number().default(0), -}) - -export type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema> - -interface Vendor { - id: number - vendorName: string - vendorCode: string - country: string - taxId: string - status: string -} - -interface Incoterm { - id: number - code: string - description: string -} - -interface AddInitialRfqDialogProps { - rfqId: number - onSuccess?: () => void - defaultValues?: InitialRfqDetailView // 선택된 항목의 기본값 -} - -export function AddInitialRfqDialog({ rfqId, onSuccess, defaultValues }: AddInitialRfqDialogProps) { - const [open, setOpen] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [vendorsLoading, setVendorsLoading] = React.useState(false) - const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) - const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) - const [incotermsLoading, setIncotermsLoading] = React.useState(false) - const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) - - // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값) - const getDefaultFormValues = React.useCallback((): Partial<AddInitialRfqFormData> => { - if (defaultValues) { - return { - vendorId: defaultValues.vendorId, - initialRfqStatus: "DRAFT", // 새로 추가할 때는 항상 DRAFT로 시작 - dueDate: defaultValues.dueDate || new Date(), - validDate: defaultValues.validDate, - incotermsCode: defaultValues.incotermsCode || "", - classification: defaultValues.classification || "", - sparepart: defaultValues.sparepart || "", - shortList: false, // 새로 추가할 때는 기본적으로 false - returnYn: false, - cpRequestYn: defaultValues.cpRequestYn || false, - prjectGtcYn: defaultValues.prjectGtcYn || false, - returnRevision: 0, - } - } - - return { - initialRfqStatus: "DRAFT", - shortList: false, - returnYn: false, - cpRequestYn: false, - prjectGtcYn: false, - returnRevision: 0, - } - }, [defaultValues]) - - const form = useForm<AddInitialRfqFormData>({ - resolver: zodResolver(addInitialRfqSchema), - defaultValues: getDefaultFormValues(), - }) - - // 벤더 목록 로드 - const loadVendors = React.useCallback(async () => { - setVendorsLoading(true) - try { - const vendorList = await getVendorsForSelection() - setVendors(vendorList) - } catch (error) { - console.error("Failed to load vendors:", error) - toast.error("벤더 목록을 불러오는데 실패했습니다.") - } finally { - setVendorsLoading(false) - } - }, []) - - // Incoterms 목록 로드 - const loadIncoterms = React.useCallback(async () => { - setIncotermsLoading(true) - try { - const incotermsList = await getIncotermsForSelection() - setIncoterms(incotermsList) - } catch (error) { - console.error("Failed to load incoterms:", error) - toast.error("Incoterms 목록을 불러오는데 실패했습니다.") - } finally { - setIncotermsLoading(false) - } - }, []) - - // 다이얼로그 열릴 때 실행 - React.useEffect(() => { - if (open) { - // 폼을 기본값으로 리셋 - form.reset(getDefaultFormValues()) - - // 데이터 로드 - if (vendors.length === 0) { - loadVendors() - } - if (incoterms.length === 0) { - loadIncoterms() - } - } - }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues]) - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && !isSubmitting) { - form.reset(getDefaultFormValues()) - } - setOpen(newOpen) - } - - // 폼 제출 - const onSubmit = async (data: AddInitialRfqFormData) => { - setIsSubmitting(true) - - try { - const result = await addInitialRfqRecord({ - ...data, - rfqId, - }) - - if (result.success) { - toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.") - form.reset(getDefaultFormValues()) - handleOpenChange(false) - onSuccess?.() - } else { - toast.error(result.message || "초기 RFQ 추가에 실패했습니다.") - } - - } catch (error) { - console.error("Submit error:", error) - toast.error("초기 RFQ 추가 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - // 선택된 벤더 정보 - const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId")) - const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) - - // 기본값이 있을 때 버튼 텍스트 변경 - const buttonText = defaultValues ? "유사 항목 추가" : "초기 RFQ 추가" - const dialogTitle = defaultValues ? "유사 초기 RFQ 추가" : "초기 RFQ 추가" - const dialogDescription = defaultValues - ? "선택된 항목을 기본값으로 하여 새로운 초기 RFQ를 추가합니다." - : "새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다." - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">{buttonText}</span> - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>{dialogTitle}</DialogTitle> - <DialogDescription> - {dialogDescription} - {defaultValues && ( - <div className="mt-2 p-2 bg-muted rounded-md text-sm"> - <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode}) - </div> - )} - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 벤더 선택 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>벤더 선택 *</FormLabel> - <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorSearchOpen} - className="justify-between" - disabled={vendorsLoading} - > - {selectedVendor ? ( - <div className="flex items-center gap-2"> - <Building className="h-4 w-4" /> - <span className="truncate"> - {selectedVendor.vendorName} ({selectedVendor.vendorCode}) - </span> - </div> - ) : ( - <span className="text-muted-foreground"> - {vendorsLoading ? "로딩 중..." : "벤더를 선택하세요"} - </span> - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0" align="start"> - <Command> - <CommandInput - placeholder="벤더명 또는 코드로 검색..." - className="h-9" - /> - <CommandList> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - field.onChange(vendor.id) - setVendorSearchOpen(false) - }} - > - <div className="flex items-center gap-2 w-full"> - <Building className="h-4 w-4" /> - <div className="flex-1 min-w-0"> - <div className="font-medium truncate"> - {vendor.vendorName} - </div> - <div className="text-sm text-muted-foreground"> - {vendor.vendorCode} • {vendor.country} • {vendor.taxId} - </div> - </div> - <Check - className={cn( - "ml-auto h-4 w-4", - vendor.id === field.value ? "opacity-100" : "opacity-0" - )} - /> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 날짜 필드들 */} - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>견적 마감일 *</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - formatDate(field.value, "KR") - ) : ( - <span>견적 마감일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="validDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>견적 유효일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - formatDate(field.value, "KR") - ) : ( - <span>견적 유효일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* Incoterms 선택 */} - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>Incoterms</FormLabel> - <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={incotermsSearchOpen} - className="justify-between" - disabled={incotermsLoading} - > - {selectedIncoterm ? ( - <div className="flex items-center gap-2"> - <span className="truncate"> - {selectedIncoterm.code} - {selectedIncoterm.description} - </span> - </div> - ) : ( - <span className="text-muted-foreground"> - {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"} - </span> - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0" align="start"> - <Command> - <CommandInput - placeholder="코드 또는 내용으로 검색..." - className="h-9" - /> - <CommandList> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {incoterms.map((incoterm) => ( - <CommandItem - key={incoterm.id} - value={`${incoterm.code} ${incoterm.description}`} - onSelect={() => { - field.onChange(incoterm.code) - setIncotermsSearchOpen(false) - }} - > - <div className="flex items-center gap-2 w-full"> - <div className="flex-1 min-w-0"> - <div className="font-medium truncate"> - {incoterm.code} - {incoterm.description} - </div> - </div> - <Check - className={cn( - "ml-auto h-4 w-4", - incoterm.code === field.value ? "opacity-100" : "opacity-0" - )} - /> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 옵션 체크박스 */} - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="cpRequestYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none ml-2"> - <FormLabel>CP 요청</FormLabel> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="prjectGtcYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none ml-2"> - <FormLabel>Project용 GTC 사용</FormLabel> - </div> - </FormItem> - )} - /> - </div> - - {/* 분류 정보 */} - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="classification" - render={({ field }) => ( - <FormItem> - <FormLabel>선급</FormLabel> - <FormControl> - <Input placeholder="선급" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="sparepart" - render={({ field }) => ( - <FormItem> - <FormLabel>Spare part</FormLabel> - <FormControl> - <Input placeholder="O1, O2" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? "추가 중..." : "추가"} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx deleted file mode 100644 index b5a231b7..00000000 --- a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"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 { InitialRfqDetailView } from "@/db/schema" -import { removeInitialRfqs } from "../service" - -interface DeleteInitialRfqDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - initialRfqs: Row<InitialRfqDetailView>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteInitialRfqDialog({ - initialRfqs, - showTrigger = true, - onSuccess, - ...props -}: DeleteInitialRfqDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startDeleteTransition(async () => { - const { error } = await removeInitialRfqs({ - ids: initialRfqs.map((rfq) => rfq.id), - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - toast.success("초기 RFQ가 삭제되었습니다") - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({initialRfqs.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - <span className="font-medium">{initialRfqs.length}개</span>의 - 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</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" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({initialRfqs.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - <span className="font-medium">{initialRfqs.length}개</span>의 - 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</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" /> - )} - 삭제 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx deleted file mode 100644 index 2d9c3a68..00000000 --- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx +++ /dev/null @@ -1,446 +0,0 @@ -// initial-rfq-detail-columns.tsx -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { type Row } from "@tanstack/react-table" -import { - Ellipsis, Building, Eye, Edit, Trash, - MessageSquare, Settings, CheckCircle2, XCircle -} from "lucide-react" - -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, - DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { InitialRfqDetailView } from "@/db/schema" - - -// RowAction 타입 정의 -export interface DataTableRowAction<TData> { - row: Row<TData> - type: "update" | "delete" -} - -interface GetInitialRfqDetailColumnsProps { - onSelectDetail?: (detail: any) => void - setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<InitialRfqDetailView> | null>> -} - -export function getInitialRfqDetailColumns({ - onSelectDetail, - setRowAction -}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] { - - return [ - /** ───────────── 체크박스 ───────────── */ - { - 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, - }, - - /** ───────────── RFQ 정보 ───────────── */ - { - accessorKey: "initialRfqStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 상태" /> - ), - cell: ({ row }) => { - const status = row.getValue("initialRfqStatus") as string - const getInitialStatusColor = (status: string) => { - switch (status) { - case "DRAFT": return "outline" - case "Init. RFQ Sent": return "default" - case "Init. RFQ Answered": return "success" - case "S/L Decline": return "destructive" - default: return "secondary" - } - } - return ( - <Badge variant={getInitialStatusColor(status) as any}> - {status} - </Badge> - ) - }, - size: 120 - }, - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ No." /> - ), - cell: ({ row }) => ( - <div className="text-sm"> - {row.getValue("rfqCode") as string} - </div> - ), - size: 120, - }, - { - accessorKey: "rfqRevision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 리비전" /> - ), - cell: ({ row }) => ( - <div className="text-sm"> - Rev. {row.getValue("rfqRevision") as number} - </div> - ), - size: 120, - }, - - /** ───────────── 벤더 정보 ───────────── */ - { - id: "vendorInfo", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 정보" /> - ), - cell: ({ row }) => { - const vendorName = row.original.vendorName as string - const vendorCode = row.original.vendorCode as string - const vendorType = row.original.vendorCategory as string - const vendorCountry = row.original.vendorCountry === "KR" ? "D":"F" - const businessSize = row.original.vendorBusinessSize as string - - return ( - <div className="space-y-1"> - <div className="flex items-center gap-2"> - <Building className="h-4 w-4 text-muted-foreground" /> - <div className="font-medium">{vendorName}</div> - </div> - <div className="text-sm text-muted-foreground"> - {vendorCode} • {vendorType} • {vendorCountry} - </div> - {businessSize && ( - <Badge variant="outline" className="text-xs"> - {businessSize} - </Badge> - )} - </div> - ) - }, - size: 200, - }, - - { - accessorKey: "cpRequestYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="CP" /> - ), - cell: ({ row }) => { - const cpRequest = row.getValue("cpRequestYn") as boolean - return cpRequest ? ( - <Badge variant="outline" className="text-xs"> - Yes - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 60, - }, - { - accessorKey: "prjectGtcYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Project GTC" /> - ), - cell: ({ row }) => { - const projectGtc = row.getValue("prjectGtcYn") as boolean - return projectGtc ? ( - <Badge variant="outline" className="text-xs"> - Yes - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 100, - }, - { - accessorKey: "gtcYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="GTC" /> - ), - cell: ({ row }) => { - const gtc = row.getValue("gtcYn") as boolean - return gtc ? ( - <Badge variant="outline" className="text-xs"> - Yes - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 60, - }, - { - accessorKey: "gtcValidDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="GTC 유효일" /> - ), - cell: ({ row }) => { - const gtcValidDate = row.getValue("gtcValidDate") as string - return gtcValidDate ? ( - <div className="text-sm"> - {gtcValidDate} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 100, - }, - - { - accessorKey: "classification", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="선급" /> - ), - cell: ({ row }) => { - const classification = row.getValue("classification") as string - return classification ? ( - <div className="text-sm font-medium max-w-[120px] truncate" title={classification}> - {classification} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - { - accessorKey: "sparepart", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Spare Part" /> - ), - cell: ({ row }) => { - const sparepart = row.getValue("sparepart") as string - return sparepart ? ( - <Badge variant="outline" className="text-xs"> - {sparepart} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 100, - }, - - { - id: "incoterms", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Incoterms" /> - ), - cell: ({ row }) => { - const code = row.original.incotermsCode as string - const description = row.original.incotermsDescription as string - - return code ? ( - <div className="space-y-1"> - <Badge variant="outline">{code}</Badge> - {description && ( - <div className="text-xs text-muted-foreground max-w-[150px] truncate" title={description}> - {description} - </div> - )} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** ───────────── 날짜 정보 ───────────── */ - { - accessorKey: "validDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유효일" /> - ), - cell: ({ row }) => { - const validDate = row.getValue("validDate") as Date - return validDate ? ( - <div className="text-sm"> - {formatDate(validDate, "KR")} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 100, - }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="마감일" /> - ), - cell: ({ row }) => { - const dueDate = row.getValue("dueDate") as Date - const isOverdue = dueDate && new Date(dueDate) < new Date() - - return dueDate ? ( - <div className={`${isOverdue ? 'text-red-600' : ''}`}> - <div className="font-medium">{formatDate(dueDate, "KR")}</div> - {isOverdue && ( - <div className="text-xs text-red-600">지연</div> - )} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - { - accessorKey: "returnYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 회신여부" /> - ), - cell: ({ row }) => { - const returnFlag = row.getValue("returnYn") as boolean - return returnFlag ? ( - <Badge variant="outline" className="text-xs"> - Yes - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 70, - }, - { - accessorKey: "returnRevision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="회신 리비전" /> - ), - cell: ({ row }) => { - const revision = row.getValue("returnRevision") as number - return revision > 0 ? ( - <Badge variant="outline"> - Rev. {revision} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 80, - }, - - { - accessorKey: "shortList", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Short List" /> - ), - cell: ({ row }) => { - const shortList = row.getValue("shortList") as boolean - return shortList ? ( - <Badge variant="secondary" className="text-xs"> - <CheckCircle2 className="h-3 w-3 mr-1" /> - Yes - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 90, - }, - - /** ───────────── 등록/수정 정보 ───────────── */ - { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등록일" /> - ), - cell: ({ row }) => { - const created = row.getValue("createdAt") as Date - const updated = row.original.updatedAt as Date - - return ( - <div className="space-y-1"> - <div className="text-sm">{formatDate(created, "KR")}</div> - {updated && new Date(updated) > new Date(created) && ( - <div className="text-xs text-blue-600"> - 수정: {formatDate(updated, "KR")} - </div> - )} - </div> - ) - }, - size: 120, - }, - - /** ───────────── 액션 ───────────── */ - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - 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-48"> - <DropdownMenuItem> - <MessageSquare className="mr-2 h-4 w-4" /> - 벤더 응답 보기 - </DropdownMenuItem> - <DropdownMenuSeparator /> - {setRowAction && ( - <> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - <Edit className="mr-2 h-4 w-4" /> - 수정 - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - <Trash className="mr-2 h-4 w-4" /> - 삭제 - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </> - )} - - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - }, - ] -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx deleted file mode 100644 index 5ea6b0bf..00000000 --- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx +++ /dev/null @@ -1,267 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션 -import { - getInitialRfqDetailColumns, - type DataTableRowAction -} from "./initial-rfq-detail-columns" -import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions" -import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" -import { UpdateInitialRfqSheet } from "./update-initial-rfq-sheet" -import { InitialRfqDetailView } from "@/db/schema" - -interface InitialRfqDetailTableProps { - promises: Promise<Awaited<ReturnType<typeof getInitialRfqDetail>>> - rfqId?: number -} - -export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTableProps) { - const { data, pageCount } = React.use(promises) - - // 선택된 상세 정보 - const [selectedDetail, setSelectedDetail] = React.useState<any>(null) - - // Row action 상태 (update/delete) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<InitialRfqDetailView> | null>(null) - - const columns = React.useMemo( - () => getInitialRfqDetailColumns({ - onSelectDetail: setSelectedDetail, - setRowAction: setRowAction - }), - [] - ) - - /** - * 필터 필드 정의 - */ - const filterFields: DataTableFilterField<any>[] = [ - { - id: "rfqCode", - label: "RFQ 코드", - placeholder: "RFQ 코드로 검색...", - }, - { - id: "vendorName", - label: "벤더명", - placeholder: "벤더명으로 검색...", - }, - { - id: "rfqStatus", - label: "RFQ 상태", - options: [ - { label: "Draft", value: "DRAFT", count: 0 }, - { label: "문서 접수", value: "Doc. Received", count: 0 }, - { label: "담당자 배정", value: "PIC Assigned", count: 0 }, - { label: "문서 확정", value: "Doc. Confirmed", count: 0 }, - { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 }, - { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 }, - { label: "TBE 시작", value: "TBE started", count: 0 }, - { label: "TBE 완료", value: "TBE finished", count: 0 }, - { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 }, - { label: "견적 접수", value: "Quotation Received", count: 0 }, - { label: "벤더 선정", value: "Vendor Selected", count: 0 }, - ], - }, - { - id: "initialRfqStatus", - label: "초기 RFQ 상태", - options: [ - { label: "초안", value: "DRAFT", count: 0 }, - { label: "발송", value: "Init. RFQ Sent", count: 0 }, - { label: "응답", value: "Init. RFQ Answered", count: 0 }, - { label: "거절", value: "S/L Decline", count: 0 }, - ], - }, - { - id: "vendorCountry", - label: "벤더 국가", - options: [ - { label: "한국", value: "KR", count: 0 }, - { label: "중국", value: "CN", count: 0 }, - { label: "일본", value: "JP", count: 0 }, - { label: "미국", value: "US", count: 0 }, - { label: "독일", value: "DE", count: 0 }, - ], - }, - ] - - /** - * 고급 필터 필드 - */ - const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { - id: "rfqCode", - label: "RFQ 코드", - type: "text", - }, - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "vendorCountry", - label: "벤더 국가", - type: "multi-select", - options: [ - { label: "한국", value: "KR" }, - { label: "중국", value: "CN" }, - { label: "일본", value: "JP" }, - { label: "미국", value: "US" }, - { label: "독일", value: "DE" }, - ], - }, - { - id: "rfqStatus", - label: "RFQ 상태", - type: "multi-select", - options: [ - { label: "Draft", value: "DRAFT" }, - { label: "문서 접수", value: "Doc. Received" }, - { label: "담당자 배정", value: "PIC Assigned" }, - { label: "문서 확정", value: "Doc. Confirmed" }, - { label: "초기 RFQ 발송", value: "Init. RFQ Sent" }, - { label: "초기 RFQ 응답", value: "Init. RFQ Answered" }, - { label: "TBE 시작", value: "TBE started" }, - { label: "TBE 완료", value: "TBE finished" }, - { label: "최종 RFQ 발송", value: "Final RFQ Sent" }, - { label: "견적 접수", value: "Quotation Received" }, - { label: "벤더 선정", value: "Vendor Selected" }, - ], - }, - { - id: "initialRfqStatus", - label: "초기 RFQ 상태", - type: "multi-select", - options: [ - { label: "초안", value: "DRAFT" }, - { label: "발송", value: "Init. RFQ Sent" }, - { label: "응답", value: "Init. RFQ Answered" }, - { label: "거절", value: "S/L Decline" }, - ], - }, - { - id: "vendorBusinessSize", - label: "벤더 규모", - type: "multi-select", - options: [ - { label: "대기업", value: "LARGE" }, - { label: "중기업", value: "MEDIUM" }, - { label: "소기업", value: "SMALL" }, - { label: "스타트업", value: "STARTUP" }, - ], - }, - { - id: "incotermsCode", - label: "Incoterms", - type: "text", - }, - { - id: "dueDate", - label: "마감일", - type: "date", - }, - { - id: "validDate", - label: "유효일", - type: "date", - }, - { - id: "shortList", - label: "Short List", - type: "boolean", - }, - { - id: "returnYn", - label: "Return 여부", - type: "boolean", - }, - { - id: "cpRequestYn", - label: "CP Request 여부", - type: "boolean", - }, - { - id: "prjectGtcYn", - label: "Project GTC 여부", - type: "boolean", - }, - { - id: "classification", - label: "분류", - type: "text", - }, - { - id: "sparepart", - label: "예비부품", - type: "text", - }, - { - id: "createdAt", - label: "등록일", - type: "date", - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => originalRow.initialRfqId ? originalRow.initialRfqId.toString():"1", - shallow: false, - clearOnDefault: true, - }) - - return ( - <div className="space-y-6"> - {/* 메인 테이블 */} - <div className="h-full w-full"> - <DataTable table={table} className="h-full"> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <InitialRfqDetailTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - </div> - - {/* Update Sheet */} - <UpdateInitialRfqSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - initialRfq={rowAction?.type === "update" ? rowAction.row.original : null} - /> - - {/* Delete Dialog */} - <DeleteInitialRfqDialog - open={rowAction?.type === "delete"} - onOpenChange={() => setRowAction(null)} - initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []} - showTrigger={false} - onSuccess={() => { - setRowAction(null) - // 테이블 리프레시는 revalidatePath로 자동 처리됨 - }} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx deleted file mode 100644 index c26bda28..00000000 --- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { useRouter } from "next/navigation" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { - Download, - Mail, - RefreshCw, - Settings, - Trash2, - FileText, - CheckCircle2, - Loader -} from "lucide-react" -import { AddInitialRfqDialog } from "./add-initial-rfq-dialog" -import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" -import { ShortListConfirmDialog } from "./short-list-confirm-dialog" -import { InitialRfqDetailView } from "@/db/schema" -import { sendBulkInitialRfqEmails } from "../service" - -interface InitialRfqDetailTableToolbarActionsProps { - table: Table<InitialRfqDetailView> - rfqId?: number - onRefresh?: () => void // 데이터 새로고침 콜백 -} - -export function InitialRfqDetailTableToolbarActions({ - table, - rfqId, - onRefresh -}: InitialRfqDetailTableToolbarActionsProps) { - const router = useRouter() - - // 선택된 행들 가져오기 - const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedDetails = selectedRows.map((row) => row.original) - const selectedCount = selectedRows.length - - // 상태 관리 - const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) - const [showShortListDialog, setShowShortListDialog] = React.useState(false) - const [isEmailSending, setIsEmailSending] = React.useState(false) - - // 전체 벤더 리스트 가져오기 (ShortList 확정용) - const allVendors = table.getRowModel().rows.map(row => row.original) - -const handleBulkEmail = async () => { - if (selectedCount === 0) return - - setIsEmailSending(true) - - try { - const initialRfqIds = selectedDetails - .map(detail => detail.initialRfqId) - .filter((id): id is number => id !== null); - - if (initialRfqIds.length === 0) { - toast.error("유효한 RFQ ID가 없습니다.") - return - } - - const result = await sendBulkInitialRfqEmails({ - initialRfqIds, - language: "en" // 기본 영어, 필요시 사용자 설정으로 변경 - }) - - if (result.success) { - toast.success(result.message) - - // 에러가 있다면 별도 알림 - if (result.errors && result.errors.length > 0) { - setTimeout(() => { - toast.warning(`일부 오류 발생: ${result.errors?.join(', ')}`) - }, 1000) - } - - // 선택 해제 - table.toggleAllRowsSelected(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } - } else { - toast.error(result.message || "RFQ 발송에 실패했습니다.") - } - - } catch (error) { - console.error("Email sending error:", error) - toast.error("RFQ 발송 중 오류가 발생했습니다.") - } finally { - setIsEmailSending(false) - } - } - - const handleBulkDelete = () => { - // DRAFT가 아닌 상태의 RFQ 확인 - const nonDraftRfqs = selectedDetails.filter( - detail => detail.initialRfqStatus !== "DRAFT" - ) - - if (nonDraftRfqs.length > 0) { - const statusMessages = { - "Init. RFQ Sent": "이미 발송된", - "S/L Decline": "Short List 거절 처리된", - "Init. RFQ Answered": "답변 완료된" - } - - const nonDraftStatuses = [...new Set(nonDraftRfqs.map(rfq => rfq.initialRfqStatus))] - const statusText = nonDraftStatuses - .map(status => statusMessages[status as keyof typeof statusMessages] || status) - .join(", ") - - toast.error( - `${statusText} RFQ는 삭제할 수 없습니다. DRAFT 상태의 RFQ만 삭제 가능합니다.` - ) - return - } - - setShowDeleteDialog(true) - } - - // S/L 확정 버튼 클릭 - const handleSlConfirm = () => { - if (!rfqId || allVendors.length === 0) { - toast.error("S/L 확정할 벤더가 없습니다.") - return - } - - // 진행 가능한 상태 확인 - const validVendors = allVendors.filter(vendor => - vendor.initialRfqStatus === "Init. RFQ Answered" || - vendor.initialRfqStatus === "Init. RFQ Sent" - ) - - if (validVendors.length === 0) { - toast.error("S/L 확정이 가능한 벤더가 없습니다. (RFQ 발송 또는 응답 완료된 벤더만 가능)") - return - } - - setShowShortListDialog(true) - } - - // 초기 RFQ 추가 성공 시 처리 - const handleAddSuccess = () => { - // 선택 해제 - table.toggleAllRowsSelected(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } else { - // fallback으로 페이지 새로고침 - setTimeout(() => { - window.location.reload() - }, 1000) - } - } - - // 삭제 성공 시 처리 - const handleDeleteSuccess = () => { - // 선택 해제 - table.toggleAllRowsSelected(false) - setShowDeleteDialog(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } - } - - // Short List 확정 성공 시 처리 - const handleShortListSuccess = () => { - // 선택 해제 - table.toggleAllRowsSelected(false) - setShowShortListDialog(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } - - // 최종 RFQ 페이지로 이동 - if (rfqId) { - toast.success("Short List가 확정되었습니다. 최종 RFQ 페이지로 이동합니다.") - setTimeout(() => { - router.push(`/evcp/b-rfq/${rfqId}`) - }, 1500) - } - } - - // 선택된 항목 중 첫 번째를 기본값으로 사용 - const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined - - const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT") - const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length - - // S/L 확정 가능한 벤더 수 - const validForShortList = allVendors.filter(vendor => - vendor.initialRfqStatus === "Init. RFQ Answered" || - vendor.initialRfqStatus === "Init. RFQ Sent" - ).length - - return ( - <> - <div className="flex items-center gap-2"> - {/** 선택된 항목이 있을 때만 표시되는 액션들 */} - {selectedCount > 0 && ( - <> - <Button - variant="outline" - size="sm" - onClick={handleBulkEmail} - className="h-8" - disabled={isEmailSending} - > - {isEmailSending ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />} - RFQ 발송 ({selectedCount}) - </Button> - - <Button - variant="outline" - size="sm" - onClick={handleBulkDelete} - className="h-8 text-red-600 hover:text-red-700" - disabled={!canDelete || selectedCount === 0} - title={!canDelete ? "DRAFT 상태의 RFQ만 삭제할 수 있습니다" : ""} - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 ({draftCount}/{selectedCount}) - </Button> - </> - )} - - {/* S/L 확정 버튼 */} - {rfqId && ( - <Button - variant="default" - size="sm" - onClick={handleSlConfirm} - className="h-8" - disabled={validForShortList === 0} - title={validForShortList === 0 ? "S/L 확정이 가능한 벤더가 없습니다" : `${validForShortList}개 벤더 중 Short List 선택`} - > - <CheckCircle2 className="mr-2 h-4 w-4" /> - S/L 확정 ({validForShortList}) - </Button> - )} - - {/* 초기 RFQ 추가 버튼 */} - {rfqId && ( - <AddInitialRfqDialog - rfqId={rfqId} - onSuccess={handleAddSuccess} - defaultValues={defaultValues} - /> - )} - </div> - - {/* 삭제 다이얼로그 */} - <DeleteInitialRfqDialog - open={showDeleteDialog} - onOpenChange={setShowDeleteDialog} - initialRfqs={selectedDetails} - showTrigger={false} - onSuccess={handleDeleteSuccess} - /> - - {/* Short List 확정 다이얼로그 */} - {rfqId && ( - <ShortListConfirmDialog - open={showShortListDialog} - onOpenChange={setShowShortListDialog} - rfqId={rfqId} - vendors={allVendors.filter(vendor => - vendor.initialRfqStatus === "Init. RFQ Answered" || - vendor.initialRfqStatus === "Init. RFQ Sent" - )} - onSuccess={handleShortListSuccess} - /> - )} - </> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/short-list-confirm-dialog.tsx b/lib/b-rfq/initial/short-list-confirm-dialog.tsx deleted file mode 100644 index 92c62dc0..00000000 --- a/lib/b-rfq/initial/short-list-confirm-dialog.tsx +++ /dev/null @@ -1,269 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" -import { Loader2, Building, CheckCircle2, XCircle } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { shortListConfirm } from "../service" -import { InitialRfqDetailView } from "@/db/schema" - -const shortListSchema = z.object({ - selectedVendorIds: z.array(z.number()).min(1, "최소 1개 이상의 벤더를 선택해야 합니다."), -}) - -type ShortListFormData = z.infer<typeof shortListSchema> - -interface ShortListConfirmDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - rfqId: number - vendors: InitialRfqDetailView[] - onSuccess?: () => void -} - -export function ShortListConfirmDialog({ - open, - onOpenChange, - rfqId, - vendors, - onSuccess -}: ShortListConfirmDialogProps) { - const [isLoading, setIsLoading] = React.useState(false) - - const form = useForm<ShortListFormData>({ - resolver: zodResolver(shortListSchema), - defaultValues: { - selectedVendorIds: vendors - .filter(vendor => vendor.shortList === true) - .map(vendor => vendor.vendorId) - .filter(Boolean) as number[] - }, - }) - - const watchedSelectedIds = form.watch("selectedVendorIds") - - // 선택된/탈락된 벤더 계산 - const selectedVendors = vendors.filter(vendor => - vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId) - ) - const rejectedVendors = vendors.filter(vendor => - vendor.vendorId && !watchedSelectedIds.includes(vendor.vendorId) - ) - - async function onSubmit(data: ShortListFormData) { - if (!rfqId) return - - setIsLoading(true) - - try { - const result = await shortListConfirm({ - rfqId, - selectedVendorIds: data.selectedVendorIds, - rejectedVendorIds: vendors - .filter(v => v.vendorId && !data.selectedVendorIds.includes(v.vendorId)) - .map(v => v.vendorId!) - }) - - if (result.success) { - toast.success(result.message) - onOpenChange(false) - form.reset() - onSuccess?.() - } else { - toast.error(result.message || "Short List 확정에 실패했습니다.") - } - } catch (error) { - console.error("Short List confirm error:", error) - toast.error("Short List 확정 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - const handleVendorToggle = (vendorId: number, checked: boolean) => { - const currentSelected = form.getValues("selectedVendorIds") - - if (checked) { - form.setValue("selectedVendorIds", [...currentSelected, vendorId]) - } else { - form.setValue("selectedVendorIds", currentSelected.filter(id => id !== vendorId)) - } - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[80vh]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <CheckCircle2 className="h-5 w-5 text-green-600" /> - Short List 확정 - </DialogTitle> - <DialogDescription> - 최종 RFQ로 진행할 벤더를 선택해주세요. 선택되지 않은 벤더에게는 자동으로 Letter of Regret이 발송됩니다. - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - <FormField - control={form.control} - name="selectedVendorIds" - render={() => ( - <FormItem> - <FormLabel className="text-base font-semibold"> - 벤더 선택 ({vendors.length}개 업체) - </FormLabel> - <FormControl> - <ScrollArea className="h-[400px] border rounded-md p-4"> - <div className="space-y-4"> - {vendors.map((vendor) => { - const isSelected = vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId) - - return ( - <div - key={vendor.vendorId} - className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors ${ - isSelected - ? 'border-green-200 bg-green-50' - : 'border-red-100 bg-red-50' - }`} - > - <Checkbox - checked={isSelected} - onCheckedChange={(checked) => - vendor.vendorId && handleVendorToggle(vendor.vendorId, !!checked) - } - className="mt-1" - /> - <div className="flex-1 space-y-2"> - <div className="flex items-center gap-2"> - <Building className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">{vendor.vendorName}</span> - {isSelected ? ( - <Badge variant="secondary" className="bg-green-100 text-green-800"> - 선택됨 - </Badge> - ) : ( - <Badge variant="secondary" className="bg-red-100 text-red-800"> - 탈락 - </Badge> - )} - </div> - <div className="text-sm text-muted-foreground"> - <span className="font-mono">{vendor.vendorCode}</span> - {vendor.vendorCountry && ( - <> - <span className="mx-2">•</span> - <span>{vendor.vendorCountry === "KR" ? "국내" : "해외"}</span> - </> - )} - {vendor.vendorCategory && ( - <> - <span className="mx-2">•</span> - <span>{vendor.vendorCategory}</span> - </> - )} - {vendor.vendorBusinessSize && ( - <> - <span className="mx-2">•</span> - <span>{vendor.vendorBusinessSize}</span> - </> - )} - </div> - <div className="text-xs text-muted-foreground"> - RFQ 상태: <Badge variant="outline" className="text-xs"> - {vendor.initialRfqStatus} - </Badge> - </div> - </div> - </div> - ) - })} - </div> - </ScrollArea> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 요약 정보 */} - <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"> - <div className="space-y-2"> - <div className="flex items-center gap-2 text-green-700"> - <CheckCircle2 className="h-4 w-4" /> - <span className="font-medium">선택된 벤더</span> - </div> - <div className="text-2xl font-bold text-green-700"> - {selectedVendors.length}개 업체 - </div> - {selectedVendors.length > 0 && ( - <div className="text-sm text-muted-foreground"> - {selectedVendors.map(v => v.vendorName).join(", ")} - </div> - )} - </div> - <div className="space-y-2"> - <div className="flex items-center gap-2 text-red-700"> - <XCircle className="h-4 w-4" /> - <span className="font-medium">탈락 벤더</span> - </div> - <div className="text-2xl font-bold text-red-700"> - {rejectedVendors.length}개 업체 - </div> - {rejectedVendors.length > 0 && ( - <div className="text-sm text-muted-foreground"> - Letter of Regret 발송 예정 - </div> - )} - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isLoading} - > - 취소 - </Button> - <Button - type="submit" - disabled={isLoading || selectedVendors.length === 0} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Short List 확정 - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx deleted file mode 100644 index a19b5172..00000000 --- a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx +++ /dev/null @@ -1,496 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { CalendarIcon, Loader, ChevronsUpDown, Check } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { format } from "date-fns" -import { ko } from "date-fns/locale" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Calendar } from "@/components/ui/calendar" -import { Checkbox } from "@/components/ui/checkbox" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - } from "@/components/ui/command" -import { Input } from "@/components/ui/input" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { UpdateInitialRfqSchema, updateInitialRfqSchema } from "../validations" -import { getIncotermsForSelection, modifyInitialRfq } from "../service" -import { InitialRfqDetailView } from "@/db/schema" - -interface UpdateInitialRfqSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - initialRfq: InitialRfqDetailView | null -} - -interface Incoterm { - id: number - code: string - description: string -} - -export function UpdateInitialRfqSheet({ initialRfq, ...props }: UpdateInitialRfqSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) - const [incotermsLoading, setIncotermsLoading] = React.useState(false) - const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) - - const loadIncoterms = React.useCallback(async () => { - setIncotermsLoading(true) - try { - const incotermsList = await getIncotermsForSelection() - setIncoterms(incotermsList) - } catch (error) { - console.error("Failed to load incoterms:", error) - toast.error("Incoterms 목록을 불러오는데 실패했습니다.") - } finally { - setIncotermsLoading(false) - } - }, []) - - React.useEffect(() => { - if (incoterms.length === 0) { - loadIncoterms() - } - }, [incoterms.length, loadIncoterms]) - - const form = useForm<UpdateInitialRfqSchema>({ - resolver: zodResolver(updateInitialRfqSchema), - defaultValues: { - initialRfqStatus: initialRfq?.initialRfqStatus ?? "DRAFT", - dueDate: initialRfq?.dueDate ?? new Date(), - validDate: initialRfq?.validDate ?? undefined, - incotermsCode: initialRfq?.incotermsCode ?? "", - classification: initialRfq?.classification ?? "", - sparepart: initialRfq?.sparepart ?? "", - rfqRevision: initialRfq?.rfqRevision ?? 0, - shortList: initialRfq?.shortList ?? false, - returnYn: initialRfq?.returnYn ?? false, - cpRequestYn: initialRfq?.cpRequestYn ?? false, - prjectGtcYn: initialRfq?.prjectGtcYn ?? false, - }, - }) - - // initialRfq가 변경될 때 폼 값을 업데이트 - React.useEffect(() => { - if (initialRfq) { - form.reset({ - initialRfqStatus: initialRfq.initialRfqStatus ?? "DRAFT", - dueDate: initialRfq.dueDate, - validDate: initialRfq.validDate, - incotermsCode: initialRfq.incotermsCode ?? "", - classification: initialRfq.classification ?? "", - sparepart: initialRfq.sparepart ?? "", - shortList: initialRfq.shortList ?? false, - returnYn: initialRfq.returnYn ?? false, - rfqRevision: initialRfq.rfqRevision ?? 0, - cpRequestYn: initialRfq.cpRequestYn ?? false, - prjectGtcYn: initialRfq.prjectGtcYn ?? false, - }) - } - }, [initialRfq, form]) - - function onSubmit(input: UpdateInitialRfqSchema) { - startUpdateTransition(async () => { - if (!initialRfq || !initialRfq.initialRfqId) { - toast.error("유효하지 않은 RFQ입니다.") - return - } - - const { error } = await modifyInitialRfq({ - id: initialRfq.initialRfqId, - ...input, - }) - - if (error) { - toast.error(error) - return - } - - form.reset() - props.onOpenChange?.(false) - toast.success("초기 RFQ가 수정되었습니다") - }) - } - - const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col h-full sm:max-w-md"> - {/* 고정 헤더 */} - <SheetHeader className="flex-shrink-0 text-left pb-6"> - <SheetTitle>초기 RFQ 수정</SheetTitle> - <SheetDescription> - 초기 RFQ 정보를 수정하고 변경사항을 저장하세요 - </SheetDescription> - </SheetHeader> - - {/* 스크롤 가능한 폼 영역 */} - <div className="flex-1 overflow-y-auto"> - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4 pr-2" - > - {/* RFQ 리비전 */} - <FormField - control={form.control} - name="rfqRevision" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ 리비전</FormLabel> - <FormControl> - <Input - type="number" - min="0" - placeholder="0" - {...field} - onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 마감일 */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>마감일 *</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant={"outline"} - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - format(field.value, "PPP", { locale: ko }) - ) : ( - <span>날짜를 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 유효일 */} - <FormField - control={form.control} - name="validDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>유효일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant={"outline"} - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - format(field.value, "PPP", { locale: ko }) - ) : ( - <span>날짜를 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* Incoterms 코드 */} - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>Incoterms</FormLabel> - <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={incotermsSearchOpen} - className="justify-between" - disabled={incotermsLoading} - > - {selectedIncoterm ? ( - <div className="flex items-center gap-2"> - <span className="truncate"> - {selectedIncoterm.code} - {selectedIncoterm.description} - </span> - </div> - ) : ( - <span className="text-muted-foreground"> - {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"} - </span> - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0" align="start"> - <Command> - <CommandInput - placeholder="코드 또는 내용으로 검색..." - className="h-9" - /> - <CommandList> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {incoterms.map((incoterm) => ( - <CommandItem - key={incoterm.id} - value={`${incoterm.code} ${incoterm.description}`} - onSelect={() => { - field.onChange(incoterm.code) - setIncotermsSearchOpen(false) - }} - > - <div className="flex items-center gap-2 w-full"> - <div className="flex-1 min-w-0"> - <div className="font-medium truncate"> - {incoterm.code} - {incoterm.description} - </div> - </div> - <Check - className={cn( - "ml-auto h-4 w-4", - incoterm.code === field.value ? "opacity-100" : "opacity-0" - )} - /> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - {/* 체크박스 옵션들 */} - <div className="space-y-3"> - <FormField - control={form.control} - name="shortList" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none ml-2"> - <FormLabel>Short List</FormLabel> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="returnYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none ml-2"> - <FormLabel>회신 여부</FormLabel> - </div> - </FormItem> - )} - /> - - {/* 선급 */} - <FormField - control={form.control} - name="classification" - render={({ field }) => ( - <FormItem> - <FormLabel>선급</FormLabel> - <FormControl> - <Input - placeholder="선급" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 예비부품 */} - <FormField - control={form.control} - name="sparepart" - render={({ field }) => ( - <FormItem> - <FormLabel>예비부품</FormLabel> - <FormControl> - <Input - placeholder="O1, O2" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - - - - <FormField - control={form.control} - name="cpRequestYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none ml-2"> - <FormLabel>CP 요청</FormLabel> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="prjectGtcYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none ml-2"> - <FormLabel>프로젝트 GTC</FormLabel> - </div> - </FormItem> - )} - /> - </div> - - {/* 하단 여백 */} - <div className="h-4" /> - </form> - </Form> - </div> - - {/* 고정 푸터 */} - <SheetFooter className="flex-shrink-0 gap-2 pt-6 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 취소 - </Button> - </SheetClose> - <Button - onClick={form.handleSubmit(onSubmit)} - disabled={isUpdatePending} - > - {isUpdatePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 저장 - </Button> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/repository.ts b/lib/b-rfq/repository.ts deleted file mode 100644 index e69de29b..00000000 --- a/lib/b-rfq/repository.ts +++ /dev/null diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts deleted file mode 100644 index 896a082d..00000000 --- a/lib/b-rfq/service.ts +++ /dev/null @@ -1,2976 +0,0 @@ -'use server' - -import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from "next/cache" -import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm" -import { filterColumns } from "@/lib/filter-columns" -import db from "@/db/db" -import { - vendorResponseDetailView, - attachmentRevisionHistoryView, - rfqProgressSummaryView, - vendorResponseAttachmentsEnhanced, Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors, - vendorResponseAttachmentsB, - finalRfq, - finalRfqDetailView -} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 -import { rfqDashboardView } from "@/db/schema" // 뷰 import -import type { SQL } from "drizzle-orm" -import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, ShortListConfirmInput, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema, shortListConfirmSchema, GetFinalRfqDetailSchema } from "./validations" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { unlink } from "fs/promises" -import { getErrorMessage } from "../handle-error" -import { AddInitialRfqFormData } from "./initial/add-initial-rfq-dialog" -import { sendEmail } from "../mail/sendEmail" -import { RfqType } from "../rfqs/validations" - -const tag = { - initialRfqDetail: "initial-rfq", - rfqDashboard: 'rfq-dashboard', - rfq: (id: number) => `rfq-${id}`, - rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`, - attachmentRevisions: (attId: number) => `attachment-revisions-${attId}`, - vendorResponses: ( - attId: number, - type: 'INITIAL' | 'FINAL' = 'INITIAL', - ) => `vendor-responses-${attId}-${type}`, -} as const; - -export async function getRFQDashboard(input: GetRFQDashboardSchema) { - - try { - const offset = (input.page - 1) * input.perPage; - - const rfqFilterMapping = createRFQFilterMapping(); - const joinedTables = getRFQJoinedTables(); - - console.log(input, "견적 인풋") - - // 1) 고급 필터 조건 - let advancedWhere: SQL<unknown> | undefined = undefined; - if (input.filters && input.filters.length > 0) { - advancedWhere = filterColumns({ - table: rfqDashboardView, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - joinedTables, - customColumnMapping: rfqFilterMapping, - }); - } - - // 2) 기본 필터 조건 - let basicWhere: SQL<unknown> | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: rfqDashboardView, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - joinedTables, - customColumnMapping: rfqFilterMapping, - }); - } - - // 3) 글로벌 검색 조건 - let globalWhere: SQL<unknown> | undefined = undefined; - if (input.search) { - const s = `%${input.search}%`; - - const validSearchConditions: SQL<unknown>[] = []; - - const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s); - if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); - - const descriptionCondition = ilike(rfqDashboardView.description, s); - if (descriptionCondition) validSearchConditions.push(descriptionCondition); - - const projectNameCondition = ilike(rfqDashboardView.projectName, s); - if (projectNameCondition) validSearchConditions.push(projectNameCondition); - - const projectCodeCondition = ilike(rfqDashboardView.projectCode, s); - if (projectCodeCondition) validSearchConditions.push(projectCodeCondition); - - const picNameCondition = ilike(rfqDashboardView.picName, s); - if (picNameCondition) validSearchConditions.push(picNameCondition); - - const packageNoCondition = ilike(rfqDashboardView.packageNo, s); - if (packageNoCondition) validSearchConditions.push(packageNoCondition); - - const packageNameCondition = ilike(rfqDashboardView.packageName, s); - if (packageNameCondition) validSearchConditions.push(packageNameCondition); - - if (validSearchConditions.length > 0) { - globalWhere = or(...validSearchConditions); - } - } - - - - // 6) 최종 WHERE 조건 생성 - const whereConditions: SQL<unknown>[] = []; - - if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); - if (globalWhere) whereConditions.push(globalWhere); - - const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // 7) 전체 데이터 수 조회 - const totalResult = await db - .select({ count: count() }) - .from(rfqDashboardView) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - - if (total === 0) { - return { data: [], pageCount: 0, total: 0 }; - } - - console.log(total) - - // 8) 정렬 및 페이징 처리된 데이터 조회 - const orderByColumns = input.sort.map((sort) => { - const column = sort.id as keyof typeof rfqDashboardView.$inferSelect; - return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]); - }); - - if (orderByColumns.length === 0) { - orderByColumns.push(desc(rfqDashboardView.createdAt)); - } - - const rfqData = await db - .select() - .from(rfqDashboardView) - .where(finalWhere) - .orderBy(...orderByColumns) - .limit(input.perPage) - .offset(offset); - - const pageCount = Math.ceil(total / input.perPage); - - return { data: rfqData, pageCount, total }; - } catch (err) { - console.error("Error in getRFQDashboard:", err); - return { data: [], pageCount: 0, total: 0 }; - } - -} - -// 헬퍼 함수들 -function createRFQFilterMapping() { - return { - // 뷰의 컬럼명과 실제 필터링할 컬럼 매핑 - rfqCode: rfqDashboardView.rfqCode, - description: rfqDashboardView.description, - status: rfqDashboardView.status, - projectName: rfqDashboardView.projectName, - projectCode: rfqDashboardView.projectCode, - picName: rfqDashboardView.picName, - packageNo: rfqDashboardView.packageNo, - packageName: rfqDashboardView.packageName, - dueDate: rfqDashboardView.dueDate, - overallProgress: rfqDashboardView.overallProgress, - createdAt: rfqDashboardView.createdAt, - }; -} - -function getRFQJoinedTables() { - return { - // 조인된 테이블 정보 (뷰이므로 실제로는 사용되지 않을 수 있음) - projects, - users, - }; -} - -// ================================================================ -// 3. RFQ Dashboard 타입 정의 -// ================================================================ - -async function generateNextSerial(picCode: string): Promise<string> { - try { - // 해당 picCode로 시작하는 RFQ 개수 조회 - const existingCount = await db - .select({ count: count() }) - .from(bRfqs) - .where(eq(bRfqs.picCode, picCode)) - - const nextSerial = (existingCount[0]?.count || 0) + 1 - return nextSerial.toString().padStart(5, '0') // 5자리로 패딩 - } catch (error) { - console.error("시리얼 번호 생성 오류:", error) - return "00001" // 기본값 - } -} - -export async function createRfqAction(input: CreateRfqInput) { - try { - // 입력 데이터 검증 - const validatedData = createRfqServerSchema.parse(input) - - // RFQ 코드 자동 생성: N + picCode + 시리얼5자리 - const serialNumber = await generateNextSerial(validatedData.picCode) - const rfqCode = `N${validatedData.picCode}${serialNumber}` - - // 데이터베이스에 삽입 - const result = await db.insert(bRfqs).values({ - rfqCode, - projectId: validatedData.projectId, - dueDate: validatedData.dueDate, - status: "DRAFT", - picCode: validatedData.picCode, - picName: validatedData.picName || null, - EngPicName: validatedData.engPicName || null, - packageNo: validatedData.packageNo || null, - packageName: validatedData.packageName || null, - remark: validatedData.remark || null, - projectCompany: validatedData.projectCompany || null, - projectFlag: validatedData.projectFlag || null, - projectSite: validatedData.projectSite || null, - createdBy: validatedData.createdBy, - updatedBy: validatedData.updatedBy, - }).returning({ - id: bRfqs.id, - rfqCode: bRfqs.rfqCode, - }) - - - - return { - success: true, - data: result[0], - message: "RFQ가 성공적으로 생성되었습니다", - } - - } catch (error) { - console.error("RFQ 생성 오류:", error) - - - return { - success: false, - error: "RFQ 생성에 실패했습니다", - } - } -} - -// RFQ 코드 중복 확인 액션 -export async function checkRfqCodeExists(rfqCode: string) { - try { - const existing = await db.select({ id: bRfqs.id }) - .from(bRfqs) - .where(eq(bRfqs.rfqCode, rfqCode)) - .limit(1) - - return existing.length > 0 - } catch (error) { - console.error("RFQ 코드 확인 오류:", error) - return false - } -} - -// picCode별 다음 예상 RFQ 코드 미리보기 -export async function previewNextRfqCode(picCode: string) { - try { - const serialNumber = await generateNextSerial(picCode) - return `N${picCode}${serialNumber}` - } catch (error) { - console.error("RFQ 코드 미리보기 오류:", error) - return `N${picCode}00001` - } -} - -const getBRfqById = async (id: number): Promise<RfqDashboardView | null> => { - // 1) RFQ 단건 조회 - const rfqsRes = await db - .select() - .from(rfqDashboardView) - .where(eq(rfqDashboardView.rfqId, id)) - .limit(1); - - if (rfqsRes.length === 0) return null; - const rfqRow = rfqsRes[0]; - - // 3) RfqWithItems 형태로 반환 - const result: RfqDashboardView = { - ...rfqRow, - - }; - - return result; -}; - - -export const findBRfqById = async (id: number): Promise<RfqDashboardView | null> => { - try { - - const rfq = await getBRfqById(id); - - return rfq; - } catch (error) { - throw new Error('Failed to fetch user'); - } -}; - - -export async function getRfqAttachments( - input: GetRfqAttachmentsSchema, - rfqId: number -) { - try { - const offset = (input.page - 1) * input.perPage - - // Advanced Filter 처리 (메인 테이블 기준) - const advancedWhere = filterColumns({ - table: bRfqsAttachments, - filters: input.filters, - joinOperator: input.joinOperator, - }) - - // 전역 검색 (첨부파일 + 리비전 파일명 검색) - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - ilike(bRfqsAttachments.serialNo, s), - ilike(bRfqsAttachments.description, s), - ilike(bRfqsAttachments.currentRevision, s), - ilike(bRfqAttachmentRevisions.fileName, s), - ilike(bRfqAttachmentRevisions.originalFileName, s) - ) - } - - // 기본 필터 - let basicWhere - if (input.attachmentType.length > 0 || input.fileType.length > 0) { - basicWhere = and( - input.attachmentType.length > 0 - ? inArray(bRfqsAttachments.attachmentType, input.attachmentType) - : undefined, - input.fileType.length > 0 - ? inArray(bRfqAttachmentRevisions.fileType, input.fileType) - : undefined - ) - } - - // 최종 WHERE 절 - const finalWhere = and( - eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건 - advancedWhere, - globalWhere, - basicWhere - ) - - // 정렬 (메인 테이블 기준) - const orderBy = input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) - ) - : [desc(bRfqsAttachments.createdAt)] - - // 트랜잭션으로 데이터 조회 - const { data, total } = await db.transaction(async (tx) => { - // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인) - const data = await tx - .select({ - // 첨부파일 메인 정보 - id: bRfqsAttachments.id, - attachmentType: bRfqsAttachments.attachmentType, - serialNo: bRfqsAttachments.serialNo, - rfqId: bRfqsAttachments.rfqId, - currentRevision: bRfqsAttachments.currentRevision, - latestRevisionId: bRfqsAttachments.latestRevisionId, - description: bRfqsAttachments.description, - createdBy: bRfqsAttachments.createdBy, - createdAt: bRfqsAttachments.createdAt, - updatedAt: bRfqsAttachments.updatedAt, - - // 최신 리비전 파일 정보 - fileName: bRfqAttachmentRevisions.fileName, - originalFileName: bRfqAttachmentRevisions.originalFileName, - filePath: bRfqAttachmentRevisions.filePath, - fileSize: bRfqAttachmentRevisions.fileSize, - fileType: bRfqAttachmentRevisions.fileType, - revisionComment: bRfqAttachmentRevisions.revisionComment, - - // 생성자 정보 - createdByName: users.name, - }) - .from(bRfqsAttachments) - .leftJoin( - bRfqAttachmentRevisions, - and( - eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id), - eq(bRfqAttachmentRevisions.isLatest, true) - ) - ) - .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id)) - .where(finalWhere) - .orderBy(...orderBy) - .limit(input.perPage) - .offset(offset) - - // 전체 개수 조회 - const totalResult = await tx - .select({ count: count() }) - .from(bRfqsAttachments) - .leftJoin( - bRfqAttachmentRevisions, - eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id) - ) - .where(finalWhere) - - const total = totalResult[0]?.count ?? 0 - - return { data, total } - }) - - const pageCount = Math.ceil(total / input.perPage) - - // 각 첨부파일별 벤더 응답 통계 조회 - const attachmentIds = data.map(item => item.id) - let responseStatsMap: Record<number, any> = {} - - if (attachmentIds.length > 0) { - responseStatsMap = await getAttachmentResponseStats(attachmentIds) - } - - // 통계 데이터 병합 - const dataWithStats = data.map(attachment => ({ - ...attachment, - responseStats: responseStatsMap[attachment.id] || { - totalVendors: 0, - respondedCount: 0, - pendingCount: 0, - waivedCount: 0, - responseRate: 0 - } - })) - - return { data: dataWithStats, pageCount } - } catch (err) { - console.error("getRfqAttachments error:", err) - return { data: [], pageCount: 0 } - } - -} - -// 첨부파일별 벤더 응답 통계 조회 -async function getAttachmentResponseStats(attachmentIds: number[]) { - try { - const stats = await db - .select({ - attachmentId: vendorAttachmentResponses.attachmentId, - totalVendors: count(), - respondedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' then 1 end)`, - pendingCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' then 1 end)`, - waivedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'WAIVED' then 1 end)`, - }) - .from(vendorAttachmentResponses) - .where(inArray(vendorAttachmentResponses.attachmentId, attachmentIds)) - .groupBy(vendorAttachmentResponses.attachmentId) - - // 응답률 계산해서 객체로 변환 - const statsMap: Record<number, any> = {} - stats.forEach(stat => { - const activeVendors = stat.totalVendors - stat.waivedCount - const responseRate = activeVendors > 0 - ? Math.round((stat.respondedCount / activeVendors) * 100) - : 0 - - statsMap[stat.attachmentId] = { - totalVendors: stat.totalVendors, - respondedCount: stat.respondedCount, - pendingCount: stat.pendingCount, - waivedCount: stat.waivedCount, - responseRate - } - }) - - return statsMap - } catch (error) { - console.error("getAttachmentResponseStats error:", error) - return {} - } -} - -// 특정 첨부파일에 대한 벤더 응답 현황 상세 조회 -export async function getVendorResponsesForAttachment( - attachmentId: number, - rfqType: 'INITIAL' | 'FINAL' = 'INITIAL' -) { - try { - // 1. 기본 벤더 응답 정보 가져오기 (첨부파일 정보와 조인) - const responses = await db - .select({ - id: vendorAttachmentResponses.id, - attachmentId: vendorAttachmentResponses.attachmentId, - vendorId: vendorAttachmentResponses.vendorId, - vendorCode: vendors.vendorCode, - vendorName: vendors.vendorName, - vendorCountry: vendors.country, - rfqType: vendorAttachmentResponses.rfqType, - rfqRecordId: vendorAttachmentResponses.rfqRecordId, - responseStatus: vendorAttachmentResponses.responseStatus, - - // 첨부파일의 현재 리비전 (가장 중요!) - currentRevision: bRfqsAttachments.currentRevision, - - // 벤더가 응답한 리비전 - respondedRevision: vendorAttachmentResponses.respondedRevision, - - responseComment: vendorAttachmentResponses.responseComment, - vendorComment: vendorAttachmentResponses.vendorComment, - - // 새로 추가된 필드들 - revisionRequestComment: vendorAttachmentResponses.revisionRequestComment, - revisionRequestedAt: vendorAttachmentResponses.revisionRequestedAt, - requestedAt: vendorAttachmentResponses.requestedAt, - respondedAt: vendorAttachmentResponses.respondedAt, - updatedAt: vendorAttachmentResponses.updatedAt, - }) - .from(vendorAttachmentResponses) - .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id)) - .leftJoin(bRfqsAttachments, eq(vendorAttachmentResponses.attachmentId, bRfqsAttachments.id)) - .where( - and( - eq(vendorAttachmentResponses.attachmentId, attachmentId), - eq(vendorAttachmentResponses.rfqType, rfqType) - ) - ) - .orderBy(vendors.vendorName); - - // 2. 각 응답에 대한 파일 정보 가져오기 - const responseIds = responses.map(r => r.id); - - let responseFiles: any[] = []; - if (responseIds.length > 0) { - responseFiles = await db - .select({ - id: vendorResponseAttachmentsB.id, - vendorResponseId: vendorResponseAttachmentsB.vendorResponseId, - fileName: vendorResponseAttachmentsB.fileName, - originalFileName: vendorResponseAttachmentsB.originalFileName, - filePath: vendorResponseAttachmentsB.filePath, - fileSize: vendorResponseAttachmentsB.fileSize, - fileType: vendorResponseAttachmentsB.fileType, - description: vendorResponseAttachmentsB.description, - uploadedAt: vendorResponseAttachmentsB.uploadedAt, - }) - .from(vendorResponseAttachmentsB) - .where(inArray(vendorResponseAttachmentsB.vendorResponseId, responseIds)) - .orderBy(desc(vendorResponseAttachmentsB.uploadedAt)); - } - - // 3. 응답에 파일 정보 병합 및 리비전 상태 체크 - const enhancedResponses = responses.map(response => { - const files = responseFiles.filter(file => file.vendorResponseId === response.id); - const latestFile = files - .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())[0] || null; - - // 벤더가 최신 리비전에 응답했는지 체크 - const isUpToDate = response.respondedRevision === response.currentRevision; - - return { - ...response, - files, - totalFiles: files.length, - latestFile, - isUpToDate, // 최신 리비전 응답 여부 - }; - }); - - return enhancedResponses; - } catch (err) { - console.error("getVendorResponsesForAttachment error:", err); - return []; - } -} - -export async function confirmDocuments(rfqId: number) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - throw new Error("인증이 필요합니다.") - } - - // TODO: RFQ 상태를 "Doc. Confirmed"로 업데이트 - await db - .update(bRfqs) - .set({ - status: "Doc. Confirmed", - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(bRfqs.id, rfqId)) - - - return { - success: true, - message: "문서가 확정되었습니다.", - } - - } catch (error) { - console.error("confirmDocuments error:", error) - return { - success: false, - message: error instanceof Error ? error.message : "문서 확정 중 오류가 발생했습니다.", - } - } -} - -// TBE 요청 서버 액션 -export async function requestTbe(rfqId: number, attachmentIds?: number[]) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - throw new Error("인증이 필요합니다.") - } - - // attachmentIds가 제공된 경우 해당 첨부파일들만 처리 - let targetAttachments = [] - if (attachmentIds && attachmentIds.length > 0) { - // 선택된 첨부파일들 조회 - targetAttachments = await db - .select({ - id: bRfqsAttachments.id, - serialNo: bRfqsAttachments.serialNo, - attachmentType: bRfqsAttachments.attachmentType, - currentRevision: bRfqsAttachments.currentRevision, - }) - .from(bRfqsAttachments) - .where( - and( - eq(bRfqsAttachments.rfqId, rfqId), - inArray(bRfqsAttachments.id, attachmentIds) - ) - ) - - if (targetAttachments.length === 0) { - throw new Error("선택된 첨부파일을 찾을 수 없습니다.") - } - } else { - // 전체 RFQ의 모든 첨부파일 처리 - targetAttachments = await db - .select({ - id: bRfqsAttachments.id, - serialNo: bRfqsAttachments.serialNo, - attachmentType: bRfqsAttachments.attachmentType, - currentRevision: bRfqsAttachments.currentRevision, - }) - .from(bRfqsAttachments) - .where(eq(bRfqsAttachments.rfqId, rfqId)) - } - - if (targetAttachments.length === 0) { - throw new Error("TBE 요청할 첨부파일이 없습니다.") - } - - // TODO: TBE 요청 로직 구현 - // 1. RFQ 상태를 "TBE started"로 업데이트 (선택적) - // 2. 선택된 첨부파일들에 대해 벤더들에게 TBE 요청 이메일 발송 - // 3. vendorAttachmentResponses 테이블에 TBE 요청 레코드 생성 - // 4. TBE 관련 메타데이터 업데이트 - - - - // 예시: 선택된 첨부파일들에 대한 벤더 응답 레코드 생성 - await db.transaction(async (tx) => { - - const [updatedRfq] = await tx - .update(bRfqs) - .set({ - status: "TBE started", - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(bRfqs.id, rfqId)) - .returning() - - // 각 첨부파일에 대해 벤더 응답 레코드 생성 또는 업데이트 - for (const attachment of targetAttachments) { - // TODO: 해당 첨부파일과 연관된 벤더들에게 TBE 요청 처리 - console.log(`TBE 요청 처리: ${attachment.serialNo} (${attachment.currentRevision})`) - } - }) - - - const attachmentCount = targetAttachments.length - const attachmentList = targetAttachments - .map(a => `${a.serialNo} (${a.currentRevision})`) - .join(', ') - - return { - success: true, - message: `${attachmentCount}개 문서에 대한 TBE 요청이 전송되었습니다.\n대상: ${attachmentList}`, - targetAttachments, - } - - } catch (error) { - console.error("requestTbe error:", error) - return { - success: false, - message: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다.", - } - } -} - -// 다음 시리얼 번호 생성 -async function getNextSerialNo(rfqId: number): Promise<string> { - try { - // 해당 RFQ의 기존 첨부파일 개수 조회 - const [result] = await db - .select({ count: count() }) - .from(bRfqsAttachments) - .where(eq(bRfqsAttachments.rfqId, rfqId)) - - const nextNumber = (result?.count || 0) + 1 - - // 001, 002, 003... 형태로 포맷팅 - return nextNumber.toString().padStart(3, '0') - - } catch (error) { - console.error("getNextSerialNo error:", error) - // 에러 발생 시 타임스탬프 기반으로 fallback - return Date.now().toString().slice(-3) - } -} - -export async function addRfqAttachmentRecord(record: AttachmentRecord) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - throw new Error("인증이 필요합니다.") - } - - const validatedRecord = attachmentRecordSchema.parse(record) - const userId = Number(session.user.id) - - const result = await db.transaction(async (tx) => { - // 1. 시리얼 번호 생성 - const [countResult] = await tx - .select({ count: count() }) - .from(bRfqsAttachments) - .where(eq(bRfqsAttachments.rfqId, validatedRecord.rfqId)) - - const serialNo = (countResult.count + 1).toString().padStart(3, '0') - - // 2. 메인 첨부파일 레코드 생성 - const [attachment] = await tx - .insert(bRfqsAttachments) - .values({ - rfqId: validatedRecord.rfqId, - attachmentType: validatedRecord.attachmentType, - serialNo: serialNo, - currentRevision: "Rev.0", - description: validatedRecord.description, - createdBy: userId, - }) - .returning() - - // 3. 초기 리비전 (Rev.0) 생성 - const [revision] = await tx - .insert(bRfqAttachmentRevisions) - .values({ - attachmentId: attachment.id, - revisionNo: "Rev.0", - fileName: validatedRecord.fileName, - originalFileName: validatedRecord.originalFileName, - filePath: validatedRecord.filePath, - fileSize: validatedRecord.fileSize, - fileType: validatedRecord.fileType, - revisionComment: validatedRecord.revisionComment, - isLatest: true, - createdBy: userId, - }) - .returning() - - // 4. 메인 테이블의 latest_revision_id 업데이트 - await tx - .update(bRfqsAttachments) - .set({ - latestRevisionId: revision.id, - updatedAt: new Date(), - }) - .where(eq(bRfqsAttachments.id, attachment.id)) - - return { attachment, revision } - }) - - return { - success: true, - message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`, - attachment: result.attachment, - revision: result.revision, - } - - } catch (error) { - console.error("addRfqAttachmentRecord error:", error) - return { - success: false, - message: error instanceof Error ? error.message : "첨부파일 등록 중 오류가 발생했습니다.", - } - } -} - -// 리비전 추가 (기존 첨부파일에 새 버전 추가) -export async function addRevisionToAttachment( - attachmentId: number, - revisionData: { - fileName: string; - originalFileName: string; - filePath: string; - fileSize: number; - fileType: string; - revisionComment?: string; - }, -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) throw new Error('인증이 필요합니다.'); - - const userId = Number(session.user.id); - - // ──────────────────────────────────────────────────────────────────────────── - // 0. 첨부파일의 rfqId 사전 조회 (태그 무효화를 위해 필요) - // ──────────────────────────────────────────────────────────────────────────── - const [attInfo] = await db - .select({ rfqId: bRfqsAttachments.rfqId }) - .from(bRfqsAttachments) - .where(eq(bRfqsAttachments.id, attachmentId)) - .limit(1); - - if (!attInfo) throw new Error('첨부파일을 찾을 수 없습니다.'); - const rfqId = attInfo.rfqId; - - // ──────────────────────────────────────────────────────────────────────────── - // 1‑5. 리비전 트랜잭션 - // ──────────────────────────────────────────────────────────────────────────── - const newRevision = await db.transaction(async (tx) => { - // 1. 현재 최신 리비전 조회 - const [latestRevision] = await tx - .select({ revisionNo: bRfqAttachmentRevisions.revisionNo }) - .from(bRfqAttachmentRevisions) - .where( - and( - eq(bRfqAttachmentRevisions.attachmentId, attachmentId), - eq(bRfqAttachmentRevisions.isLatest, true), - ), - ); - - if (!latestRevision) throw new Error('기존 첨부파일을 찾을 수 없습니다.'); - - // 2. 새 리비전 번호 생성 - const currentNum = parseInt(latestRevision.revisionNo.replace('Rev.', '')); - const newRevisionNo = `Rev.${currentNum + 1}`; - - // 3. 기존 리비전 isLatest → false - await tx - .update(bRfqAttachmentRevisions) - .set({ isLatest: false }) - .where( - and( - eq(bRfqAttachmentRevisions.attachmentId, attachmentId), - eq(bRfqAttachmentRevisions.isLatest, true), - ), - ); - - // 4. 새 리비전 INSERT - const [inserted] = await tx - .insert(bRfqAttachmentRevisions) - .values({ - attachmentId, - revisionNo: newRevisionNo, - fileName: revisionData.fileName, - originalFileName: revisionData.originalFileName, - filePath: revisionData.filePath, - fileSize: revisionData.fileSize, - fileType: revisionData.fileType, - revisionComment: revisionData.revisionComment ?? `${newRevisionNo} 업데이트`, - isLatest: true, - createdBy: userId, - }) - .returning(); - - // 5. 메인 첨부파일 row 업데이트 - await tx - .update(bRfqsAttachments) - .set({ - currentRevision: newRevisionNo, - latestRevisionId: inserted.id, - updatedAt: new Date(), - }) - .where(eq(bRfqsAttachments.id, attachmentId)); - - return inserted; - }); - - - - return { - success: true, - message: `새 리비전(${newRevision.revisionNo})이 성공적으로 추가되었습니다.`, - revision: newRevision, - }; - } catch (error) { - console.error('addRevisionToAttachment error:', error); - return { - success: false, - message: error instanceof Error ? error.message : '리비전 추가 중 오류가 발생했습니다.', - }; - } -} - -// 특정 첨부파일의 모든 리비전 조회 -export async function getAttachmentRevisions(attachmentId: number) { - - try { - const revisions = await db - .select({ - id: bRfqAttachmentRevisions.id, - revisionNo: bRfqAttachmentRevisions.revisionNo, - fileName: bRfqAttachmentRevisions.fileName, - originalFileName: bRfqAttachmentRevisions.originalFileName, - filePath: bRfqAttachmentRevisions.filePath, - fileSize: bRfqAttachmentRevisions.fileSize, - fileType: bRfqAttachmentRevisions.fileType, - revisionComment: bRfqAttachmentRevisions.revisionComment, - isLatest: bRfqAttachmentRevisions.isLatest, - createdBy: bRfqAttachmentRevisions.createdBy, - createdAt: bRfqAttachmentRevisions.createdAt, - createdByName: users.name, - }) - .from(bRfqAttachmentRevisions) - .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id)) - .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId)) - .orderBy(desc(bRfqAttachmentRevisions.createdAt)) - - return { - success: true, - revisions, - } - } catch (error) { - console.error("getAttachmentRevisions error:", error) - return { - success: false, - message: "리비전 조회 중 오류가 발생했습니다.", - revisions: [], - } - } -} - - -// 첨부파일 삭제 (리비전 포함) -export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - throw new Error("인증이 필요합니다.") - } - - const validatedInput = deleteAttachmentsSchema.parse(input) - - const result = await db.transaction(async (tx) => { - // 1. 삭제할 첨부파일들의 정보 조회 (파일 경로 포함) - const attachmentsToDelete = await tx - .select({ - id: bRfqsAttachments.id, - rfqId: bRfqsAttachments.rfqId, - serialNo: bRfqsAttachments.serialNo, - }) - .from(bRfqsAttachments) - .where(inArray(bRfqsAttachments.id, validatedInput.ids)) - - if (attachmentsToDelete.length === 0) { - throw new Error("삭제할 첨부파일을 찾을 수 없습니다.") - } - - // 2. 관련된 모든 리비전 파일 경로 조회 - const revisionFilePaths = await tx - .select({ filePath: bRfqAttachmentRevisions.filePath }) - .from(bRfqAttachmentRevisions) - .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids)) - - // 3. DB에서 리비전 삭제 (CASCADE로 자동 삭제되지만 명시적으로) - await tx - .delete(bRfqAttachmentRevisions) - .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids)) - - // 4. DB에서 첨부파일 삭제 - await tx - .delete(bRfqsAttachments) - .where(inArray(bRfqsAttachments.id, validatedInput.ids)) - - // 5. 실제 파일 삭제 (비동기로 처리) - Promise.all( - revisionFilePaths.map(async ({ filePath }) => { - try { - if (filePath) { - const fullPath = `${process.cwd()}/public${filePath}` - await unlink(fullPath) - } - } catch (fileError) { - console.warn(`Failed to delete file: ${filePath}`, fileError) - } - }) - ).catch(error => { - console.error("Some files failed to delete:", error) - }) - - return { - deletedCount: attachmentsToDelete.length, - rfqIds: [...new Set(attachmentsToDelete.map(a => a.rfqId))], - attachments: attachmentsToDelete, - } - }) - - - return { - success: true, - message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`, - deletedAttachments: result.attachments, - } - - } catch (error) { - console.error("deleteRfqAttachments error:", error) - - return { - success: false, - message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.", - } - } -} - - - -//Initial RFQ - -export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) { - - try { - const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 조건 - let advancedWhere: SQL<unknown> | undefined = undefined; - if (input.filters && input.filters.length > 0) { - advancedWhere = filterColumns({ - table: initialRfqDetailView, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - }); - } - - // 2) 기본 필터 조건 - let basicWhere: SQL<unknown> | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: initialRfqDetailView, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - }); - } - - let rfqIdWhere: SQL<unknown> | undefined = undefined; - if (rfqId) { - rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId); - } - - - // 3) 글로벌 검색 조건 - let globalWhere: SQL<unknown> | undefined = undefined; - if (input.search) { - const s = `%${input.search}%`; - - const validSearchConditions: SQL<unknown>[] = []; - - const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s); - if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); - - const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s); - if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); - - const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s); - if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); - - const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s); - if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition); - - const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s); - if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition); - - const classificationCondition = ilike(initialRfqDetailView.classification, s); - if (classificationCondition) validSearchConditions.push(classificationCondition); - - const sparepartCondition = ilike(initialRfqDetailView.sparepart, s); - if (sparepartCondition) validSearchConditions.push(sparepartCondition); - - if (validSearchConditions.length > 0) { - globalWhere = or(...validSearchConditions); - } - } - - - // 5) 최종 WHERE 조건 생성 - const whereConditions: SQL<unknown>[] = []; - - if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); - if (globalWhere) whereConditions.push(globalWhere); - if (rfqIdWhere) whereConditions.push(rfqIdWhere); - - const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // 6) 전체 데이터 수 조회 - const totalResult = await db - .select({ count: count() }) - .from(initialRfqDetailView) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - - if (total === 0) { - return { data: [], pageCount: 0, total: 0 }; - } - - console.log(totalResult); - console.log(total); - - // 7) 정렬 및 페이징 처리된 데이터 조회 - const orderByColumns = input.sort.map((sort) => { - const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect; - return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]); - }); - - if (orderByColumns.length === 0) { - orderByColumns.push(desc(initialRfqDetailView.createdAt)); - } - - const initialRfqData = await db - .select() - .from(initialRfqDetailView) - .where(finalWhere) - .orderBy(...orderByColumns) - .limit(input.perPage) - .offset(offset); - - const pageCount = Math.ceil(total / input.perPage); - - return { data: initialRfqData, pageCount, total }; - } catch (err) { - console.error("Error in getInitialRfqDetail:", err); - return { data: [], pageCount: 0, total: 0 }; - } -} - -export async function getVendorsForSelection() { - try { - const vendorsData = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName, - vendorCode: vendors.vendorCode, - taxId: vendors.taxId, - country: vendors.country, - status: vendors.status, - }) - .from(vendors) - // .where( - // and( - // ne(vendors.status, "BLACKLISTED"), - // ne(vendors.status, "REJECTED") - // ) - // ) - .orderBy(vendors.vendorName) - - - return vendorsData.map(vendor => ({ - id: vendor.id, - vendorName: vendor.vendorName || "", - vendorCode: vendor.vendorCode || "", - country: vendor.country || "", - status: vendor.status, - })) - } catch (error) { - console.log("Error fetching vendors:", error) - throw new Error("Failed to fetch vendors") - } -} - -export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) { - try { - console.log('Incoming data:', data); - - const [newRecord] = await db - .insert(initialRfq) - .values({ - rfqId: data.rfqId, - vendorId: data.vendorId, - initialRfqStatus: data.initialRfqStatus, - dueDate: data.dueDate, - validDate: data.validDate, - incotermsCode: data.incotermsCode, - gtc: data.gtc, - gtcValidDate: data.gtcValidDate, - classification: data.classification, - sparepart: data.sparepart, - shortList: data.shortList, - returnYn: data.returnYn, - cpRequestYn: data.cpRequestYn, - prjectGtcYn: data.prjectGtcYn, - returnRevision: data.returnRevision, - }) - .returning() - - return { - success: true, - message: "초기 RFQ가 성공적으로 추가되었습니다.", - data: newRecord, - } - } catch (error) { - console.error("Error adding initial RFQ:", error) - return { - success: false, - message: "초기 RFQ 추가에 실패했습니다.", - error, - } - } -} - -export async function getIncotermsForSelection() { - try { - const incotermData = await db - .select({ - code: incoterms.code, - description: incoterms.description, - }) - .from(incoterms) - .orderBy(incoterms.code) - - return incotermData - - } catch (error) { - console.error("Error fetching incoterms:", error) - throw new Error("Failed to fetch incoterms") - } -} - -export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) { - unstable_noStore() - try { - const { ids } = removeInitialRfqsSchema.parse(input) - - await db.transaction(async (tx) => { - await tx.delete(initialRfq).where(inArray(initialRfq.id, ids)) - }) - - - return { - data: null, - error: null, - } - } catch (err) { - return { - data: null, - error: getErrorMessage(err), - } - } -} - -interface ModifyInitialRfqInput extends UpdateInitialRfqSchema { - id: number -} - -export async function modifyInitialRfq(input: ModifyInitialRfqInput) { - unstable_noStore() - try { - const { id, ...updateData } = input - - // validation - updateInitialRfqSchema.parse(updateData) - - await db.transaction(async (tx) => { - const existingRfq = await tx - .select() - .from(initialRfq) - .where(eq(initialRfq.id, id)) - .limit(1) - - if (existingRfq.length === 0) { - throw new Error("초기 RFQ를 찾을 수 없습니다.") - } - - await tx - .update(initialRfq) - .set({ - ...updateData, - // Convert empty strings to null for optional fields - incotermsCode: updateData.incotermsCode || null, - gtc: updateData.gtc || null, - gtcValidDate: updateData.gtcValidDate || null, - classification: updateData.classification || null, - sparepart: updateData.sparepart || null, - validDate: updateData.validDate || null, - updatedAt: new Date(), - }) - .where(eq(initialRfq.id, id)) - }) - - - return { - data: null, - error: null, - } - } catch (err) { - return { - data: null, - error: getErrorMessage(err), - } - } -} - - - - -// 이메일 발송용 데이터 타입 -interface EmailData { - rfqCode: string - projectName: string - projectCompany: string - projectFlag: string - projectSite: string - classification: string - incotermsCode: string - incotermsDescription: string - dueDate: string - validDate: string - sparepart: string - vendorName: string - picName: string - picEmail: string - warrantyPeriod: string - packageName: string - rfqRevision: number - emailType: string -} - -export async function sendBulkInitialRfqEmails(input: BulkEmailInput) { - unstable_noStore() - try { - - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - throw new Error("인증이 필요합니다.") - } - - const { initialRfqIds, language } = bulkEmailSchema.parse(input) - - // 1. 선택된 초기 RFQ들의 상세 정보 조회 - const initialRfqDetails = await db - .select({ - // initialRfqDetailView 필드들을 명시적으로 선택 - rfqId: initialRfqDetailView.rfqId, - rfqCode: initialRfqDetailView.rfqCode, - rfqStatus: initialRfqDetailView.rfqStatus, - initialRfqId: initialRfqDetailView.initialRfqId, - initialRfqStatus: initialRfqDetailView.initialRfqStatus, - vendorId: initialRfqDetailView.vendorId, - vendorCode: initialRfqDetailView.vendorCode, - vendorName: initialRfqDetailView.vendorName, - vendorCategory: initialRfqDetailView.vendorCategory, - vendorCountry: initialRfqDetailView.vendorCountry, - vendorBusinessSize: initialRfqDetailView.vendorBusinessSize, - dueDate: initialRfqDetailView.dueDate, - validDate: initialRfqDetailView.validDate, - incotermsCode: initialRfqDetailView.incotermsCode, - incotermsDescription: initialRfqDetailView.incotermsDescription, - shortList: initialRfqDetailView.shortList, - returnYn: initialRfqDetailView.returnYn, - cpRequestYn: initialRfqDetailView.cpRequestYn, - prjectGtcYn: initialRfqDetailView.prjectGtcYn, - returnRevision: initialRfqDetailView.returnRevision, - rfqRevision: initialRfqDetailView.rfqRevision, - gtc: initialRfqDetailView.gtc, - gtcValidDate: initialRfqDetailView.gtcValidDate, - classification: initialRfqDetailView.classification, - sparepart: initialRfqDetailView.sparepart, - createdAt: initialRfqDetailView.createdAt, - updatedAt: initialRfqDetailView.updatedAt, - // bRfqs에서 추가로 필요한 필드들 - picName: bRfqs.picName, - picCode: bRfqs.picCode, - packageName: bRfqs.packageName, - packageNo: bRfqs.packageNo, - projectCompany: bRfqs.projectCompany, - projectFlag: bRfqs.projectFlag, - projectSite: bRfqs.projectSite, - }) - .from(initialRfqDetailView) - .leftJoin(bRfqs, eq(initialRfqDetailView.rfqId, bRfqs.id)) - .where(inArray(initialRfqDetailView.initialRfqId, initialRfqIds)) - - if (initialRfqDetails.length === 0) { - return { - success: false, - message: "선택된 초기 RFQ를 찾을 수 없습니다.", - } - } - - // 2. 각 RFQ에 대한 첨부파일 조회 - const rfqIds = [...new Set(initialRfqDetails.map(rfq => rfq.rfqId))].filter((id): id is number => id !== null) - const attachments = await db - .select() - .from(bRfqsAttachments) - .where(inArray(bRfqsAttachments.rfqId, rfqIds)) - - // 3. 벤더 이메일 정보 조회 (모든 이메일 주소 포함) - const vendorIds = [...new Set(initialRfqDetails.map(rfq => rfq.vendorId))].filter((id): id is number => id !== null) - const vendorsWithAllEmails = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName, - email: vendors.email, - representativeEmail: vendors.representativeEmail, - // 연락처 이메일들을 JSON 배열로 집계 - contactEmails: sql<string[]>` - COALESCE( - (SELECT json_agg(contact_email) - FROM vendor_contacts - WHERE vendor_id = ${vendors.id} - AND contact_email IS NOT NULL - AND contact_email != '' - ), - '[]'::json - ) - `.as("contact_emails") - }) - .from(vendors) - .where(inArray(vendors.id, vendorIds)) - - // 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수 - function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] { - const emails: string[] = [] - - // 벤더 기본 이메일 - if (vendor.email) { - emails.push(vendor.email) - } - - // 대표자 이메일 - if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) { - emails.push(vendor.representativeEmail) - } - - // 연락처 이메일들 - if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) { - vendor.contactEmails.forEach(contactEmail => { - if (contactEmail && !emails.includes(contactEmail)) { - emails.push(contactEmail) - } - }) - } - - return emails.filter(email => email && email.trim() !== '') - } - - const results = [] - const errors = [] - - // 4. 각 초기 RFQ에 대해 처리 - for (const rfqDetail of initialRfqDetails) { - try { - // vendorId null 체크 - if (!rfqDetail.vendorId) { - errors.push(`벤더 ID가 없습니다: RFQ ID ${rfqDetail.initialRfqId}`) - continue - } - - // 해당 RFQ의 첨부파일들 - const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId) - - // 벤더 정보 - const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId) - if (!vendor) { - errors.push(`벤더 정보를 찾을 수 없습니다: RFQ ID ${rfqDetail.initialRfqId}`) - continue - } - - // 해당 벤더의 모든 이메일 주소 수집 - const vendorEmails = getAllVendorEmails(vendor) - - if (vendorEmails.length === 0) { - errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`) - continue - } - - // 5. 기존 vendorAttachmentResponses 조회하여 리비전 상태 확인 - const currentRfqRevision = rfqDetail.rfqRevision || 0 - let emailType: "NEW" | "RESEND" | "REVISION" = "NEW" - let revisionToUse = currentRfqRevision - - // 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용) - if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) { - const existingResponses = await db - .select() - .from(vendorAttachmentResponses) - .where( - and( - eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId), - eq(vendorAttachmentResponses.rfqType, "INITIAL"), - eq(vendorAttachmentResponses.rfqRecordId, rfqDetail.initialRfqId) - ) - ) - - if (existingResponses.length > 0) { - // 기존 응답이 있음 - const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0") - - if (currentRfqRevision > existingRevision) { - // RFQ 리비전이 올라감 → 리비전 업데이트 - emailType = "REVISION" - revisionToUse = currentRfqRevision - } else { - // 동일하거나 낮음 → 재전송 - emailType = "RESEND" - revisionToUse = existingRevision - } - } else { - // 기존 응답이 없음 → 신규 전송 - emailType = "NEW" - revisionToUse = currentRfqRevision - } - } - - // 6. vendorAttachmentResponses 레코드 생성/업데이트 - for (const attachment of rfqAttachments) { - const existingResponse = await db - .select() - .from(vendorAttachmentResponses) - .where( - and( - eq(vendorAttachmentResponses.attachmentId, attachment.id), - eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId), - eq(vendorAttachmentResponses.rfqType, "INITIAL") - ) - ) - .limit(1) - - if (existingResponse.length === 0) { - // 새 응답 레코드 생성 - await db.insert(vendorAttachmentResponses).values({ - attachmentId: attachment.id, - vendorId: rfqDetail.vendorId, - rfqType: "INITIAL", - rfqRecordId: rfqDetail.initialRfqId, - responseStatus: "NOT_RESPONDED", - currentRevision: `Rev.${revisionToUse}`, - requestedAt: new Date(), - }) - } else { - // 기존 레코드 업데이트 - await db - .update(vendorAttachmentResponses) - .set({ - currentRevision: `Rev.${revisionToUse}`, - requestedAt: new Date(), - // 리비전 업데이트인 경우 응답 상태 초기화 - responseStatus: emailType === "REVISION" ? "NOT_RESPONDED" : existingResponse[0].responseStatus, - }) - .where(eq(vendorAttachmentResponses.id, existingResponse[0].id)) - } - - } - - const formatDateSafely = (date: Date | string | null | undefined): string => { - if (!date) return "" - try { - // Date 객체로 변환하고 포맷팅 - const dateObj = new Date(date) - // 유효한 날짜인지 확인 - if (isNaN(dateObj.getTime())) return "" - - return dateObj.toLocaleDateString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - }) - } catch (error) { - console.error("Date formatting error:", error) - return "" - } - } - - // 7. 이메일 발송 - const emailData: EmailData = { - name: vendor.vendorName, - rfqCode: rfqDetail.rfqCode || "", - projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용 - projectCompany: rfqDetail.projectCompany || "", - projectFlag: rfqDetail.projectFlag || "", - projectSite: rfqDetail.projectSite || "", - classification: rfqDetail.classification || "ABS", - incotermsCode: rfqDetail.incotermsCode || "FOB", - incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port", - dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "", - validDate: rfqDetail.validDate ? formatDateSafely(rfqDetail.validDate) : "", - sparepart: rfqDetail.sparepart || "One(1) year operational spare parts", - vendorName: vendor.vendorName, - picName: session.user.name || rfqDetail.picName || "Procurement Manager", - picEmail: session.user.email || "procurement@samsung.com", - warrantyPeriod: "Refer to commercial package attached", - packageName: rfqDetail.packageName || "", - rfqRevision: revisionToUse, // 리비전 정보 추가 - emailType: emailType, // 이메일 타입 추가 - } - - // 이메일 제목 생성 (타입에 따라 다르게) - let emailSubject = "" - const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : "" - - switch (emailType) { - case "NEW": - emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}` - break - case "RESEND": - emailSubject = `[SHI RFQ - RESEND] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}` - break - case "REVISION": - emailSubject = `[SHI RFQ - REVISED] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}` - break - } - - // nodemailer로 모든 이메일 주소에 한번에 발송 - await sendEmail({ - to: vendorEmails.join(", "), // 콤마+공백으로 구분 - subject: emailSubject, - template: "initial-rfq-invitation", // hbs 템플릿 파일명 - context: { - ...emailData, - language, - } - }) - - // 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용) - if (rfqDetail.initialRfqId && rfqDetail.rfqId) { - // Promise.all로 두 테이블 동시 업데이트 - await Promise.all([ - // initialRfq 테이블 업데이트 - db - .update(initialRfq) - .set({ - initialRfqStatus: "Init. RFQ Sent", - updatedAt: new Date(), - }) - .where(eq(initialRfq.id, rfqDetail.initialRfqId)), - - // bRfqs 테이블 status도 함께 업데이트 - db - .update(bRfqs) - .set({ - status: "Init. RFQ Sent", - // updatedBy: session.user.id, - updatedAt: new Date(), - }) - .where(eq(bRfqs.id, rfqDetail.rfqId)) - ]); - } - - results.push({ - initialRfqId: rfqDetail.initialRfqId, - vendorName: vendor.vendorName, - vendorEmails: vendorEmails, // 발송된 모든 이메일 주소 기록 - emailCount: vendorEmails.length, - emailType: emailType, - rfqRevision: revisionToUse, - success: true, - }) - - } catch (error) { - console.error(`Error processing RFQ ${rfqDetail.initialRfqId}:`, error) - errors.push(`RFQ ${rfqDetail.initialRfqId} 처리 중 오류: ${getErrorMessage(error)}`) - } - } - - - - return { - success: true, - message: `${results.length}개의 RFQ 이메일이 발송되었습니다.`, - results, - errors: errors.length > 0 ? errors : undefined, - } - - } catch (err) { - console.error("Bulk email error:", err) - return { - success: false, - message: getErrorMessage(err), - } - } -} - -// 개별 RFQ 이메일 재발송 -export async function resendInitialRfqEmail(initialRfqId: number) { - unstable_noStore() - try { - const result = await sendBulkInitialRfqEmails({ - initialRfqIds: [initialRfqId], - language: "en", - }) - - return result - } catch (err) { - return { - success: false, - message: getErrorMessage(err), - } - } -} - -export type VendorResponseDetail = VendorAttachmentResponse & { - attachment: { - id: number; - attachmentType: string; - serialNo: string; - description: string | null; - currentRevision: string; - }; - vendor: { - id: number; - vendorCode: string; - vendorName: string; - country: string | null; - businessSize: string | null; - }; - rfq: { - id: number; - rfqCode: string | null; - description: string | null; - status: string; - dueDate: Date; - }; -}; - -export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) { - try { - // 페이지네이션 설정 - const page = input.page || 1; - const perPage = input.perPage || 10; - const offset = (page - 1) * perPage; - - // 기본 조건 - let whereConditions = []; - - // 벤더 ID 조건 - if (vendorId) { - whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId))); - } - - // RFQ 타입 조건 - // if (input.rfqType !== "ALL") { - // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType)); - // } - - // 날짜 범위 조건 - if (input.from && input.to) { - whereConditions.push( - and( - gte(vendorAttachmentResponses.requestedAt, new Date(input.from)), - lte(vendorAttachmentResponses.requestedAt, new Date(input.to)) - ) - ); - } - - const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // 그룹핑된 응답 요약 데이터 조회 - const groupedResponses = await db - .select({ - vendorId: vendorAttachmentResponses.vendorId, - rfqRecordId: vendorAttachmentResponses.rfqRecordId, - rfqType: vendorAttachmentResponses.rfqType, - - // 통계 계산 (조건부 COUNT 수정) - totalAttachments: count(), - respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`, - pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`, - revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`, - waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`, - - // 날짜 정보 - requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`, - lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`, - - // 코멘트 여부 - hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`, - }) - .from(vendorAttachmentResponses) - .where(baseWhere) - .groupBy( - vendorAttachmentResponses.vendorId, - vendorAttachmentResponses.rfqRecordId, - vendorAttachmentResponses.rfqType - ) - .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`)) - .offset(offset) - .limit(perPage); - - // 벤더 정보와 RFQ 정보를 별도로 조회 - const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))]; - const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))]; - - // 벤더 정보 조회 - const vendorsData = await db.query.vendors.findMany({ - where: or(...vendorIds.map(id => eq(vendors.id, id))), - columns: { - id: true, - vendorCode: true, - vendorName: true, - country: true, - businessSize: true, - } - }); - - // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두) - const [initialRfqs] = await Promise.all([ - db.query.initialRfq.findMany({ - where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))), - with: { - rfq: { - columns: { - id: true, - rfqCode: true, - description: true, - status: true, - dueDate: true, - } - } - } - }) - - ]); - - // 데이터 조합 및 변환 - const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => { - const vendor = vendorsData.find(v => v.id === response.vendorId); - - let rfqInfo = null; - if (response.rfqType === "INITIAL") { - const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId); - rfqInfo = initialRfq?.rfq || null; - } - - // 응답률 계산 - const responseRate = Number(response.totalAttachments) > 0 - ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100) - : 0; - - // 완료율 계산 (응답완료 + 포기) - const completionRate = Number(response.totalAttachments) > 0 - ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100) - : 0; - - // 전체 상태 결정 - let overallStatus: ResponseStatus = "NOT_RESPONDED"; - if (Number(response.revisionRequestedCount) > 0) { - overallStatus = "REVISION_REQUESTED"; - } else if (completionRate === 100) { - overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED"; - } else if (Number(response.respondedCount) > 0) { - overallStatus = "RESPONDED"; // 부분 응답 - } - - return { - id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`, - vendorId: response.vendorId, - rfqRecordId: response.rfqRecordId, - rfqType: response.rfqType, - rfq: rfqInfo, - vendor: vendor || null, - totalAttachments: Number(response.totalAttachments), - respondedCount: Number(response.respondedCount), - pendingCount: Number(response.pendingCount), - revisionRequestedCount: Number(response.revisionRequestedCount), - waivedCount: Number(response.waivedCount), - responseRate, - completionRate, - overallStatus, - requestedAt: response.requestedAt, - lastRespondedAt: response.lastRespondedAt, - hasComments: response.hasComments, - }; - }); - - // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식 - const totalCountResult = await db - .select({ - totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))` - }) - .from(vendorAttachmentResponses) - .where(baseWhere); - - const totalCount = Number(totalCountResult[0].totalCount); - const pageCount = Math.ceil(totalCount / perPage); - - return { - data: transformedResponses, - pageCount, - totalCount - }; - - } catch (err) { - console.error("getVendorRfqResponses 에러:", err); - return { data: [], pageCount: 0, totalCount: 0 }; - } -} -/** - * 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용) - */ -export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) { - try { - // 해당 RFQ의 모든 첨부파일 응답 조회 - const responses = await db.query.vendorAttachmentResponses.findMany({ - where: and( - eq(vendorAttachmentResponses.vendorId, Number(vendorId)), - eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)), - ), - with: { - attachment: { - with: { - rfq: { - columns: { - id: true, - rfqCode: true, - description: true, - status: true, - dueDate: true, - // 추가 정보 - picCode: true, - picName: true, - EngPicName: true, - packageNo: true, - packageName: true, - projectId: true, - projectCompany: true, - projectFlag: true, - projectSite: true, - remark: true, - }, - with: { - project: { - columns: { - id: true, - code: true, - name: true, - type: true, - } - } - } - } - } - }, - vendor: { - columns: { - id: true, - vendorCode: true, - vendorName: true, - country: true, - businessSize: true, - } - }, - responseAttachments: true, - }, - orderBy: [asc(vendorAttachmentResponses.attachmentId)] - }); - - return { - data: responses, - rfqInfo: responses[0]?.attachment?.rfq || null, - vendorInfo: responses[0]?.vendor || null, - }; - - } catch (err) { - console.error("getRfqAttachmentResponses 에러:", err); - return { data: [], rfqInfo: null, vendorInfo: null }; - } -} - -export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) { - try { - const initial: Record<ResponseStatus, number> = { - NOT_RESPONDED: 0, - RESPONDED: 0, - REVISION_REQUESTED: 0, - WAIVED: 0, - }; - - // 조건 설정 - let whereConditions = []; - - // 벤더 ID 조건 - if (vendorId) { - whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId))); - } - - // RFQ ID 조건 - if (rfqId) { - const attachmentIds = await db - .select({ id: bRfqsAttachments.id }) - .from(bRfqsAttachments) - .where(eq(bRfqsAttachments.rfqId, Number(rfqId))); - - if (attachmentIds.length > 0) { - whereConditions.push( - or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id))) - ); - } - } - - // RFQ 타입 조건 - if (rfqType) { - whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType)); - } - - const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // 상태별 그룹핑 쿼리 - const rows = await db - .select({ - status: vendorAttachmentResponses.responseStatus, - count: count(), - }) - .from(vendorAttachmentResponses) - .where(whereCondition) - .groupBy(vendorAttachmentResponses.responseStatus); - - // 결과 처리 - const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => { - if (status) { - acc[status as ResponseStatus] = Number(count); - } - return acc; - }, initial); - - return result; - } catch (err) { - console.error("getVendorResponseStatusCounts 에러:", err); - return {} as Record<ResponseStatus, number>; - } -} - -/** - * RFQ별 벤더 응답 요약 조회 - */ -export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) { - - try { - // RFQ의 첨부파일 목록 조회 (relations 사용) - const attachments = await db.query.bRfqsAttachments.findMany({ - where: eq(bRfqsAttachments.rfqId, Number(rfqId)), - columns: { - id: true, - attachmentType: true, - serialNo: true, - description: true, - } - }); - - if (attachments.length === 0) { - return { - totalAttachments: 0, - totalVendors: 0, - responseRate: 0, - completionRate: 0, - statusCounts: {} as Record<ResponseStatus, number> - }; - } - - // 조건 설정 - let whereConditions = [ - or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id))) - ]; - - if (rfqType) { - whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType)); - } - - const whereCondition = and(...whereConditions); - - // 벤더 수 및 응답 통계 조회 - const [vendorStats, statusCounts] = await Promise.all([ - // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정) - db - .select({ - totalVendors: count(), - respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`, - completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`, - }) - .from(vendorAttachmentResponses) - .where(whereCondition), - - // 상태별 개수 - db - .select({ - status: vendorAttachmentResponses.responseStatus, - count: count(), - }) - .from(vendorAttachmentResponses) - .where(whereCondition) - .groupBy(vendorAttachmentResponses.responseStatus) - ]); - - const stats = vendorStats[0]; - const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => { - if (status) { - acc[status as ResponseStatus] = Number(count); - } - return acc; - }, { - NOT_RESPONDED: 0, - RESPONDED: 0, - REVISION_REQUESTED: 0, - WAIVED: 0, - }); - - const responseRate = stats.totalVendors > 0 - ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100) - : 0; - - const completionRate = stats.totalVendors > 0 - ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100) - : 0; - - return { - totalAttachments: attachments.length, - totalVendors: Number(stats.totalVendors), - responseRate, - completionRate, - statusCounts: statusCountsMap - }; - - } catch (err) { - console.error("getRfqResponseSummary 에러:", err); - return { - totalAttachments: 0, - totalVendors: 0, - responseRate: 0, - completionRate: 0, - statusCounts: {} as Record<ResponseStatus, number> - }; - } -} - -/** - * 벤더별 응답 진행률 조회 - */ -export async function getVendorResponseProgress(vendorId: string) { - - try { - let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))]; - - const whereCondition = and(...whereConditions); - - const progress = await db - .select({ - totalRequests: count(), - responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`, - pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`, - revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`, - waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`, - }) - .from(vendorAttachmentResponses) - .where(whereCondition); - console.log(progress, "progress") - - const stats = progress[0]; - const responseRate = Number(stats.totalRequests) > 0 - ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100) - : 0; - - const completionRate = Number(stats.totalRequests) > 0 - ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100) - : 0; - - return { - totalRequests: Number(stats.totalRequests), - responded: Number(stats.responded), - pending: Number(stats.pending), - revisionRequested: Number(stats.revisionRequested), - waived: Number(stats.waived), - responseRate, - completionRate, - }; - - } catch (err) { - console.error("getVendorResponseProgress 에러:", err); - return { - totalRequests: 0, - responded: 0, - pending: 0, - revisionRequested: 0, - waived: 0, - responseRate: 0, - completionRate: 0, - }; - } -} - - -export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, rfqRecordId: string) { - try { - // 1. 벤더 응답 상세 정보 조회 (뷰 사용) - const responses = await db - .select() - .from(vendorResponseDetailView) - .where( - and( - eq(vendorResponseDetailView.vendorId, Number(vendorId)), - eq(vendorResponseDetailView.rfqRecordId, Number(rfqRecordId)) - ) - ) - .orderBy(asc(vendorResponseDetailView.attachmentId)); - - // 2. RFQ 진행 현황 요약 조회 - const progressSummaryResult = await db - .select() - .from(rfqProgressSummaryView) - .where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0)) - .limit(1); - - const progressSummary = progressSummaryResult[0] || null; - - // 3. 각 응답의 첨부파일 리비전 히스토리 조회 - const attachmentHistories = await Promise.all( - responses.map(async (response) => { - const history = await db - .select() - .from(attachmentRevisionHistoryView) - .where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId)) - .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt)); - - return { - attachmentId: response.attachmentId, - revisions: history - }; - }) - ); - - // 4. 벤더 응답 파일들 조회 (향상된 정보 포함) - const responseFiles = await Promise.all( - responses.map(async (response) => { - const files = await db - .select() - .from(vendorResponseAttachmentsEnhanced) - .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId)) - .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt)); - - return { - responseId: response.responseId, - files: files - }; - }) - ); - - // 5. 데이터 변환 및 통합 - const enhancedResponses = responses.map(response => { - const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId); - const responseFileData = responseFiles.find(f => f.responseId === response.responseId); - - return { - ...response, - // 첨부파일 정보에 리비전 히스토리 추가 - attachment: { - id: response.attachmentId, - attachmentType: response.attachmentType, - serialNo: response.serialNo, - description: response.attachmentDescription, - currentRevision: response.currentRevision, - // 모든 리비전 정보 - revisions: attachmentHistory?.revisions?.map(rev => ({ - id: rev.clientRevisionId, - revisionNo: rev.clientRevisionNo, - fileName: rev.clientFileName, - originalFileName: rev.clientFileName, - filePath: rev.clientFilePath, // 파일 경로 추가 - fileSize: rev.clientFileSize, - revisionComment: rev.clientRevisionComment, - createdAt: rev.clientRevisionCreatedAt?.toISOString() || new Date().toISOString(), - isLatest: rev.isLatestClientRevision - })) || [] - }, - // 벤더 응답 파일들 - responseAttachments: responseFileData?.files?.map(file => ({ - id: file.responseAttachmentId, - fileName: file.fileName, - originalFileName: file.originalFileName, - filePath: file.filePath, - fileSize: file.fileSize, - description: file.description, - uploadedAt: file.uploadedAt?.toISOString() || new Date().toISOString(), - isLatestResponseFile: file.isLatestResponseFile, - fileSequence: file.fileSequence - })) || [], - // 리비전 분석 정보 - isVersionMatched: response.isVersionMatched, - versionLag: response.versionLag, - needsUpdate: response.needsUpdate, - hasMultipleRevisions: response.hasMultipleRevisions, - - // 새로 추가된 필드들 - revisionRequestComment: response.revisionRequestComment, - revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null, - }; - }); - - // RFQ 기본 정보 (첫 번째 응답에서 추출) - const rfqInfo = responses[0] ? { - id: responses[0].rfqId, - rfqCode: responses[0].rfqCode, - // 추가 정보는 기존 방식대로 별도 조회 필요 - description: "", - dueDate: progressSummary?.dueDate || new Date(), - status: progressSummary?.rfqStatus || "DRAFT", - // ... 기타 필요한 정보들 - } : null; - - // 벤더 정보 - const vendorInfo = responses[0] ? { - id: responses[0].vendorId, - vendorCode: responses[0].vendorCode, - vendorName: responses[0].vendorName, - country: responses[0].vendorCountry, - } : null; - - // 통계 정보 계산 - const calculateStats = (responses: typeof enhancedResponses) => { - const total = responses.length; - const responded = responses.filter(r => r.responseStatus === "RESPONDED").length; - const pending = responses.filter(r => r.responseStatus === "NOT_RESPONDED").length; - const revisionRequested = responses.filter(r => r.responseStatus === "REVISION_REQUESTED").length; - const waived = responses.filter(r => r.responseStatus === "WAIVED").length; - const versionMismatch = responses.filter(r => r.effectiveStatus === "VERSION_MISMATCH").length; - const upToDate = responses.filter(r => r.effectiveStatus === "UP_TO_DATE").length; - - return { - total, - responded, - pending, - revisionRequested, - waived, - versionMismatch, - upToDate, - responseRate: total > 0 ? Math.round((responded / total) * 100) : 0, - completionRate: total > 0 ? Math.round(((responded + waived) / total) * 100) : 0, - versionMatchRate: responded > 0 ? Math.round((upToDate / responded) * 100) : 100 - }; - }; - - const statistics = calculateStats(enhancedResponses); - - return { - data: enhancedResponses, - rfqInfo, - vendorInfo, - statistics, - progressSummary: progressSummary ? { - totalAttachments: progressSummary.totalAttachments, - attachmentsWithMultipleRevisions: progressSummary.attachmentsWithMultipleRevisions, - totalClientRevisions: progressSummary.totalClientRevisions, - totalResponseFiles: progressSummary.totalResponseFiles, - daysToDeadline: progressSummary.daysToDeadline - } : null - }; - - } catch (err) { - console.error("getRfqAttachmentResponsesWithRevisions 에러:", err); - return { - data: [], - rfqInfo: null, - vendorInfo: null, - statistics: { - total: 0, - responded: 0, - pending: 0, - revisionRequested: 0, - waived: 0, - versionMismatch: 0, - upToDate: 0, - responseRate: 0, - completionRate: 0, - versionMatchRate: 100 - }, - progressSummary: null - }; - } -} - -// 첨부파일 리비전 히스토리 조회 -export async function getAttachmentRevisionHistory(attachmentId: number) { - - try { - const history = await db - .select() - .from(attachmentRevisionHistoryView) - .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId)) - .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt)); - - return history; - } catch (err) { - console.error("getAttachmentRevisionHistory 에러:", err); - return []; - } -} - -// RFQ 전체 진행 현황 조회 -export async function getRfqProgressSummary(rfqId: number) { - try { - const summaryResult = await db - .select() - .from(rfqProgressSummaryView) - .where(eq(rfqProgressSummaryView.rfqId, rfqId)) - .limit(1); - - return summaryResult[0] || null; - } catch (err) { - console.error("getRfqProgressSummary 에러:", err); - return null; - } -} - -// 벤더 응답 파일 상세 조회 (향상된 정보 포함) -export async function getVendorResponseFiles(vendorResponseId: number) { - try { - const files = await db - .select() - .from(vendorResponseAttachmentsEnhanced) - .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId)) - .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt)); - - return files; - } catch (err) { - console.error("getVendorResponseFiles 에러:", err); - return []; - } -} - - -// 타입 정의 확장 -export type EnhancedVendorResponse = { - // 기본 응답 정보 - responseId: number; - rfqId: number; - rfqCode: string; - rfqType: "INITIAL" | "FINAL"; - rfqRecordId: number; - - // 첨부파일 정보 - attachmentId: number; - attachmentType: string; - serialNo: string; - attachmentDescription?: string; - - // 벤더 정보 - vendorId: number; - vendorCode: string; - vendorName: string; - vendorCountry: string; - - // 응답 상태 - responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED"; - currentRevision: string; - respondedRevision?: string; - effectiveStatus: string; - - // 코멘트 관련 필드들 (새로 추가된 필드 포함) - responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트 - vendorComment?: string; // 벤더 내부 메모 - revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가) - - // 날짜 관련 필드들 (새로 추가된 필드 포함) - requestedAt: string; - respondedAt?: string; - revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가) - - // 발주처 최신 리비전 정보 - latestClientRevisionNo?: string; - latestClientFileName?: string; - latestClientFileSize?: number; - latestClientRevisionComment?: string; - - // 리비전 분석 - isVersionMatched: boolean; - versionLag?: number; - needsUpdate: boolean; - hasMultipleRevisions: boolean; - - // 응답 파일 통계 - totalResponseFiles: number; - latestResponseFileName?: string; - latestResponseFileSize?: number; - latestResponseUploadedAt?: string; - - // 첨부파일 정보 (리비전 히스토리 포함) - attachment: { - id: number; - attachmentType: string; - serialNo: string; - description?: string; - currentRevision: string; - revisions: Array<{ - id: number; - revisionNo: string; - fileName: string; - originalFileName: string; - filePath?: string; - fileSize?: number; - revisionComment?: string; - createdAt: string; - isLatest: boolean; - }>; - }; - - // 벤더 응답 파일들 - responseAttachments: Array<{ - id: number; - fileName: string; - originalFileName: string; - filePath: string; - fileSize?: number; - description?: string; - uploadedAt: string; - isLatestResponseFile: boolean; - fileSequence: number; - }>; -}; - - -export async function requestRevision( - responseId: number, - revisionReason: string -): Promise<RequestRevisionResult> { - try { - // 입력값 검증 - - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - throw new Error("인증이 필요합니다.") - } - const validatedData = requestRevisionSchema.parse({ - responseId, - revisionReason, - }); - - // 현재 응답 정보 조회 - const existingResponse = await db - .select() - .from(vendorAttachmentResponses) - .where(eq(vendorAttachmentResponses.id, validatedData.responseId)) - .limit(1); - - if (existingResponse.length === 0) { - return { - success: false, - message: "해당 응답을 찾을 수 없습니다", - error: "NOT_FOUND", - }; - } - - const response = existingResponse[0]; - - // 응답 상태 확인 (이미 응답되었거나 포기된 상태에서만 수정 요청 가능) - if (response.responseStatus !== "RESPONDED") { - return { - success: false, - message: "응답된 상태의 항목에서만 수정을 요청할 수 있습니다", - error: "INVALID_STATUS", - }; - } - - // 응답 상태를 REVISION_REQUESTED로 업데이트 - const updateResult = await db - .update(vendorAttachmentResponses) - .set({ - responseStatus: "REVISION_REQUESTED", - revisionRequestComment: validatedData.revisionReason, // 새로운 필드에 저장 - revisionRequestedAt: new Date(), // 수정 요청 시간 저장 - updatedAt: new Date(), - updatedBy: Number(session.user.id), - }) - .where(eq(vendorAttachmentResponses.id, validatedData.responseId)) - .returning(); - - if (updateResult.length === 0) { - return { - success: false, - message: "수정 요청 업데이트에 실패했습니다", - error: "UPDATE_FAILED", - }; - } - - return { - success: true, - message: "수정 요청이 성공적으로 전송되었습니다", - }; - - } catch (error) { - console.error("Request revision server action error:", error); - return { - success: false, - message: "내부 서버 오류가 발생했습니다", - error: "INTERNAL_ERROR", - }; - } -} - - - -export async function shortListConfirm(input: ShortListConfirmInput) { - try { - const validatedInput = shortListConfirmSchema.parse(input) - const { rfqId, selectedVendorIds, rejectedVendorIds } = validatedInput - - // 1. RFQ 정보 조회 - const rfqInfo = await db - .select() - .from(bRfqs) - .where(eq(bRfqs.id, rfqId)) - .limit(1) - - if (!rfqInfo.length) { - return { success: false, message: "RFQ를 찾을 수 없습니다." } - } - - const rfq = rfqInfo[0] - - // 2. 기존 initial_rfq에서 필요한 정보 조회 - const initialRfqData = await db - .select({ - id: initialRfq.id, - vendorId: initialRfq.vendorId, - dueDate: initialRfq.dueDate, - validDate: initialRfq.validDate, - incotermsCode: initialRfq.incotermsCode, - gtc: initialRfq.gtc, - gtcValidDate: initialRfq.gtcValidDate, - classification: initialRfq.classification, - sparepart: initialRfq.sparepart, - cpRequestYn: initialRfq.cpRequestYn, - prjectGtcYn: initialRfq.prjectGtcYn, - returnRevision: initialRfq.returnRevision, - }) - .from(initialRfq) - .where( - and( - eq(initialRfq.rfqId, rfqId), - inArray(initialRfq.vendorId, [...selectedVendorIds, ...rejectedVendorIds]) - ) - ) - - if (!initialRfqData.length) { - return { success: false, message: "해당 RFQ의 초기 RFQ 데이터를 찾을 수 없습니다." } - } - - // 3. 탈락된 벤더들의 이메일 정보 조회 - let rejectedVendorEmails: Array<{ - vendorId: number - vendorName: string - email: string - }> = [] - - if (rejectedVendorIds.length > 0) { - rejectedVendorEmails = await db - .select({ - vendorId: vendors.id, - vendorName: vendors.vendorName, - email: vendors.email, - }) - .from(vendors) - .where(inArray(vendors.id, rejectedVendorIds)) - } - - await db.transaction(async (tx) => { - // 4. 선택된 벤더들에 대해 final_rfq 테이블에 데이터 생성/업데이트 - for (const vendorId of selectedVendorIds) { - const initialData = initialRfqData.find(data => data.vendorId === vendorId) - - if (initialData) { - // 기존 final_rfq 레코드 확인 - const existingFinalRfq = await tx - .select() - .from(finalRfq) - .where( - and( - eq(finalRfq.rfqId, rfqId), - eq(finalRfq.vendorId, vendorId) - ) - ) - .limit(1) - - if (existingFinalRfq.length > 0) { - // 기존 레코드 업데이트 - await tx - .update(finalRfq) - .set({ - shortList: true, - finalRfqStatus: "DRAFT", - dueDate: initialData.dueDate, - validDate: initialData.validDate, - incotermsCode: initialData.incotermsCode, - gtc: initialData.gtc, - gtcValidDate: initialData.gtcValidDate, - classification: initialData.classification, - sparepart: initialData.sparepart, - cpRequestYn: initialData.cpRequestYn, - prjectGtcYn: initialData.prjectGtcYn, - updatedAt: new Date(), - }) - .where(eq(finalRfq.id, existingFinalRfq[0].id)) - } else { - // 새 레코드 생성 - await tx - .insert(finalRfq) - .values({ - rfqId, - vendorId, - finalRfqStatus: "DRAFT", - dueDate: initialData.dueDate, - validDate: initialData.validDate, - incotermsCode: initialData.incotermsCode, - gtc: initialData.gtc, - gtcValidDate: initialData.gtcValidDate, - classification: initialData.classification, - sparepart: initialData.sparepart, - shortList: true, - returnYn: false, - cpRequestYn: initialData.cpRequestYn, - prjectGtcYn: initialData.prjectGtcYn, - returnRevision: 0, - currency: "KRW", - taxCode: "VV", - deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 - firsttimeYn: true, - materialPriceRelatedYn: false, - }) - } - } - } - - // 5. 탈락된 벤더들에 대해서는 shortList: false로 설정 (있다면) - if (rejectedVendorIds.length > 0) { - // 기존에 final_rfq에 있는 탈락 벤더들은 shortList를 false로 업데이트 - await tx - .update(finalRfq) - .set({ - shortList: false, - updatedAt: new Date(), - }) - .where( - and( - eq(finalRfq.rfqId, rfqId), - inArray(finalRfq.vendorId, rejectedVendorIds) - ) - ) - } - - // 6. initial_rfq의 shortList 필드도 업데이트 - if (selectedVendorIds.length > 0) { - await tx - .update(initialRfq) - .set({ - shortList: true, - updatedAt: new Date(), - }) - .where( - and( - eq(initialRfq.rfqId, rfqId), - inArray(initialRfq.vendorId, selectedVendorIds) - ) - ) - } - - if (rejectedVendorIds.length > 0) { - await tx - .update(initialRfq) - .set({ - shortList: false, - updatedAt: new Date(), - }) - .where( - and( - eq(initialRfq.rfqId, rfqId), - inArray(initialRfq.vendorId, rejectedVendorIds) - ) - ) - } - }) - - // 7. 탈락된 벤더들에게 Letter of Regret 이메일 발송 - const emailErrors: string[] = [] - - for (const rejectedVendor of rejectedVendorEmails) { - if (rejectedVendor.email) { - try { - await sendEmail({ - to: rejectedVendor.email, - subject: `Letter of Regret - RFQ ${rfq.rfqCode}`, - template: "letter-of-regret", - context: { - rfqCode: rfq.rfqCode, - vendorName: rejectedVendor.vendorName, - projectTitle: rfq.projectTitle || "Project", - dateTime: new Date().toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - }), - companyName: "Your Company Name", // 실제 회사명으로 변경 - language: "ko", - }, - }) - } catch (error) { - console.error(`Email sending failed for vendor ${rejectedVendor.vendorName}:`, error) - emailErrors.push(`${rejectedVendor.vendorName}에게 이메일 발송 실패`) - } - } - } - - // 8. 페이지 revalidation - revalidatePath(`/evcp/a-rfq/${rfqId}`) - revalidatePath(`/evcp/b-rfq/${rfqId}`) - - const successMessage = `Short List가 확정되었습니다. (선택: ${selectedVendorIds.length}개, 탈락: ${rejectedVendorIds.length}개)` - - return { - success: true, - message: successMessage, - errors: emailErrors.length > 0 ? emailErrors : undefined, - data: { - selectedCount: selectedVendorIds.length, - rejectedCount: rejectedVendorIds.length, - emailsSent: rejectedVendorEmails.length - emailErrors.length, - }, - } - - } catch (error) { - console.error("Short List confirm error:", error) - return { - success: false, - message: "Short List 확정 중 오류가 발생했습니다.", - } - } -} - -export async function getFinalRfqDetail(input: GetFinalRfqDetailSchema, rfqId?: number) { - - try { - const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 조건 - let advancedWhere: SQL<unknown> | undefined = undefined; - if (input.filters && input.filters.length > 0) { - advancedWhere = filterColumns({ - table: finalRfqDetailView, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - }); - } - - // 2) 기본 필터 조건 - let basicWhere: SQL<unknown> | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: finalRfqDetailView, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - }); - } - - let rfqIdWhere: SQL<unknown> | undefined = undefined; - if (rfqId) { - rfqIdWhere = eq(finalRfqDetailView.rfqId, rfqId); - } - - // 3) 글로벌 검색 조건 - let globalWhere: SQL<unknown> | undefined = undefined; - if (input.search) { - const s = `%${input.search}%`; - - const validSearchConditions: SQL<unknown>[] = []; - - const rfqCodeCondition = ilike(finalRfqDetailView.rfqCode, s); - if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); - - const vendorNameCondition = ilike(finalRfqDetailView.vendorName, s); - if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); - - const vendorCodeCondition = ilike(finalRfqDetailView.vendorCode, s); - if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); - - const vendorCountryCondition = ilike(finalRfqDetailView.vendorCountry, s); - if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition); - - const incotermsDescriptionCondition = ilike(finalRfqDetailView.incotermsDescription, s); - if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition); - - const paymentTermsDescriptionCondition = ilike(finalRfqDetailView.paymentTermsDescription, s); - if (paymentTermsDescriptionCondition) validSearchConditions.push(paymentTermsDescriptionCondition); - - const classificationCondition = ilike(finalRfqDetailView.classification, s); - if (classificationCondition) validSearchConditions.push(classificationCondition); - - const sparepartCondition = ilike(finalRfqDetailView.sparepart, s); - if (sparepartCondition) validSearchConditions.push(sparepartCondition); - - if (validSearchConditions.length > 0) { - globalWhere = or(...validSearchConditions); - } - } - - // 5) 최종 WHERE 조건 생성 - const whereConditions: SQL<unknown>[] = []; - - if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); - if (globalWhere) whereConditions.push(globalWhere); - if (rfqIdWhere) whereConditions.push(rfqIdWhere); - - const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // 6) 전체 데이터 수 조회 - const totalResult = await db - .select({ count: count() }) - .from(finalRfqDetailView) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - - if (total === 0) { - return { data: [], pageCount: 0, total: 0 }; - } - - console.log(totalResult); - console.log(total); - - // 7) 정렬 및 페이징 처리된 데이터 조회 - const orderByColumns = input.sort.map((sort) => { - const column = sort.id as keyof typeof finalRfqDetailView.$inferSelect; - return sort.desc ? desc(finalRfqDetailView[column]) : asc(finalRfqDetailView[column]); - }); - - if (orderByColumns.length === 0) { - orderByColumns.push(desc(finalRfqDetailView.createdAt)); - } - - const finalRfqData = await db - .select() - .from(finalRfqDetailView) - .where(finalWhere) - .orderBy(...orderByColumns) - .limit(input.perPage) - .offset(offset); - - const pageCount = Math.ceil(total / input.perPage); - - return { data: finalRfqData, pageCount, total }; - } catch (err) { - console.error("Error in getFinalRfqDetail:", err); - return { data: [], pageCount: 0, total: 0 }; - } -}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx deleted file mode 100644 index 2333d9cf..00000000 --- a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx +++ /dev/null @@ -1,523 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { format } from "date-fns" -import { CalendarIcon, Plus, Loader2, Eye } from "lucide-react" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Calendar } from "@/components/ui/calendar" -import { Badge } from "@/components/ui/badge" -import { cn } from "@/lib/utils" -import { toast } from "sonner" -import { ProjectSelector } from "@/components/ProjectSelector" -import { createRfqAction, previewNextRfqCode } from "../service" - -export type Project = { - id: number; - projectCode: string; - projectName: string; -} - -// 클라이언트 폼 스키마 (projectId 필수로 변경) -const createRfqSchema = z.object({ - projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경 - dueDate: z.date({ - required_error: "마감일을 선택해주세요", - }), - picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"), - picName: z.string().optional(), - engPicName: z.string().optional(), - packageNo: z.string().min(1, "패키지 번호를 입력해주세요"), - packageName: z.string().min(1, "패키지명을 입력해주세요"), - remark: z.string().optional(), - projectCompany: z.string().optional(), - projectFlag: z.string().optional(), - projectSite: z.string().optional(), -}) - -type CreateRfqFormValues = z.infer<typeof createRfqSchema> - -interface CreateRfqDialogProps { - onSuccess?: () => void; -} - -export function CreateRfqDialog({ onSuccess }: CreateRfqDialogProps) { - const [open, setOpen] = React.useState(false) - const [isLoading, setIsLoading] = React.useState(false) - const [previewCode, setPreviewCode] = React.useState<string>("") - const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) - const router = useRouter() - const { data: session } = useSession() - - const userId = React.useMemo(() => { - return session?.user?.id ? Number(session.user.id) : null; - }, [session]); - - const form = useForm<CreateRfqFormValues>({ - resolver: zodResolver(createRfqSchema), - defaultValues: { - projectId: undefined, - dueDate: undefined, - picCode: "", - picName: "", - engPicName: "", - packageNo: "", - packageName: "", - remark: "", - projectCompany: "", - projectFlag: "", - projectSite: "", - }, - }) - - // picCode 변경 시 미리보기 업데이트 - const watchedPicCode = form.watch("picCode") - - React.useEffect(() => { - if (watchedPicCode && watchedPicCode.length > 0) { - setIsLoadingPreview(true) - const timer = setTimeout(async () => { - try { - const preview = await previewNextRfqCode(watchedPicCode) - setPreviewCode(preview) - } catch (error) { - console.error("미리보기 오류:", error) - setPreviewCode("") - } finally { - setIsLoadingPreview(false) - } - }, 500) // 500ms 디바운스 - - return () => clearTimeout(timer) - } else { - setPreviewCode("") - } - }, [watchedPicCode]) - - // 다이얼로그 열림/닫힘 처리 및 폼 리셋 - const handleOpenChange = (newOpen: boolean) => { - setOpen(newOpen) - - // 다이얼로그가 닫힐 때 폼과 상태 초기화 - if (!newOpen) { - form.reset() - setPreviewCode("") - setIsLoadingPreview(false) - } - } - - const handleCancel = () => { - form.reset() - setOpen(false) - } - - - const onSubmit = async (data: CreateRfqFormValues) => { - if (!userId) { - toast.error("로그인이 필요합니다") - return - } - - setIsLoading(true) - - try { - // 서버 액션 호출 - Date 객체를 직접 전달 - const result = await createRfqAction({ - projectId: data.projectId, // 이제 항상 값이 있음 - dueDate: data.dueDate, // Date 객체 직접 전달 - picCode: data.picCode, - picName: data.picName || "", - engPicName: data.engPicName || "", - packageNo: data.packageNo, - packageName: data.packageName, - remark: data.remark || "", - projectCompany: data.projectCompany || "", - projectFlag: data.projectFlag || "", - projectSite: data.projectSite || "", - createdBy: userId, - updatedBy: userId, - }) - - if (result.success) { - toast.success(result.message, { - description: `RFQ 코드: ${result.data?.rfqCode}`, - }) - - // 다이얼로그 닫기 (handleOpenChange에서 리셋 처리됨) - setOpen(false) - - // 성공 콜백 실행 - if (onSuccess) { - onSuccess() - } - - } else { - toast.error(result.error || "RFQ 생성에 실패했습니다") - } - - } catch (error) { - console.error('RFQ 생성 오류:', error) - toast.error("RFQ 생성에 실패했습니다", { - description: "알 수 없는 오류가 발생했습니다", - }) - } finally { - setIsLoading(false) - } - } - - const handleProjectSelect = (project: Project | null) => { - if (project === null) { - form.setValue("projectId", undefined as any); // 타입 에러 방지 - return; - } - form.setValue("projectId", project.id); - }; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - <Button size="sm" variant="outline"> - <Plus className="mr-2 h-4 w-4" /> - 새 RFQ - </Button> - </DialogTrigger> - <DialogContent className="max-w-3xl h-[90vh] flex flex-col"> - {/* 고정된 헤더 */} - <DialogHeader className="flex-shrink-0"> - <DialogTitle>새 RFQ 생성</DialogTitle> - <DialogDescription> - 새로운 RFQ를 생성합니다. 필수 정보를 입력해주세요. - </DialogDescription> - </DialogHeader> - - {/* 스크롤 가능한 컨텐츠 영역 */} - <div className="flex-1 overflow-y-auto px-1"> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> - - {/* 프로젝트 선택 (필수) */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel> - 프로젝트 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 마감일 (필수) */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel> - 마감일 <span className="text-red-500">*</span> - </FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - format(field.value, "yyyy-MM-dd") - ) : ( - <span>마감일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 구매 담당자 코드 (필수) + 미리보기 */} - <FormField - control={form.control} - name="picCode" - render={({ field }) => ( - <FormItem> - <FormLabel> - 구매 담당자 코드 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <div className="space-y-2"> - <Input - placeholder="예: P001, P002, MGR01 등" - {...field} - /> - {/* RFQ 코드 미리보기 */} - {previewCode && ( - <div className="flex items-center gap-2 p-2 bg-muted rounded-md"> - <Eye className="h-4 w-4 text-muted-foreground" /> - <span className="text-sm text-muted-foreground"> - 생성될 RFQ 코드: - </span> - <Badge variant="outline" className="font-mono"> - {isLoadingPreview ? "생성 중..." : previewCode} - </Badge> - </div> - )} - </div> - </FormControl> - <FormDescription> - RFQ 코드는 N + 담당자코드 + 시리얼번호(5자리) 형식으로 자동 생성됩니다 - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - {/* 담당자 정보 (두 개 나란히) */} - <div className="space-y-3"> - <h4 className="text-sm font-medium">담당자 정보</h4> - <div className="grid grid-cols-2 gap-4"> - {/* 구매 담당자 */} - <FormField - control={form.control} - name="picName" - render={({ field }) => ( - <FormItem> - <FormLabel>구매 담당자명</FormLabel> - <FormControl> - <Input - placeholder="구매 담당자명" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 설계 담당자 */} - <FormField - control={form.control} - name="engPicName" - render={({ field }) => ( - <FormItem> - <FormLabel>설계 담당자명</FormLabel> - <FormControl> - <Input - placeholder="설계 담당자명" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* 패키지 정보 (두 개 나란히) - 필수 */} - <div className="space-y-3"> - <h4 className="text-sm font-medium">패키지 정보</h4> - <div className="grid grid-cols-2 gap-4"> - {/* 패키지 번호 (필수) */} - <FormField - control={form.control} - name="packageNo" - render={({ field }) => ( - <FormItem> - <FormLabel> - 패키지 번호 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Input - placeholder="패키지 번호" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 패키지명 (필수) */} - <FormField - control={form.control} - name="packageName" - render={({ field }) => ( - <FormItem> - <FormLabel> - 패키지명 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Input - placeholder="패키지명" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* 프로젝트 상세 정보 */} - <div className="space-y-3"> - <h4 className="text-sm font-medium">프로젝트 상세 정보</h4> - <div className="grid grid-cols-1 gap-3"> - <FormField - control={form.control} - name="projectCompany" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트 회사</FormLabel> - <FormControl> - <Input - placeholder="프로젝트 회사명" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-3"> - <FormField - control={form.control} - name="projectFlag" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트 플래그</FormLabel> - <FormControl> - <Input - placeholder="프로젝트 플래그" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="projectSite" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트 사이트</FormLabel> - <FormControl> - <Input - placeholder="프로젝트 사이트" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - </div> - - {/* 비고 */} - <FormField - control={form.control} - name="remark" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - placeholder="추가 비고사항을 입력하세요" - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </form> - </Form> - </div> - - {/* 고정된 푸터 */} - <DialogFooter className="flex-shrink-0"> - <Button - type="button" - variant="outline" - onClick={handleCancel} - disabled={isLoading} - > - 취소 - </Button> - <Button - type="submit" - onClick={form.handleSubmit(onSubmit)} - disabled={isLoading} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "생성 중..." : "RFQ 생성"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-columns.tsx b/lib/b-rfq/summary-table/summary-rfq-columns.tsx deleted file mode 100644 index af5c22b2..00000000 --- a/lib/b-rfq/summary-table/summary-rfq-columns.tsx +++ /dev/null @@ -1,499 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, Eye, Calendar, AlertTriangle, CheckCircle2, Clock, FileText } from "lucide-react" - -import { formatDate, cn } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { Progress } from "@/components/ui/progress" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useRouter } from "next/navigation" -import { RfqDashboardView } from "@/db/schema" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetRFQColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqDashboardView> | null>>; - router: NextRouter; -} - -// 상태에 따른 Badge 변형 결정 함수 -function getStatusBadge(status: string) { - switch (status) { - case "DRAFT": - return { variant: "outline" as const, label: "초안" }; - case "Doc. Received": - return { variant: "secondary" as const, label: "문서접수" }; - case "PIC Assigned": - return { variant: "secondary" as const, label: "담당자배정" }; - case "Doc. Confirmed": - return { variant: "default" as const, label: "문서확정" }; - case "Init. RFQ Sent": - return { variant: "default" as const, label: "초기RFQ발송" }; - case "Init. RFQ Answered": - return { variant: "default" as const, label: "초기RFQ회신" }; - case "TBE started": - return { variant: "secondary" as const, label: "TBE시작" }; - case "TBE finished": - return { variant: "secondary" as const, label: "TBE완료" }; - case "Final RFQ Sent": - return { variant: "default" as const, label: "최종RFQ발송" }; - case "Quotation Received": - return { variant: "default" as const, label: "견적접수" }; - case "Vendor Selected": - return { variant: "success" as const, label: "업체선정" }; - default: - return { variant: "outline" as const, label: status }; - } -} - -function getProgressBadge(progress: number) { - if (progress >= 100) { - return { variant: "success" as const, label: "완료" }; - } else if (progress >= 70) { - return { variant: "default" as const, label: "진행중" }; - } else if (progress >= 30) { - return { variant: "secondary" as const, label: "초기진행" }; - } else { - return { variant: "outline" as const, label: "시작" }; - } -} - -function getUrgencyLevel(daysToDeadline: number): "high" | "medium" | "low" { - if (daysToDeadline <= 3) return "high"; - if (daysToDeadline <= 7) return "medium"; - return "low"; -} - -export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): ColumnDef<RfqDashboardView>[] { - - // Select 컬럼 - const selectColumn: ColumnDef<RfqDashboardView> = { - 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, - }; - - // RFQ 코드 컬럼 - const rfqCodeColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> - ), - cell: ({ row }) => ( - <div className="flex flex-col"> - <span className="font-medium">{row.getValue("rfqCode")}</span> - {row.original.description && ( - <span className="text-xs text-muted-foreground truncate max-w-[200px]"> - {row.original.description} - </span> - )} - </div> - ), - }; - - // 프로젝트 정보 컬럼 - const projectColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "projectName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트" /> - ), - cell: ({ row }) => { - const projectName = row.original.projectName; - const projectCode = row.original.projectCode; - - if (!projectName) { - return <span className="text-muted-foreground">-</span>; - } - - return ( - <div className="flex flex-col"> - <span className="font-medium">{projectName}</span> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - {projectCode && <span>{projectCode}</span>} - </div> - </div> - ); - }, - }; - - // 패키지 정보 컬럼 - const packageColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "packageNo", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="패키지" /> - ), - cell: ({ row }) => { - const packageNo = row.original.packageNo; - const packageName = row.original.packageName; - - if (!packageNo) { - return <span className="text-muted-foreground">-</span>; - } - - return ( - <div className="flex flex-col"> - <span className="font-medium">{packageNo}</span> - {packageName && ( - <span className="text-xs text-muted-foreground truncate max-w-[150px]"> - {packageName} - </span> - )} - </div> - ); - }, - }; - - const updatedColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "updatedBy", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Updated By" /> - ), - cell: ({ row }) => { - const updatedByName = row.original.updatedByName; - const updatedByEmail = row.original.updatedByEmail; - - if (!updatedByName) { - return <span className="text-muted-foreground">-</span>; - } - - return ( - <div className="flex flex-col"> - <span className="font-medium">{updatedByName}</span> - {updatedByEmail && ( - <span className="text-xs text-muted-foreground truncate max-w-[150px]"> - {updatedByEmail} - </span> - )} - </div> - ); - }, - }; - - - // 상태 컬럼 - const statusColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "status", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="상태" /> - ), - cell: ({ row }) => { - const statusBadge = getStatusBadge(row.original.status); - return <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>; - }, - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }; - - // 진행률 컬럼 - const progressColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "overallProgress", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="진행률" /> - ), - cell: ({ row }) => { - const progress = row.original.overallProgress; - const progressBadge = getProgressBadge(progress); - - return ( - <div className="flex flex-col gap-1 min-w-[120px]"> - <div className="flex items-center justify-between"> - <span className="text-sm font-medium">{progress}%</span> - <Badge variant={progressBadge.variant} className="text-xs"> - {progressBadge.label} - </Badge> - </div> - <Progress value={progress} className="h-2" /> - </div> - ); - }, - }; - - // 마감일 컬럼 - const dueDateColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="마감일" /> - ), - cell: ({ row }) => { - const dueDate = row.original.dueDate; - const daysToDeadline = row.original.daysToDeadline; - const urgencyLevel = getUrgencyLevel(daysToDeadline); - - if (!dueDate) { - return <span className="text-muted-foreground">-</span>; - } - - return ( - <div className="flex flex-col"> - <div className="flex items-center gap-2"> - <Calendar className="h-4 w-4 text-muted-foreground" /> - <span>{formatDate(dueDate, 'KR')}</span> - </div> - <div className="flex items-center gap-1 text-xs"> - {urgencyLevel === "high" && ( - <AlertTriangle className="h-3 w-3 text-red-500" /> - )} - {urgencyLevel === "medium" && ( - <Clock className="h-3 w-3 text-yellow-500" /> - )} - {urgencyLevel === "low" && ( - <CheckCircle2 className="h-3 w-3 text-green-500" /> - )} - <span className={cn( - urgencyLevel === "high" && "text-red-500", - urgencyLevel === "medium" && "text-yellow-600", - urgencyLevel === "low" && "text-green-600" - )}> - {daysToDeadline > 0 ? `${daysToDeadline}일 남음` : - daysToDeadline === 0 ? "오늘 마감" : - `${Math.abs(daysToDeadline)}일 지남`} - </span> - </div> - </div> - ); - }, - }; - - // 담당자 컬럼 - const picColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "picName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="구매 담당자" /> - ), - cell: ({ row }) => { - const picName = row.original.picName; - return picName ? ( - <span>{picName}</span> - ) : ( - <span className="text-muted-foreground">미배정</span> - ); - }, - }; - - const engPicColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "engPicName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="설계 담당자" /> - ), - cell: ({ row }) => { - const picName = row.original.engPicName; - return picName ? ( - <span>{picName}</span> - ) : ( - <span className="text-muted-foreground">미배정</span> - ); - }, - }; - - - const pjtCompanyColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "projectCompany", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" /> - ), - cell: ({ row }) => { - const projectCompany = row.original.projectCompany; - return projectCompany ? ( - <span>{projectCompany}</span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - }; - - const pjtFlagColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "projectFlag", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" /> - ), - cell: ({ row }) => { - const projectFlag = row.original.projectFlag; - return projectFlag ? ( - <span>{projectFlag}</span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - }; - - - const pjtSiteColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "projectSite", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" /> - ), - cell: ({ row }) => { - const projectSite = row.original.projectSite; - return projectSite ? ( - <span>{projectSite}</span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - }; - const remarkColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "remark", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="비고" /> - ), - cell: ({ row }) => { - const remark = row.original.remark; - return remark ? ( - <span>{remark}</span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - }; - - // 첨부파일 수 컬럼 - const attachmentColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "totalAttachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첨부파일" /> - ), - cell: ({ row }) => { - const count = row.original.totalAttachments; - return ( - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <span>{count}</span> - </div> - ); - }, - }; - - // 벤더 현황 컬럼 - const vendorStatusColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "initialVendorCount", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 현황" /> - ), - cell: ({ row }) => { - const initial = row.original.initialVendorCount; - const final = row.original.finalVendorCount; - const initialRate = row.original.initialResponseRate; - const finalRate = row.original.finalResponseRate; - - return ( - <div className="flex flex-col gap-1 text-xs"> - <div className="flex items-center justify-between"> - <span className="text-muted-foreground">초기:</span> - <span>{initial}개사 ({Number(initialRate).toFixed(0)}%)</span> - </div> - <div className="flex items-center justify-between"> - <span className="text-muted-foreground">최종:</span> - <span>{final}개사 ({Number(finalRate).toFixed(0)}%)</span> - </div> - </div> - ); - }, - }; - - // 생성일 컬럼 - const createdAtColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="생성일" /> - ), - cell: ({ row }) => { - const dateVal = row.original.createdAt as Date; - return formatDate(dateVal, 'KR'); - }, - }; - - const updatedAtColumn: ColumnDef<RfqDashboardView> = { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정일" /> - ), - cell: ({ row }) => { - const dateVal = row.original.updatedAt as Date; - return formatDate(dateVal, 'KR'); - }, - }; - - // Actions 컬럼 - const actionsColumn: ColumnDef<RfqDashboardView> = { - id: "detail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="상세내용" /> - ), - // enableHiding: false, - cell: function Cell({ row }) { - const rfq = row.original; - const detailUrl = `/evcp/b-rfq/${rfq.rfqId}/initial`; - - return ( - - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - onClick={() => router.push(detailUrl)} - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - ); - }, - size: 40, - }; - - return [ - selectColumn, - rfqCodeColumn, - projectColumn, - packageColumn, - statusColumn, - picColumn, - progressColumn, - dueDateColumn, - actionsColumn, - - engPicColumn, - - pjtCompanyColumn, - pjtFlagColumn, - pjtSiteColumn, - - attachmentColumn, - vendorStatusColumn, - createdAtColumn, - - updatedAtColumn, - updatedColumn, - remarkColumn - ]; -}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx deleted file mode 100644 index ff3bc132..00000000 --- a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx +++ /dev/null @@ -1,617 +0,0 @@ -"use client" - -import { useEffect, useTransition, useState, useRef } from "react" -import { useRouter, useParams } from "next/navigation" -import { z } from "zod" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Search, X } from "lucide-react" -import { customAlphabet } from "nanoid" -import { parseAsStringEnum, useQueryState } from "nuqs" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { getFiltersStateParser } from "@/lib/parsers" - -// nanoid 생성기 -const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) - -// RFQ 필터 스키마 정의 -const rfqFilterSchema = z.object({ - rfqCode: z.string().optional(), - projectCode: z.string().optional(), - picName: z.string().optional(), - packageNo: z.string().optional(), - packageName: z.string().optional(), - status: z.string().optional(), -}) - -// RFQ 상태 옵션 정의 -const rfqStatusOptions = [ - { value: "DRAFT", label: "초안" }, - { value: "Doc. Received", label: "문서접수" }, - { value: "PIC Assigned", label: "담당자배정" }, - { value: "Doc. Confirmed", label: "문서확인" }, - { value: "Init. RFQ Sent", label: "초기RFQ발송" }, - { value: "Init. RFQ Answered", label: "초기RFQ회신" }, - { value: "TBE started", label: "TBE시작" }, - { value: "TBE finished", label: "TBE완료" }, - { value: "Final RFQ Sent", label: "최종RFQ발송" }, - { value: "Quotation Received", label: "견적접수" }, - { value: "Vendor Selected", label: "업체선정" }, -] - -type RFQFilterFormValues = z.infer<typeof rfqFilterSchema> - -interface RFQFilterSheetProps { - isOpen: boolean; - onClose: () => void; - onSearch?: () => void; - isLoading?: boolean; -} - -export function RFQFilterSheet({ - isOpen, - onClose, - onSearch, - isLoading = false -}: RFQFilterSheetProps) { - const router = useRouter() - const params = useParams(); - const lng = params ? (params.lng as string) : 'ko'; - - const [isPending, startTransition] = useTransition() - - // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 - const [isInitializing, setIsInitializing] = useState(false) - // 마지막으로 적용된 필터를 추적하기 위한 ref - const lastAppliedFilters = useRef<string>("") - - // nuqs로 URL 상태 관리 - const [filters, setFilters] = useQueryState( - "basicFilters", - getFiltersStateParser().withDefault([]) - ) - - // joinOperator 설정 - const [joinOperator, setJoinOperator] = useQueryState( - "basicJoinOperator", - parseAsStringEnum(["and", "or"]).withDefault("and") - ) - - // 현재 URL의 페이지 파라미터도 가져옴 - const [page, setPage] = useQueryState("page", { defaultValue: "1" }) - - // 폼 상태 초기화 - const form = useForm<RFQFilterFormValues>({ - resolver: zodResolver(rfqFilterSchema), - defaultValues: { - rfqCode: "", - projectCode: "", - picName: "", - packageNo: "", - packageName: "", - status: "", - }, - }) - - // URL 필터에서 초기 폼 상태 설정 - useEffect(() => { - // 현재 필터를 문자열로 직렬화 - const currentFiltersString = JSON.stringify(filters); - - // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 - if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { - setIsInitializing(true); - - const formValues = { ...form.getValues() }; - let formUpdated = false; - - filters.forEach(filter => { - if (filter.id in formValues) { - // @ts-ignore - 동적 필드 접근 - formValues[filter.id] = filter.value; - formUpdated = true; - } - }); - - // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 - if (formUpdated) { - form.reset(formValues); - lastAppliedFilters.current = currentFiltersString; - } - - setIsInitializing(false); - } - }, [filters, isOpen]) - - // 현재 적용된 필터 카운트 - const getActiveFilterCount = () => { - return filters?.length || 0 - } - - // 폼 제출 핸들러 - async function onSubmit(data: RFQFilterFormValues) { - // 초기화 중이면 제출 방지 - if (isInitializing) return; - - startTransition(async () => { - try { - // 필터 배열 생성 - const newFilters = [] - - if (data.rfqCode?.trim()) { - newFilters.push({ - id: "rfqCode", - value: data.rfqCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.projectCode?.trim()) { - newFilters.push({ - id: "projectCode", - value: data.projectCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.picName?.trim()) { - newFilters.push({ - id: "picName", - value: data.picName.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.packageNo?.trim()) { - newFilters.push({ - id: "packageNo", - value: data.packageNo.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.packageName?.trim()) { - newFilters.push({ - id: "packageName", - value: data.packageName.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.status?.trim()) { - newFilters.push({ - id: "status", - value: data.status.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // 수동으로 URL 업데이트 (nuqs 대신) - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 기존 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('rfqBasicFilters'); - params.delete('basicJoinOperator'); - params.delete('rfqBasicJoinOperator'); - params.delete('page'); - - // 새로운 필터 추가 - if (newFilters.length > 0) { - params.set('basicFilters', JSON.stringify(newFilters)); - params.set('basicJoinOperator', joinOperator); - } - - // 페이지를 1로 설정 - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("New RFQ Filter URL:", newUrl); - - // 페이지 완전 새로고침으로 서버 렌더링 강제 - window.location.href = newUrl; - - // 마지막 적용된 필터 업데이트 - lastAppliedFilters.current = JSON.stringify(newFilters); - - // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) - if (onSearch) { - console.log("Calling RFQ onSearch..."); - onSearch(); - } - - console.log("=== RFQ Filter Submit Complete ==="); - } catch (error) { - console.error("RFQ 필터 적용 오류:", error); - } - }) - } - - // 필터 초기화 핸들러 - async function handleReset() { - try { - setIsInitializing(true); - - form.reset({ - rfqCode: "", - projectCode: "", - picName: "", - packageNo: "", - packageName: "", - status: "", - }); - - console.log("=== RFQ Filter Reset Debug ==="); - console.log("Current URL before reset:", window.location.href); - - // 수동으로 URL 초기화 - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('rfqBasicFilters'); - params.delete('basicJoinOperator'); - params.delete('rfqBasicJoinOperator'); - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("Reset URL:", newUrl); - - // 페이지 완전 새로고침 - window.location.href = newUrl; - - // 마지막 적용된 필터 초기화 - lastAppliedFilters.current = ""; - - console.log("RFQ 필터 초기화 완료"); - setIsInitializing(false); - } catch (error) { - console.error("RFQ 필터 초기화 오류:", error); - setIsInitializing(false); - } - } - - // Don't render if not open (for side panel use) - if (!isOpen) { - return null; - } - - return ( - <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> - {/* Filter Panel Header */} - <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> - <h3 className="text-lg font-semibold whitespace-nowrap">RFQ 검색 필터</h3> - <div className="flex items-center gap-2"> - {getActiveFilterCount() > 0 && ( - <Badge variant="secondary" className="px-2 py-1"> - {getActiveFilterCount()}개 필터 적용됨 - </Badge> - )} - </div> - </div> - - {/* Join Operator Selection */} - <div className="px-6 shrink-0"> - <label className="text-sm font-medium">조건 결합 방식</label> - <Select - value={joinOperator} - onValueChange={(value: "and" | "or") => setJoinOperator(value)} - disabled={isInitializing} - > - <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> - <SelectValue placeholder="조건 결합 방식" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> - <SelectItem value="or">하나라도 충족 (OR)</SelectItem> - </SelectContent> - </Select> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> - {/* Scrollable content area */} - <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> - <div className="space-y-4 pt-2"> - - {/* RFQ 코드 */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ 코드</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="RFQ 코드 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("rfqCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 프로젝트 코드 */} - <FormField - control={form.control} - name="projectCode" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트 코드</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="프로젝트 코드 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("projectCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 담당자명 */} - <FormField - control={form.control} - name="picName" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자명</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="담당자명 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("picName", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 패키지 번호 */} - <FormField - control={form.control} - name="packageNo" - render={({ field }) => ( - <FormItem> - <FormLabel>패키지 번호</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="패키지 번호 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("packageNo", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 패키지명 */} - <FormField - control={form.control} - name="packageName" - render={({ field }) => ( - <FormItem> - <FormLabel>패키지명</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="패키지명 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("packageName", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ 상태 */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ 상태</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isInitializing} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="RFQ 상태 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("status", ""); - }} - disabled={isInitializing} - > - <X className="size-3" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {rfqStatusOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* Fixed buttons at bottom */} - <div className="p-4 shrink-0"> - <div className="flex gap-2 justify-end"> - <Button - type="button" - variant="outline" - onClick={handleReset} - disabled={isPending || getActiveFilterCount() === 0 || isInitializing} - className="px-4" - > - 초기화 - </Button> - <Button - type="submit" - variant="samsung" - disabled={isPending || isLoading || isInitializing} - className="px-4" - > - <Search className="size-4 mr-2" /> - {isPending || isLoading ? "조회 중..." : "조회"} - </Button> - </div> - </div> - </form> - </Form> - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx deleted file mode 100644 index 02ba4aaa..00000000 --- a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, FileText, Mail, Search } from "lucide-react" -import { useRouter } from "next/navigation" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { RfqDashboardView } from "@/db/schema" -import { CreateRfqDialog } from "./add-new-rfq-dialog" - -interface RFQTableToolbarActionsProps { - table: Table<RfqDashboardView> -} - -export function RFQTableToolbarActions({ table }: RFQTableToolbarActionsProps) { - const router = useRouter() - - // 선택된 행 정보 - const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedCount = selectedRows.length - const isSingleSelected = selectedCount === 1 - - // RFQ 문서 확인 핸들러 - const handleDocumentCheck = () => { - if (isSingleSelected) { - const selectedRfq = selectedRows[0].original - const rfqId = selectedRfq.rfqId - - // RFQ 첨부문서 확인 페이지로 이동 - router.push(`/evcp/b-rfq/${rfqId}`) - } - } - - // 테이블 새로고침 핸들러 - const handleRefresh = () => { - // 페이지 새로고침 또는 데이터 다시 fetch - router.refresh() - } - - return ( - <div className="flex items-center gap-2"> - {/* 새 RFQ 생성 다이얼로그 */} - <CreateRfqDialog onSuccess={handleRefresh} /> - - {/* RFQ 문서 확인 버튼 - 단일 선택시만 활성화 */} - <Button - size="sm" - variant="outline" - onClick={handleDocumentCheck} - disabled={!isSingleSelected} - className="flex items-center" - > - <Search className="mr-2 h-4 w-4" /> - RFQ 문서 확인 - </Button> - - - </div> - ) -} diff --git a/lib/b-rfq/summary-table/summary-rfq-table.tsx b/lib/b-rfq/summary-table/summary-rfq-table.tsx deleted file mode 100644 index 83d50685..00000000 --- a/lib/b-rfq/summary-table/summary-rfq-table.tsx +++ /dev/null @@ -1,285 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter, useSearchParams } from "next/navigation" -import { Button } from "@/components/ui/button" -import { PanelLeftClose, PanelLeftOpen } from "lucide-react" -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 { getRFQDashboard } from "../service" -import { cn } from "@/lib/utils" -import { useTablePresets } from "@/components/data-table/use-table-presets" -import { TablePresetManager } from "@/components/data-table/data-table-preset" -import { useMemo } from "react" -import { getRFQColumns } from "./summary-rfq-columns" -import { RfqDashboardView } from "@/db/schema" -import { RFQTableToolbarActions } from "./summary-rfq-table-toolbar-actions" -import { RFQFilterSheet } from "./summary-rfq-filter-sheet" - -interface RFQDashboardTableProps { - promises: Promise<[Awaited<ReturnType<typeof getRFQDashboard>>]> - className?: string -} - -export function RFQDashboardTable({ promises, className }: RFQDashboardTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDashboardView> | null>(null) - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) - - const router = useRouter() - const searchParams = useSearchParams() - - const containerRef = React.useRef<HTMLDivElement>(null) - const [containerTop, setContainerTop] = React.useState(0) - - const updateContainerBounds = React.useCallback(() => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect() - setContainerTop(rect.top) - } - }, []) - - React.useEffect(() => { - updateContainerBounds() - - const handleResize = () => { - updateContainerBounds() - } - - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', updateContainerBounds) - - return () => { - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', updateContainerBounds) - } - }, [updateContainerBounds]) - - const [promiseData] = React.use(promises) - const tableData = promiseData - - console.log("RFQ Dashboard Table Data:", { - dataLength: tableData.data?.length, - pageCount: tableData.pageCount, - total: tableData.total, - sampleData: tableData.data?.[0] - }) - - const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams.get('page') || '1'), - perPage: parseInt(searchParams.get('perPage') || '10'), - sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }], - filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') ? - JSON.parse(searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')!) : [], - basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams.get('search') || '', - columnVisibility: {}, - columnOrder: [], - pinnedColumns: { left: [], right: ["actions"] }, - groupBy: [], - expandedRows: [] - }), [searchParams]) - - const { - presets, - activePresetId, - hasUnsavedChanges, - isLoading: presetsLoading, - createPreset, - applyPreset, - updatePreset, - deletePreset, - setDefaultPreset, - renamePreset, - updateClientState, - getCurrentSettings, - } = useTablePresets<RfqDashboardView>('rfq-dashboard-table', initialSettings) - - const columns = React.useMemo( - () => getRFQColumns({ setRowAction, router }), - [setRowAction, router] - ) - - const filterFields: DataTableFilterField<RfqDashboardView>[] = [ - { id: "rfqCode", label: "RFQ 코드" }, - { id: "projectName", label: "프로젝트" }, - { id: "status", label: "상태" }, - ] - - const advancedFilterFields: DataTableAdvancedFilterField<RfqDashboardView>[] = [ - { id: "rfqCode", label: "RFQ 코드", type: "text" }, - { id: "description", label: "설명", type: "text" }, - { id: "projectName", label: "프로젝트명", type: "text" }, - { id: "projectCode", label: "프로젝트 코드", type: "text" }, - { id: "packageNo", label: "패키지 번호", type: "text" }, - { id: "packageName", label: "패키지명", type: "text" }, - { id: "picName", label: "담당자", type: "text" }, - { id: "status", label: "상태", type: "select", options: [ - { label: "초안", value: "DRAFT" }, - { label: "문서접수", value: "Doc. Received" }, - { label: "담당자배정", value: "PIC Assigned" }, - { label: "문서확인", value: "Doc. Confirmed" }, - { label: "초기RFQ발송", value: "Init. RFQ Sent" }, - { label: "초기RFQ회신", value: "Init. RFQ Answered" }, - { label: "TBE시작", value: "TBE started" }, - { label: "TBE완료", value: "TBE finished" }, - { label: "최종RFQ발송", value: "Final RFQ Sent" }, - { label: "견적접수", value: "Quotation Received" }, - { label: "업체선정", value: "Vendor Selected" }, - ]}, - { id: "overallProgress", label: "진행률", type: "number" }, - { id: "dueDate", label: "마감일", type: "date" }, - { id: "createdAt", label: "생성일", type: "date" }, - ] - - const currentSettings = useMemo(() => { - return getCurrentSettings() - }, [getCurrentSettings]) - - const initialState = useMemo(() => { - return { - sorting: initialSettings.sort.filter(sortItem => { - const columnExists = columns.some(col => col.accessorKey === sortItem.id) - return columnExists - }) as any, - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [currentSettings, initialSettings.sort, columns]) - - const { table } = useDataTable({ - data: tableData.data, - columns, - pageCount: tableData.pageCount, - rowCount: tableData.total || tableData.data.length, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState, - getRowId: (originalRow) => String(originalRow.rfqId), - shallow: false, - clearOnDefault: true, - }) - - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - const getActiveBasicFilterCount = () => { - try { - const basicFilters = searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 - } - } - - const FILTER_PANEL_WIDTH = 400; - - return ( - <> - {/* Filter Panel */} - <div - className={cn( - "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", - isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" - )} - style={{ - width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - top: `${containerTop}px`, - height: `calc(100vh - ${containerTop}px)` - }} - > - <div className="h-full"> - <RFQFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} - isLoading={false} - /> - </div> - </div> - - {/* Main Content Container */} - <div - ref={containerRef} - className={cn("relative w-full overflow-hidden", className)} - > - <div className="flex w-full h-full"> - <div - className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" - style={{ - width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', - marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' - }} - > - {/* Header Bar */} - <div className="flex items-center justify-between p-4 bg-background shrink-0"> - <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - type='button' - onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} - className="flex items-center shadow-sm" - > - {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} - {getActiveBasicFilterCount() > 0 && ( - <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} - </span> - )} - </Button> - </div> - - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || tableData.data.length}건</span> - )} - </div> - </div> - - {/* Table Content Area */} - <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}> - <div className="h-full w-full"> - <DataTable table={table} className="h-full"> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <div className="flex items-center gap-2"> - <TablePresetManager<RfqDashboardView> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> - - <RFQTableToolbarActions table={table} /> - </div> - </DataTableAdvancedToolbar> - </DataTable> - </div> - </div> - </div> - </div> - </div> - </> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts deleted file mode 100644 index bee10a11..00000000 --- a/lib/b-rfq/validations.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum,parseAsBoolean - } from "nuqs/server" - import * as z from "zod" - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { FinalRfqDetailView, VendorAttachmentResponse } from "@/db/schema"; - -export const searchParamsRFQDashboardCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 - rfqDashboardView 기반 - sort: getSortingStateParser<{ - rfqId: number; - rfqCode: string; - description: string; - status: string; - dueDate: Date; - projectCode: string; - projectName: string; - packageNo: string; - packageName: string; - picName: string; - totalAttachments: number; - initialVendorCount: number; - finalVendorCount: number; - initialResponseRate: number; - finalResponseRate: number; - overallProgress: number; - daysToDeadline: number; - createdAt: Date; - }>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 기본 필터 - rfqBasicFilters: getFiltersStateParser().withDefault([]), - rfqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - // RFQ 특화 필터 - rfqCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - picName: parseAsString.withDefault(""), - packageNo: parseAsString.withDefault(""), - status: parseAsStringEnum([ - "DRAFT", - "Doc. Received", - "PIC Assigned", - "Doc. Confirmed", - "Init. RFQ Sent", - "Init. RFQ Answered", - "TBE started", - "TBE finished", - "Final RFQ Sent", - "Quotation Received", - "Vendor Selected" - ]), - dueDateFrom: parseAsString.withDefault(""), - dueDateTo: parseAsString.withDefault(""), - progressMin: parseAsInteger.withDefault(0), - progressMax: parseAsInteger.withDefault(100), - }); - - export type GetRFQDashboardSchema = Awaited<ReturnType<typeof searchParamsRFQDashboardCache.parse>> - - - export const createRfqServerSchema = z.object({ - projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경 - dueDate: z.date(), // Date 객체로 직접 받기 - picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"), - picName: z.string().optional(), - engPicName: z.string().optional(), - packageNo: z.string().min(1, "패키지 번호를 입력해주세요"), - packageName: z.string().min(1, "패키지명을 입력해주세요"), - remark: z.string().optional(), - projectCompany: z.string().optional(), - projectFlag: z.string().optional(), - projectSite: z.string().optional(), - createdBy: z.number(), - updatedBy: z.number(), - }) - - export type CreateRfqInput = z.infer<typeof createRfqServerSchema> - - - - export type RfqAttachment = { - id: number - attachmentType: string - serialNo: string - rfqId: number - fileName: string - originalFileName: string - filePath: string - fileSize: number | null - fileType: string | null - description: string | null - createdBy: number - createdAt: Date - createdByName?: string - responseStats?: { - totalVendors: number - respondedCount: number - pendingCount: number - waivedCount: number - responseRate: number - } - } - - // RFQ Attachments용 검색 파라미터 캐시 - export const searchParamsRfqAttachmentsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<RfqAttachment>().withDefault([ - { id: "createdAt", desc: true }, - ]), - // 기본 필터 - attachmentType: parseAsArrayOf(z.string()).withDefault([]), - fileType: parseAsArrayOf(z.string()).withDefault([]), - search: parseAsString.withDefault(""), - // advanced filter - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - }) - - // 스키마 타입들 - export type GetRfqAttachmentsSchema = Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>> - - - // 첨부파일 레코드 타입 -export const attachmentRecordSchema = z.object({ - rfqId: z.number().positive(), - attachmentType: z.enum(["구매", "설계"]), - // serialNo: z.string().min(1), - description: z.string().optional(), - fileName: z.string(), - originalFileName: z.string(), - filePath: z.string(), - fileSize: z.number(), - fileType: z.string(), -}) - -export type AttachmentRecord = z.infer<typeof attachmentRecordSchema> - -export const deleteAttachmentsSchema = z.object({ - ids: z.array(z.number()).min(1, "삭제할 첨부파일을 선택해주세요."), -}) - -export type DeleteAttachmentsInput = z.infer<typeof deleteAttachmentsSchema> - - -//Inital RFQ -export const searchParamsInitialRfqDetailCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 - initialRfqDetailView 기반 - sort: getSortingStateParser<{ - rfqId: number; - rfqCode: string; - rfqStatus: string; - initialRfqId: number; - initialRfqStatus: string; - vendorId: number; - vendorCode: string; - vendorName: string; - vendorCountry: string; - vendorBusinessSize: string; - dueDate: Date; - validDate: Date; - incotermsCode: string; - incotermsDescription: string; - shortList: boolean; - returnYn: boolean; - cpRequestYn: boolean; - prjectGtcYn: boolean; - returnRevision: number; - gtc: string; - gtcValidDate: string; - classification: string; - sparepart: string; - createdAt: Date; - updatedAt: Date; - }>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 기본 필터 - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - // Initial RFQ Detail 특화 필터 - rfqCode: parseAsString.withDefault(""), - rfqStatus: parseAsStringEnum([ - "DRAFT", - "Doc. Received", - "PIC Assigned", - "Doc. Confirmed", - "Init. RFQ Sent", - "Init. RFQ Answered", - "TBE started", - "TBE finished", - "Final RFQ Sent", - "Quotation Received", - "Vendor Selected" - ]), - initialRfqStatus: parseAsStringEnum([ - "PENDING", - "SENT", - "RESPONDED", - "EXPIRED", - "CANCELLED" - ]), - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - vendorCountry: parseAsString.withDefault(""), - vendorBusinessSize: parseAsStringEnum([ - "LARGE", - "MEDIUM", - "SMALL", - "STARTUP" - ]), - incotermsCode: parseAsString.withDefault(""), - dueDateFrom: parseAsString.withDefault(""), - dueDateTo: parseAsString.withDefault(""), - validDateFrom: parseAsString.withDefault(""), - validDateTo: parseAsString.withDefault(""), - shortList: parseAsStringEnum(["true", "false"]), - returnYn: parseAsStringEnum(["true", "false"]), - cpRequestYn: parseAsStringEnum(["true", "false"]), - prjectGtcYn: parseAsStringEnum(["true", "false"]), - classification: parseAsString.withDefault(""), - sparepart: parseAsString.withDefault(""), -}); - -export type GetInitialRfqDetailSchema = Awaited<ReturnType<typeof searchParamsInitialRfqDetailCache.parse>>; - - - -export const updateInitialRfqSchema = z.object({ - initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"]), - dueDate: z.date({ - required_error: "마감일을 선택해주세요.", - }), - validDate: z.date().optional(), - gtc: z.string().optional(), - gtcValidDate: z.string().optional(), - incotermsCode: z.string().max(20, "Incoterms 코드는 20자 이하여야 합니다.").optional(), - classification: z.string().max(255, "분류는 255자 이하여야 합니다.").optional(), - sparepart: z.string().max(255, "예비부품은 255자 이하여야 합니다.").optional(), - shortList: z.boolean().default(false), - returnYn: z.boolean().default(false), - cpRequestYn: z.boolean().default(false), - prjectGtcYn: z.boolean().default(false), - rfqRevision: z.number().int().min(0, "RFQ 리비전은 0 이상이어야 합니다.").default(0), -}) - -export const removeInitialRfqsSchema = z.object({ - ids: z.array(z.number()).min(1, "최소 하나의 항목을 선택해주세요."), -}) - -export type UpdateInitialRfqSchema = z.infer<typeof updateInitialRfqSchema> -export type RemoveInitialRfqsSchema = z.infer<typeof removeInitialRfqsSchema> - -// 벌크 이메일 발송 스키마 -export const bulkEmailSchema = z.object({ - initialRfqIds: z.array(z.number()).min(1, "최소 하나의 초기 RFQ를 선택해주세요."), - language: z.enum(["en", "ko"]).default("en"), -}) - -export type BulkEmailInput = z.infer<typeof bulkEmailSchema> - -// 검색 파라미터 캐시 설정 - -export type ResponseStatus = "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED"; -export type RfqType = "INITIAL" | "FINAL"; - - -export type VendorRfqResponseColumns = { - id: string; - vendorId: number; - rfqRecordId: number; - rfqType: RfqType; - overallStatus: ResponseStatus; - totalAttachments: number; - respondedCount: number; - pendingCount: number; - responseRate: number; - completionRate: number; - requestedAt: Date; - lastRespondedAt: Date | null; -}; - -// 검색 파라미터 캐시 설정 -export const searchParamsVendorResponseCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<VendorRfqResponseColumns>().withDefault([ - { id: "requestedAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 기본 필터 - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 및 필터 - search: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["INITIAL", "FINAL", "ALL"]).withDefault("ALL"), - responseStatus: parseAsStringEnum(["NOT_RESPONDED", "RESPONDED", "REVISION_REQUESTED", "WAIVED", "ALL"]).withDefault("ALL"), - - // 날짜 범위 - from: parseAsString.withDefault(""), - to: parseAsString.withDefault(""), -}); - -export type GetVendorResponsesSchema = Awaited<ReturnType<typeof searchParamsVendorResponseCache.parse>>; - -// vendorId + rfqRecordId로 그룹핑된 응답 요약 타입 -export type VendorRfqResponseSummary = { - id: string; // vendorId + rfqRecordId + rfqType 조합으로 생성된 고유 ID - vendorId: number; - rfqRecordId: number; - rfqType: RfqType; - - // RFQ 정보 - rfq: { - id: number; - rfqCode: string | null; - description: string | null; - status: string; - dueDate: Date; - } | null; - - // 벤더 정보 - vendor: { - id: number; - vendorCode: string; - vendorName: string; - country: string | null; - businessSize: string | null; - } | null; - - // 응답 통계 - totalAttachments: number; - respondedCount: number; - pendingCount: number; - revisionRequestedCount: number; - waivedCount: number; - responseRate: number; - completionRate: number; - overallStatus: ResponseStatus; // 전체적인 상태 - - // 날짜 정보 - requestedAt: Date; - lastRespondedAt: Date | null; - - // 기타 - hasComments: boolean; -}; - - -// 수정 요청 스키마 -export const requestRevisionSchema = z.object({ - responseId: z.number().positive(), - revisionReason: z.string().min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요").max(500), -}); - -// 수정 요청 결과 타입 -export type RequestRevisionResult = { - success: boolean; - message: string; - error?: string; -}; - -export const shortListConfirmSchema = z.object({ - rfqId: z.number(), - selectedVendorIds: z.array(z.number()).min(1), - rejectedVendorIds: z.array(z.number()), -}) - -export type ShortListConfirmInput = z.infer<typeof shortListConfirmSchema> - - -export const searchParamsFinalRfqDetailCache = createSearchParamsCache({ - // 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 - initialRfqDetailView 기반 - sort: getSortingStateParser<FinalRfqDetailView>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 기본 필터 - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 키워드 - search: parseAsString.withDefault(""), - - -}); - -export type GetFinalRfqDetailSchema = Awaited<ReturnType<typeof searchParamsFinalRfqDetailCache.parse>>; - diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx deleted file mode 100644 index 0c2c0c62..00000000 --- a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -// components/rfq/comment-edit-dialog.tsx -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Textarea } from "@/components/ui/textarea"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { MessageSquare, Loader2 } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { useRouter } from "next/navigation"; - -const commentFormSchema = z.object({ - responseComment: z.string().optional(), - vendorComment: z.string().optional(), -}); - -type CommentFormData = z.infer<typeof commentFormSchema>; - -interface CommentEditDialogProps { - responseId: number; - currentResponseComment?: string; - currentVendorComment?: string; - trigger?: React.ReactNode; - onSuccess?: () => void; -} - -export function CommentEditDialog({ - responseId, - currentResponseComment, - currentVendorComment, - trigger, - onSuccess, -}: CommentEditDialogProps) { - const [open, setOpen] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const { toast } = useToast(); - const router = useRouter(); - - const form = useForm<CommentFormData>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - responseComment: currentResponseComment || "", - vendorComment: currentVendorComment || "", - }, - }); - - const onSubmit = async (data: CommentFormData) => { - setIsSaving(true); - - try { - const response = await fetch("/api/vendor-responses/update-comment", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - responseId, - responseComment: data.responseComment, - vendorComment: data.vendorComment, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || "코멘트 업데이트 실패"); - } - - toast({ - title: "코멘트 업데이트 완료", - description: "코멘트가 성공적으로 업데이트되었습니다.", - }); - - setOpen(false); - - router.refresh(); - onSuccess?.(); - - } catch (error) { - console.error("Comment update error:", error); - toast({ - title: "업데이트 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", - variant: "destructive", - }); - } finally { - setIsSaving(false); - } - }; - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - {trigger || ( - <Button size="sm" variant="outline"> - <MessageSquare className="h-3 w-3 mr-1" /> - 코멘트 - </Button> - )} - </DialogTrigger> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <MessageSquare className="h-5 w-5" /> - 코멘트 수정 - </DialogTitle> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - {/* 응답 코멘트 */} - <FormField - control={form.control} - name="responseComment" - render={({ field }) => ( - <FormItem> - <FormLabel>응답 코멘트</FormLabel> - <FormControl> - <Textarea - placeholder="응답에 대한 설명을 입력하세요..." - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 벤더 코멘트 */} - <FormField - control={form.control} - name="vendorComment" - render={({ field }) => ( - <FormItem> - <FormLabel>벤더 코멘트 (내부용)</FormLabel> - <FormControl> - <Textarea - placeholder="내부 참고용 코멘트를 입력하세요..." - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 버튼 */} - <div className="flex justify-end gap-2"> - <Button - type="button" - variant="outline" - onClick={() => setOpen(false)} - disabled={isSaving} - > - 취소 - </Button> - <Button type="submit" disabled={isSaving}> - {isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} - {isSaving ? "저장 중..." : "저장"} - </Button> - </div> - </form> - </Form> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-columns.tsx b/lib/b-rfq/vendor-response/response-detail-columns.tsx deleted file mode 100644 index bc27d103..00000000 --- a/lib/b-rfq/vendor-response/response-detail-columns.tsx +++ /dev/null @@ -1,653 +0,0 @@ -"use client" - -import * as React from "react" -import { ColumnDef } from "@tanstack/react-table" -import type { Row } from "@tanstack/react-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDateTime } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - FileText, - Upload, - CheckCircle, - Clock, - AlertTriangle, - FileX, - Download, - AlertCircle, - RefreshCw, - Calendar, - MessageSquare, - GitBranch, - Ellipsis -} from "lucide-react" -import { cn } from "@/lib/utils" -import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" -import { UploadResponseDialog } from "./upload-response-dialog" -import { CommentEditDialog } from "./comment-edit-dialog" -import { WaiveResponseDialog } from "./waive-response-dialog" -import { ResponseDetailSheet } from "./response-detail-sheet" - -export interface DataTableRowAction<TData> { - row: Row<TData> - type: 'upload' | 'waive' | 'edit' | 'detail' -} - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedVendorResponse> | null>> -} - -// 파일 다운로드 핸들러 -async function handleFileDownload( - filePath: string, - fileName: string, - type: "client" | "vendor" = "client", - id?: number -) { - try { - const params = new URLSearchParams({ - path: filePath, - type: type, - }); - - if (id) { - if (type === "client") { - params.append("revisionId", id.toString()); - } else { - params.append("responseFileId", id.toString()); - } - } - - const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Download failed: ${response.status}`); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - - } catch (error) { - console.error("❌ 파일 다운로드 실패:", error); - alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); - } -} - -// 상태별 정보 반환 -function getEffectiveStatusInfo(effectiveStatus: string) { - switch (effectiveStatus) { - case "NOT_RESPONDED": - return { - icon: Clock, - label: "미응답", - variant: "outline" as const, - color: "text-orange-600" - }; - case "UP_TO_DATE": - return { - icon: CheckCircle, - label: "최신", - variant: "default" as const, - color: "text-green-600" - }; - case "VERSION_MISMATCH": - return { - icon: RefreshCw, - label: "업데이트 필요", - variant: "secondary" as const, - color: "text-blue-600" - }; - case "REVISION_REQUESTED": - return { - icon: AlertTriangle, - label: "수정요청", - variant: "secondary" as const, - color: "text-yellow-600" - }; - case "WAIVED": - return { - icon: FileX, - label: "포기", - variant: "outline" as const, - color: "text-gray-600" - }; - default: - return { - icon: FileText, - label: effectiveStatus, - variant: "outline" as const, - color: "text-gray-600" - }; - } -} - -// 파일명 컴포넌트 -function AttachmentFileNameCell({ revisions }: { - revisions: Array<{ - id: number; - originalFileName: string; - revisionNo: string; - isLatest: boolean; - filePath?: string; - fileSize: number; - createdAt: string; - revisionComment?: string; - }> -}) { - if (!revisions || revisions.length === 0) { - return <span className="text-muted-foreground">파일 없음</span>; - } - - const latestRevision = revisions.find(r => r.isLatest) || revisions[0]; - const hasMultipleRevisions = revisions.length > 1; - const canDownload = latestRevision.filePath; - - return ( - <div className="space-y-1"> - <div className="flex items-center gap-2"> - {canDownload ? ( - <button - onClick={() => handleFileDownload( - latestRevision.filePath!, - latestRevision.originalFileName, - "client", - latestRevision.id - )} - className="font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left max-w-64 truncate" - title={`${latestRevision.originalFileName} - 클릭하여 다운로드`} - > - {latestRevision.originalFileName} - </button> - ) : ( - <span className="font-medium text-sm text-muted-foreground max-w-64 truncate" title={latestRevision.originalFileName}> - {latestRevision.originalFileName} - </span> - )} - - {canDownload && ( - <Button - size="sm" - variant="ghost" - className="h-6 w-6 p-0" - onClick={() => handleFileDownload( - latestRevision.filePath!, - latestRevision.originalFileName, - "client", - latestRevision.id - )} - title="파일 다운로드" - > - <Download className="h-3 w-3" /> - </Button> - )} - - {hasMultipleRevisions && ( - <Badge variant="outline" className="text-xs"> - v{latestRevision.revisionNo} - </Badge> - )} - </div> - - {hasMultipleRevisions && ( - <div className="text-xs text-muted-foreground"> - 총 {revisions.length}개 리비전 - </div> - )} - </div> - ); -} - -// 리비전 비교 컴포넌트 -function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) { - const isUpToDate = response.isVersionMatched; - const hasResponse = !!response.respondedRevision; - const versionLag = response.versionLag || 0; - - return ( - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <span className="text-xs text-muted-foreground">발주처:</span> - <Badge variant="secondary" className="text-xs font-mono"> - {response.currentRevision} - </Badge> - </div> - <div className="flex items-center gap-2"> - <span className="text-xs text-muted-foreground">응답:</span> - {hasResponse ? ( - <Badge - variant={isUpToDate ? "default" : "outline"} - className={cn( - "text-xs font-mono", - !isUpToDate && "text-blue-600 border-blue-300" - )} - > - {response.respondedRevision} - </Badge> - ) : ( - <span className="text-xs text-muted-foreground">-</span> - )} - </div> - {hasResponse && !isUpToDate && versionLag > 0 && ( - <div className="flex items-center gap-1 text-xs text-blue-600"> - <AlertCircle className="h-3 w-3" /> - <span>{versionLag}버전 차이</span> - </div> - )} - {response.hasMultipleRevisions && ( - <div className="flex items-center gap-1 text-xs text-muted-foreground"> - <GitBranch className="h-3 w-3" /> - <span>다중 리비전</span> - </div> - )} - </div> - ); -} - -// 코멘트 표시 컴포넌트 -function CommentDisplayCell({ response }: { response: EnhancedVendorResponse }) { - const hasResponseComment = !!response.responseComment; - const hasVendorComment = !!response.vendorComment; - const hasRevisionRequestComment = !!response.revisionRequestComment; - const hasClientComment = !!response.attachment?.revisions?.find(r => r.revisionComment); - - const commentCount = [hasResponseComment, hasVendorComment, hasRevisionRequestComment, hasClientComment].filter(Boolean).length; - - if (commentCount === 0) { - return <span className="text-xs text-muted-foreground">-</span>; - } - - return ( - <div className="space-y-1"> - {hasResponseComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-blue-500" title="벤더 응답 코멘트"></div> - <span className="text-xs text-blue-600 truncate max-w-32" title={response.responseComment}> - {response.responseComment} - </span> - </div> - )} - - {hasVendorComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-green-500" title="벤더 내부 메모"></div> - <span className="text-xs text-green-600 truncate max-w-32" title={response.vendorComment}> - {response.vendorComment} - </span> - </div> - )} - - {hasRevisionRequestComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-red-500" title="수정 요청 사유"></div> - <span className="text-xs text-red-600 truncate max-w-32" title={response.revisionRequestComment}> - {response.revisionRequestComment} - </span> - </div> - )} - - {hasClientComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-orange-500" title="발주처 리비전 코멘트"></div> - <span className="text-xs text-orange-600 truncate max-w-32" - title={response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}> - {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment} - </span> - </div> - )} - - {/* <div className="text-xs text-muted-foreground text-center"> - {commentCount}개 - </div> */} - </div> - ); -} - -export function getColumns({ - setRowAction, -}: GetColumnsProps): ColumnDef<EnhancedVendorResponse>[] { - return [ - // 시리얼 번호 - 핀고정용 최소 너비 - { - accessorKey: "serialNo", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="시리얼" /> - ), - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.getValue("serialNo")}</div> - ), - - meta: { - excelHeader: "시리얼", - paddingFactor: 0.8 - }, - }, - - // 분류 - 핀고정용 적절한 너비 - { - accessorKey: "attachmentType", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="분류" /> - ), - cell: ({ row }) => ( - <div className="space-y-1"> - <div className="font-medium text-sm">{row.getValue("attachmentType")}</div> - {row.original.attachmentDescription && ( - <div className="text-xs text-muted-foreground truncate max-w-32" - title={row.original.attachmentDescription}> - {row.original.attachmentDescription} - </div> - )} - </div> - ), - - meta: { - excelHeader: "분류", - paddingFactor: 1.0 - }, - }, - - // 파일명 - 가장 긴 텍스트를 위한 여유 공간 - { - id: "fileName", - header: "파일명", - cell: ({ row }) => ( - <AttachmentFileNameCell revisions={row.original.attachment?.revisions || []} /> - ), - - meta: { - paddingFactor: 1.5 - }, - }, - - // 상태 - 뱃지 크기 고려 - { - accessorKey: "effectiveStatus", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="상태" /> - ), - cell: ({ row }) => { - const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus")); - const StatusIcon = statusInfo.icon; - - return ( - <div className="space-y-1"> - <Badge variant={statusInfo.variant} className="flex items-center gap-1 w-fit"> - <StatusIcon className="h-3 w-3" /> - <span>{statusInfo.label}</span> - </Badge> - {row.original.needsUpdate && ( - <div className="text-xs text-blue-600 flex items-center gap-1"> - <RefreshCw className="h-3 w-3" /> - <span>업데이트 권장</span> - </div> - )} - </div> - ); - }, - - meta: { - excelHeader: "상태", - paddingFactor: 1.2 - }, - }, - - // 리비전 현황 - 복합 정보로 넓은 공간 필요 - { - id: "revisionStatus", - header: "리비전 현황", - cell: ({ row }) => <RevisionComparisonCell response={row.original} />, - - meta: { - paddingFactor: 1.3 - }, - }, - - // 요청일 - 날짜 형식 고정 - { - accessorKey: "requestedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="요청일" /> - ), - cell: ({ row }) => ( - <div className="text-sm flex items-center gap-1"> - <Calendar className="h-3 w-3 text-muted-foreground" /> - <span className="whitespace-nowrap">{formatDateTime(new Date(row.getValue("requestedAt")))}</span> - </div> - ), - - meta: { - excelHeader: "요청일", - paddingFactor: 0.9 - }, - }, - - // 응답일 - 날짜 형식 고정 - { - accessorKey: "respondedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="응답일" /> - ), - cell: ({ row }) => ( - <div className="text-sm"> - <span className="whitespace-nowrap"> - {row.getValue("respondedAt") - ? formatDateTime(new Date(row.getValue("respondedAt"))) - : "-" - } - </span> - </div> - ), - meta: { - excelHeader: "응답일", - paddingFactor: 0.9 - }, - }, - - // 응답파일 - 작은 공간 - { - accessorKey: "totalResponseFiles", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="응답파일" /> - ), - cell: ({ row }) => ( - <div className="text-center"> - <div className="text-sm font-medium"> - {row.getValue("totalResponseFiles")}개 - </div> - {row.original.latestResponseFileName && ( - <div className="text-xs text-muted-foreground truncate max-w-20" - title={row.original.latestResponseFileName}> - {row.original.latestResponseFileName} - </div> - )} - </div> - ), - meta: { - excelHeader: "응답파일", - paddingFactor: 0.8 - }, - }, - - // 코멘트 - 가변 텍스트 길이 - { - id: "comments", - header: "코멘트", - cell: ({ row }) => <CommentDisplayCell response={row.original} />, - // size: 180, - meta: { - paddingFactor: 1.4 - }, - }, - - // 진행도 - 중간 크기 - { - id: "progress", - header: "진행도", - cell: ({ row }) => ( - <div className="space-y-1 text-center"> - {row.original.hasMultipleRevisions && ( - <Badge variant="outline" className="text-xs"> - 다중 리비전 - </Badge> - )} - {row.original.versionLag !== undefined && row.original.versionLag > 0 && ( - <div className="text-xs text-blue-600 whitespace-nowrap"> - {row.original.versionLag}버전 차이 - </div> - )} - </div> - ), - // size: 100, - meta: { - paddingFactor: 1.1 - }, - }, - -{ - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const response = row.original; - - 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"> - {/* 상태별 주요 액션들 */} - {response.effectiveStatus === "NOT_RESPONDED" && ( - <> - <DropdownMenuItem asChild> - <UploadResponseDialog - responseId={response.responseId} - attachmentType={response.attachmentType} - serialNo={response.serialNo} - currentRevision={response.currentRevision} - trigger={ - <div className="flex items-center w-full cursor-pointer p-2"> - <Upload className="size-4 mr-2" /> - 업로드 - </div> - } - /> - </DropdownMenuItem> - <DropdownMenuItem asChild> - <WaiveResponseDialog - responseId={response.responseId} - attachmentType={response.attachmentType} - serialNo={response.serialNo} - trigger={ - <div className="flex items-center w-full cursor-pointer p-2"> - <FileX className="size-4 mr-2" /> - 포기 - </div> - } - /> - </DropdownMenuItem> - </> - )} - - {response.effectiveStatus === "REVISION_REQUESTED" && ( - <DropdownMenuItem asChild> - <UploadResponseDialog - responseId={response.responseId} - attachmentType={response.attachmentType} - serialNo={response.serialNo} - currentRevision={response.currentRevision} - trigger={ - <div className="flex items-center w-full cursor-pointer p-2"> - <FileText className="size-4 mr-2" /> - 수정 - </div> - } - /> - </DropdownMenuItem> - )} - - {response.effectiveStatus === "VERSION_MISMATCH" && ( - <DropdownMenuItem asChild> - <UploadResponseDialog - responseId={response.responseId} - attachmentType={response.attachmentType} - serialNo={response.serialNo} - currentRevision={response.currentRevision} - trigger={ - <div className="flex items-center w-full cursor-pointer p-2"> - <RefreshCw className="size-4 mr-2" /> - 업데이트 - </div> - } - /> - </DropdownMenuItem> - )} - - {/* 구분선 - 주요 액션과 보조 액션 분리 */} - {(response.effectiveStatus === "NOT_RESPONDED" || - response.effectiveStatus === "REVISION_REQUESTED" || - response.effectiveStatus === "VERSION_MISMATCH") && - response.effectiveStatus !== "WAIVED" && ( - <DropdownMenuSeparator /> - )} - - {/* 공통 액션들 */} - {response.effectiveStatus !== "WAIVED" && ( - <DropdownMenuItem asChild> - <CommentEditDialog - responseId={response.responseId} - currentResponseComment={response.responseComment || ""} - currentVendorComment={response.vendorComment || ""} - trigger={ - <div className="flex items-center w-full cursor-pointer p-2"> - <MessageSquare className="size-4 mr-2" /> - 코멘트 편집 - </div> - } - /> - </DropdownMenuItem> - )} - - <DropdownMenuItem asChild> - <ResponseDetailSheet - response={response} - trigger={ - <div className="flex items-center w-full cursor-pointer p-2"> - <FileText className="size-4 mr-2" /> - 상세보기 - </div> - } - /> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - ) - }, - size: 40, -} - - ] -}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-sheet.tsx b/lib/b-rfq/vendor-response/response-detail-sheet.tsx deleted file mode 100644 index da7f9b01..00000000 --- a/lib/b-rfq/vendor-response/response-detail-sheet.tsx +++ /dev/null @@ -1,358 +0,0 @@ -// components/rfq/response-detail-sheet.tsx -"use client"; - -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { - FileText, - Upload, - Download, - AlertCircle, - MessageSquare, - FileCheck, - Eye -} from "lucide-react"; -import { formatDateTime, formatFileSize } from "@/lib/utils"; -import { cn } from "@/lib/utils"; -import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"; - -// 파일 다운로드 핸들러 (API 사용) -async function handleFileDownload( - filePath: string, - fileName: string, - type: "client" | "vendor" = "client", - id?: number -) { - try { - const params = new URLSearchParams({ - path: filePath, - type: type, - }); - - // ID가 있으면 추가 - if (id) { - if (type === "client") { - params.append("revisionId", id.toString()); - } else { - params.append("responseFileId", id.toString()); - } - } - - const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Download failed: ${response.status}`); - } - - // Blob으로 파일 데이터 받기 - const blob = await response.blob(); - - // 임시 URL 생성하여 다운로드 - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - - // 정리 - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - - console.log("✅ 파일 다운로드 성공:", fileName); - - } catch (error) { - console.error("❌ 파일 다운로드 실패:", error); - - // 사용자에게 에러 알림 (토스트나 알럿으로 대체 가능) - alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); - } -} - -// 효과적인 상태별 아이콘 및 색상 -function getEffectiveStatusInfo(effectiveStatus: string) { - switch (effectiveStatus) { - case "NOT_RESPONDED": - return { - label: "미응답", - variant: "outline" as const - }; - case "UP_TO_DATE": - return { - label: "최신", - variant: "default" as const - }; - case "VERSION_MISMATCH": - return { - label: "업데이트 필요", - variant: "secondary" as const - }; - case "REVISION_REQUESTED": - return { - label: "수정요청", - variant: "secondary" as const - }; - case "WAIVED": - return { - label: "포기", - variant: "outline" as const - }; - default: - return { - label: effectiveStatus, - variant: "outline" as const - }; - } -} - -interface ResponseDetailSheetProps { - response: EnhancedVendorResponse; - trigger?: React.ReactNode; -} - -export function ResponseDetailSheet({ response, trigger }: ResponseDetailSheetProps) { - const hasMultipleRevisions = response.attachment?.revisions && response.attachment.revisions.length > 1; - const hasResponseFiles = response.responseAttachments && response.responseAttachments.length > 0; - - return ( - <Sheet> - <SheetTrigger asChild> - {trigger || ( - <Button size="sm" variant="ghost"> - <Eye className="h-3 w-3 mr-1" /> - 상세 - </Button> - )} - </SheetTrigger> - <SheetContent side="right" className="w-[600px] sm:w-[800px] overflow-y-auto"> - <SheetHeader> - <SheetTitle className="flex items-center gap-2"> - <FileText className="h-5 w-5" /> - 상세 정보 - {response.serialNo} - </SheetTitle> - <SheetDescription> - {response.attachmentType} • {response.attachment?.revisions?.[0]?.originalFileName} - </SheetDescription> - </SheetHeader> - - <div className="space-y-6 mt-6"> - {/* 기본 정보 */} - <div className="space-y-4"> - <h3 className="text-lg font-semibold flex items-center gap-2"> - <AlertCircle className="h-4 w-4" /> - 기본 정보 - </h3> - <div className="grid grid-cols-2 gap-4 p-4 bg-muted/30 rounded-lg"> - <div> - <div className="text-sm text-muted-foreground">상태</div> - <div className="font-medium">{getEffectiveStatusInfo(response.effectiveStatus).label}</div> - </div> - <div> - <div className="text-sm text-muted-foreground">현재 리비전</div> - <div className="font-medium">{response.currentRevision}</div> - </div> - <div> - <div className="text-sm text-muted-foreground">응답 리비전</div> - <div className="font-medium">{response.respondedRevision || "-"}</div> - </div> - <div> - <div className="text-sm text-muted-foreground">응답일</div> - <div className="font-medium"> - {response.respondedAt ? formatDateTime(new Date(response.respondedAt)) : "-"} - </div> - </div> - <div> - <div className="text-sm text-muted-foreground">요청일</div> - <div className="font-medium"> - {formatDateTime(new Date(response.requestedAt))} - </div> - </div> - <div> - <div className="text-sm text-muted-foreground">응답 파일 수</div> - <div className="font-medium">{response.totalResponseFiles}개</div> - </div> - </div> - </div> - - {/* 코멘트 정보 */} - <div className="space-y-4"> - <h3 className="text-lg font-semibold flex items-center gap-2"> - <MessageSquare className="h-4 w-4" /> - 코멘트 - </h3> - <div className="space-y-3"> - {response.responseComment && ( - <div className="p-3 border-l-4 border-blue-500 bg-blue-50"> - <div className="text-sm font-medium text-blue-700 mb-1">발주처 응답 코멘트</div> - <div className="text-sm">{response.responseComment}</div> - </div> - )} - {response.vendorComment && ( - <div className="p-3 border-l-4 border-green-500 bg-green-50"> - <div className="text-sm font-medium text-green-700 mb-1">내부 메모</div> - <div className="text-sm">{response.vendorComment}</div> - </div> - )} - {response.attachment?.revisions?.find(r => r.revisionComment) && ( - <div className="p-3 border-l-4 border-orange-500 bg-orange-50"> - <div className="text-sm font-medium text-orange-700 mb-1">발주처 요청 사항</div> - <div className="text-sm"> - {response.attachment.revisions.find(r => r.revisionComment)?.revisionComment} - </div> - </div> - )} - {!response.responseComment && !response.vendorComment && !response.attachment?.revisions?.find(r => r.revisionComment) && ( - <div className="text-center text-muted-foreground py-4 bg-muted/20 rounded-lg"> - 코멘트가 없습니다. - </div> - )} - </div> - </div> - - {/* 발주처 리비전 히스토리 */} - {hasMultipleRevisions && ( - <div className="space-y-4"> - <h3 className="text-lg font-semibold flex items-center gap-2"> - <FileCheck className="h-4 w-4" /> - 발주처 리비전 히스토리 ({response.attachment!.revisions.length}개) - </h3> - <div className="space-y-3"> - {response.attachment!.revisions - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .map((revision) => ( - <div - key={revision.id} - className={cn( - "flex items-center justify-between p-4 rounded-lg border", - revision.isLatest ? "bg-blue-50 border-blue-200" : "bg-white" - )} - > - <div className="flex items-center gap-3 flex-1"> - <Badge variant={revision.isLatest ? "default" : "outline"}> - {revision.revisionNo} - </Badge> - <div className="flex-1"> - <div className="font-medium text-sm">{revision.originalFileName}</div> - <div className="text-xs text-muted-foreground"> - {formatFileSize(revision.fileSize)} • {formatDateTime(new Date(revision.createdAt))} - </div> - {revision.revisionComment && ( - <div className="text-xs text-muted-foreground mt-1 italic"> - "{revision.revisionComment}" - </div> - )} - </div> - </div> - - <div className="flex items-center gap-2"> - {revision.isLatest && ( - <Badge variant="secondary" className="text-xs">최신</Badge> - )} - {revision.revisionNo === response.respondedRevision && ( - <Badge variant="outline" className="text-xs text-green-600 border-green-300"> - 응답됨 - </Badge> - )} - <Button - size="sm" - variant="ghost" - onClick={() => { - if (revision.filePath) { - handleFileDownload( - revision.filePath, - revision.originalFileName, - "client", - revision.id - ); - } - }} - disabled={!revision.filePath} - title="파일 다운로드" - > - <Download className="h-4 w-4" /> - </Button> - </div> - </div> - ))} - </div> - </div> - )} - - {/* 벤더 응답 파일들 */} - {hasResponseFiles && ( - <div className="space-y-4"> - <h3 className="text-lg font-semibold flex items-center gap-2"> - <Upload className="h-4 w-4" /> - 벤더 응답 파일들 ({response.totalResponseFiles}개) - </h3> - <div className="space-y-3"> - {response.responseAttachments! - .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()) - .map((file) => ( - <div key={file.id} className="flex items-center justify-between p-4 rounded-lg border bg-green-50 border-green-200"> - <div className="flex items-center gap-3 flex-1"> - <Badge variant="outline" className="bg-green-100"> - 파일 #{file.fileSequence} - </Badge> - <div className="flex-1"> - <div className="font-medium text-sm">{file.originalFileName}</div> - <div className="text-xs text-muted-foreground"> - {formatFileSize(file.fileSize)} • {formatDateTime(new Date(file.uploadedAt))} - </div> - {file.description && ( - <div className="text-xs text-muted-foreground mt-1 italic"> - "{file.description}" - </div> - )} - </div> - </div> - - <div className="flex items-center gap-2"> - {file.isLatestResponseFile && ( - <Badge variant="secondary" className="text-xs">최신</Badge> - )} - <Button - size="sm" - variant="ghost" - onClick={() => { - if (file.filePath) { - handleFileDownload( - file.filePath, - file.originalFileName, - "vendor", - file.id - ); - } - }} - disabled={!file.filePath} - title="파일 다운로드" - > - <Download className="h-4 w-4" /> - </Button> - </div> - </div> - ))} - </div> - </div> - )} - - {!hasMultipleRevisions && !hasResponseFiles && ( - <div className="text-center text-muted-foreground py-8 bg-muted/20 rounded-lg"> - <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" /> - <p>추가 파일이나 리비전 정보가 없습니다.</p> - </div> - )} - </div> - </SheetContent> - </Sheet> - ); -}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-table.tsx b/lib/b-rfq/vendor-response/response-detail-table.tsx deleted file mode 100644 index 124d5241..00000000 --- a/lib/b-rfq/vendor-response/response-detail-table.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client" - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" -import { DataTableAdvancedFilterField } from "@/types/table" -import { DataTableRowAction, getColumns } from "./response-detail-columns" - -interface FinalRfqResponseTableProps { - data: EnhancedVendorResponse[] - // ✅ 헤더 정보를 props로 받기 - statistics?: { - total: number - upToDate: number - versionMismatch: number - pending: number - revisionRequested: number - waived: number - } - showHeader?: boolean - title?: string -} - -/** - * FinalRfqResponseTable: RFQ 응답 데이터를 표시하는 표 - */ -export function FinalRfqResponseTable({ - data, - statistics, - showHeader = true, - title = "첨부파일별 응답 현황" -}: FinalRfqResponseTableProps) { - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<EnhancedVendorResponse> | null>(null) - - const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction] - ) - - // 고급 필터 필드 정의 - const advancedFilterFields: DataTableAdvancedFilterField<EnhancedVendorResponse>[] = [ - { - id: "effectiveStatus", - label: "상태", - type: "select", - options: [ - { label: "미응답", value: "NOT_RESPONDED" }, - { label: "최신", value: "UP_TO_DATE" }, - { label: "업데이트 필요", value: "VERSION_MISMATCH" }, - { label: "수정요청", value: "REVISION_REQUESTED" }, - { label: "포기", value: "WAIVED" }, - ], - }, - { - id: "attachmentType", - label: "첨부파일 분류", - type: "text", - }, - { - id: "serialNo", - label: "시리얼 번호", - type: "text", - }, - { - id: "isVersionMatched", - label: "버전 일치", - type: "select", - options: [ - { label: "일치", value: "true" }, - { label: "불일치", value: "false" }, - ], - }, - { - id: "hasMultipleRevisions", - label: "다중 리비전", - type: "select", - options: [ - { label: "있음", value: "true" }, - { label: "없음", value: "false" }, - ], - }, - ] - - if (data.length === 0) { - return ( - <div className="border rounded-lg p-12 text-center"> - <div className="mx-auto mb-4 h-12 w-12 text-muted-foreground"> - 📄 - </div> - <p className="text-muted-foreground">응답할 첨부파일이 없습니다.</p> - </div> - ) - } - - return ( - // ✅ 상위 컨테이너 구조 단순화 및 너비 제한 해제 -<> - {/* 코멘트 범례 */} - <div className="flex items-center gap-6 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg"> - <span className="font-medium">코멘트 범례:</span> - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-blue-500"></div> - <span>벤더 응답</span> - </div> - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-green-500"></div> - <span>내부 메모</span> - </div> - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-red-500"></div> - <span>수정 요청</span> - </div> - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-orange-500"></div> - <span>발주처 리비전</span> - </div> - </div> - <div style={{ - width: '100%', - maxWidth: '100%', - overflow: 'hidden', - contain: 'layout' - }}> - {/* 데이터 테이블 - 컨테이너 제약 최소화 */} - <ClientDataTable - data={data} - columns={columns} - advancedFilterFields={advancedFilterFields} - autoSizeColumns={true} - compact={true} - // ✅ RFQ 테이블에 맞는 컬럼 핀고정 - initialColumnPinning={{ - left: ["serialNo", "attachmentType"], - right: ["actions"] - }} - > - {showHeader && ( - <div className="flex items-center justify-between"> - - {statistics && ( - <div className="flex items-center gap-4 text-sm text-muted-foreground"> - <span>전체 {statistics.total}개</span> - <span className="text-green-600">최신 {statistics.upToDate}개</span> - <span className="text-blue-600">업데이트필요 {statistics.versionMismatch}개</span> - <span className="text-orange-600">미응답 {statistics.pending}개</span> - {statistics.revisionRequested > 0 && ( - <span className="text-yellow-600">수정요청 {statistics.revisionRequested}개</span> - )} - {statistics.waived > 0 && ( - <span className="text-gray-600">포기 {statistics.waived}개</span> - )} - </div> - )} - </div> - )} - </ClientDataTable> - </div> - </> - ) -} diff --git a/lib/b-rfq/vendor-response/upload-response-dialog.tsx b/lib/b-rfq/vendor-response/upload-response-dialog.tsx deleted file mode 100644 index b4b306d6..00000000 --- a/lib/b-rfq/vendor-response/upload-response-dialog.tsx +++ /dev/null @@ -1,325 +0,0 @@ -// components/rfq/upload-response-dialog.tsx -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { Upload, FileText, X, Loader2 } from "lucide-react"; -import { useToast } from "@/hooks/use-toast" -import { useRouter } from "next/navigation"; - -const uploadFormSchema = z.object({ - files: z.array(z.instanceof(File)).min(1, "최소 1개의 파일을 선택해주세요"), - responseComment: z.string().optional(), - vendorComment: z.string().optional(), -}); - -type UploadFormData = z.infer<typeof uploadFormSchema>; - -interface UploadResponseDialogProps { - responseId: number; - attachmentType: string; - serialNo: string; - currentRevision: string; - trigger?: React.ReactNode; - onSuccess?: () => void; -} - -export function UploadResponseDialog({ - responseId, - attachmentType, - serialNo, - currentRevision, - trigger, - onSuccess, -}: UploadResponseDialogProps) { - const [open, setOpen] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const { toast } = useToast(); - const router = useRouter(); - - const form = useForm<UploadFormData>({ - resolver: zodResolver(uploadFormSchema), - defaultValues: { - files: [], - responseComment: "", - vendorComment: "", - }, - }); - - const selectedFiles = form.watch("files"); - - const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { - const files = Array.from(e.target.files || []); - if (files.length > 0) { - form.setValue("files", files); - } - }; - - const removeFile = (index: number) => { - const currentFiles = form.getValues("files"); - const newFiles = currentFiles.filter((_, i) => i !== index); - form.setValue("files", newFiles); - }; - - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; - }; - - const handleOpenChange = (newOpen: boolean) => { - setOpen(newOpen); - // 다이얼로그가 닫힐 때 form 리셋 - if (!newOpen) { - form.reset(); - } - }; - - const handleCancel = () => { - form.reset(); - setOpen(false); - }; - - const onSubmit = async (data: UploadFormData) => { - setIsUploading(true); - - try { - // 1. 각 파일을 업로드 API로 전송 - const uploadedFiles = []; - - for (const file of data.files) { - const formData = new FormData(); - formData.append("file", file); - formData.append("responseId", responseId.toString()); - formData.append("description", ""); // 필요시 파일별 설명 추가 가능 - - const uploadResponse = await fetch("/api/vendor-responses/upload", { - method: "POST", - body: formData, - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json(); - throw new Error(error.message || "파일 업로드 실패"); - } - - const uploadResult = await uploadResponse.json(); - uploadedFiles.push(uploadResult); - } - - // 2. vendor response 상태 업데이트 (서버에서 자동으로 리비전 증가) - const updateResponse = await fetch("/api/vendor-responses/update", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - responseId, - responseStatus: "RESPONDED", - // respondedRevision 제거 - 서버에서 자동 처리 - responseComment: data.responseComment, - vendorComment: data.vendorComment, - respondedAt: new Date().toISOString(), - }), - }); - - if (!updateResponse.ok) { - const error = await updateResponse.json(); - throw new Error(error.message || "응답 상태 업데이트 실패"); - } - - const updateResult = await updateResponse.json(); - - toast({ - title: "업로드 완료", - description: `${data.files.length}개 파일이 성공적으로 업로드되었습니다. (${updateResult.newRevision})`, - }); - - setOpen(false); - form.reset(); - - router.refresh(); - onSuccess?.(); - - } catch (error) { - console.error("Upload error:", error); - toast({ - title: "업로드 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", - variant: "destructive", - }); - } finally { - setIsUploading(false); - } - }; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - {trigger || ( - <Button size="sm"> - <Upload className="h-3 w-3 mr-1" /> - 업로드 - </Button> - )} - </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Upload className="h-5 w-5" /> - 응답 파일 업로드 - </DialogTitle> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <Badge variant="outline">{serialNo}</Badge> - <span>{attachmentType}</span> - <Badge variant="secondary">{currentRevision}</Badge> - <span className="text-xs text-blue-600">→ 벤더 응답 리비전 자동 증가</span> - </div> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - {/* 파일 선택 */} - <FormField - control={form.control} - name="files" - render={({ field }) => ( - <FormItem> - <FormLabel>파일 선택</FormLabel> - <FormControl> - <div className="space-y-4"> - <Input - type="file" - multiple - onChange={handleFileSelect} - accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.zip,.rar" - className="cursor-pointer" - /> - <div className="text-xs text-muted-foreground"> - 지원 파일: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, ZIP, RAR (최대 10MB) - </div> - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 선택된 파일 목록 */} - {selectedFiles.length > 0 && ( - <div className="space-y-2"> - <div className="text-sm font-medium">선택된 파일 ({selectedFiles.length}개)</div> - <div className="space-y-2 max-h-40 overflow-y-auto"> - {selectedFiles.map((file, index) => ( - <div - key={index} - className="flex items-center justify-between p-3 bg-muted/50 rounded-lg" - > - <div className="flex items-center gap-2 flex-1 min-w-0"> - <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" /> - <div className="min-w-0 flex-1"> - <div className="text-sm font-medium truncate">{file.name}</div> - <div className="text-xs text-muted-foreground"> - {formatFileSize(file.size)} - </div> - </div> - </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeFile(index)} - className="flex-shrink-0 ml-2" - > - <X className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 응답 코멘트 */} - <FormField - control={form.control} - name="responseComment" - render={({ field }) => ( - <FormItem> - <FormLabel>응답 코멘트</FormLabel> - <FormControl> - <Textarea - placeholder="응답에 대한 설명을 입력하세요..." - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 벤더 코멘트 */} - <FormField - control={form.control} - name="vendorComment" - render={({ field }) => ( - <FormItem> - <FormLabel>벤더 코멘트 (내부용)</FormLabel> - <FormControl> - <Textarea - placeholder="내부 참고용 코멘트를 입력하세요..." - className="resize-none" - rows={2} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 버튼 */} - <div className="flex justify-end gap-2"> - <Button - type="button" - variant="outline" - onClick={handleCancel} - disabled={isUploading} - > - 취소 - </Button> - <Button type="submit" disabled={isUploading || selectedFiles.length === 0}> - {isUploading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} - {isUploading ? "업로드 중..." : "업로드"} - </Button> - </div> - </form> - </Form> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx deleted file mode 100644 index 47b7570b..00000000 --- a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx +++ /dev/null @@ -1,351 +0,0 @@ -// lib/vendor-responses/table/vendor-responses-table-columns.tsx -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { - Ellipsis, FileText, Pencil, Edit, Trash2, - Eye, MessageSquare, Clock, CheckCircle, AlertTriangle, FileX -} from "lucide-react" -import { formatDate, formatDateTime } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import Link from "next/link" -import { useRouter } from "next/navigation" -import { VendorResponseDetail } from "../service" -import { VendorRfqResponseSummary } from "../validations" - -// 응답 상태에 따른 배지 컴포넌트 -function ResponseStatusBadge({ status }: { status: string }) { - switch (status) { - case "NOT_RESPONDED": - return ( - <Badge variant="outline" className="text-orange-600 border-orange-600"> - <Clock className="mr-1 h-3 w-3" /> - 미응답 - </Badge> - ) - case "RESPONDED": - return ( - <Badge variant="default" className="bg-green-600 text-white"> - <CheckCircle className="mr-1 h-3 w-3" /> - 응답완료 - </Badge> - ) - case "REVISION_REQUESTED": - return ( - <Badge variant="secondary" className="text-yellow-600 border-yellow-600"> - <AlertTriangle className="mr-1 h-3 w-3" /> - 수정요청 - </Badge> - ) - case "WAIVED": - return ( - <Badge variant="outline" className="text-gray-600 border-gray-600"> - <FileX className="mr-1 h-3 w-3" /> - 포기 - </Badge> - ) - default: - return <Badge>{status}</Badge> - } -} - - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - router: NextRouter -} - -/** - * tanstack table 컬럼 정의 - */ -export function getColumns({ - router, -}: GetColumnsProps): ColumnDef<VendorResponseDetail>[] { - - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorRfqResponseSummary> = { - 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 컬럼 (작성하기 버튼만) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorRfqResponseSummary> = { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const vendorId = row.original.vendorId - const rfqRecordId = row.original.rfqRecordId - const rfqType = row.original.rfqType - const rfqCode = row.original.rfq?.rfqCode || "RFQ" - - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="sm" - onClick={() => router.push(`/partners/rfq-answer/${vendorId}/${rfqRecordId}`)} - className="h-8 px-3" - > - <Edit className="h-4 w-4 mr-1" /> - 작성하기 - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{rfqCode} 응답 작성하기</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - }, - size: 100, - minSize: 100, - maxSize: 150, - } - - // ---------------------------------------------------------------- - // 3) 컬럼 정의 배열 - // ---------------------------------------------------------------- - const columnDefinitions = [ - { - id: "rfqCode", - label: "RFQ 번호", - group: "RFQ 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - - { - id: "rfqDueDate", - label: "RFQ 마감일", - group: "RFQ 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - - { - id: "overallStatus", - label: "전체 상태", - group: null, - size: 80, - minSize: 60, - maxSize: 100, - }, - { - id: "totalAttachments", - label: "총 첨부파일", - group: "응답 통계", - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "respondedCount", - label: "응답완료", - group: "응답 통계", - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "pendingCount", - label: "미응답", - group: "응답 통계", - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "responseRate", - label: "응답률", - group: "진행률", - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "completionRate", - label: "완료율", - group: "진행률", - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "requestedAt", - label: "요청일", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "lastRespondedAt", - label: "최종 응답일", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - ]; - - // ---------------------------------------------------------------- - // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성) - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorRfqResponseSummary>[]> = {} - - columnDefinitions.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // 개별 컬럼 정의 - const columnDef: ColumnDef<VendorRfqResponseSummary> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - cell: ({ row, cell }) => { - // 각 컬럼별 특별한 렌더링 처리 - switch (cfg.id) { - case "rfqCode": - return row.original.rfq?.rfqCode || "-" - - - case "rfqDueDate": - const dueDate = row.original.rfq?.dueDate; - return dueDate ? formatDate(new Date(dueDate)) : "-"; - - case "overallStatus": - return <ResponseStatusBadge status={row.original.overallStatus} /> - - case "totalAttachments": - return ( - <div className="text-center font-medium"> - {row.original.totalAttachments} - </div> - ) - - case "respondedCount": - return ( - <div className="text-center text-green-600 font-medium"> - {row.original.respondedCount} - </div> - ) - - case "pendingCount": - return ( - <div className="text-center text-orange-600 font-medium"> - {row.original.pendingCount} - </div> - ) - - case "responseRate": - const responseRate = row.original.responseRate; - return ( - <div className="text-center"> - <span className={`font-medium ${responseRate >= 80 ? 'text-green-600' : responseRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> - {responseRate}% - </span> - </div> - ) - - case "completionRate": - const completionRate = row.original.completionRate; - return ( - <div className="text-center"> - <span className={`font-medium ${completionRate >= 80 ? 'text-green-600' : completionRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> - {completionRate}% - </span> - </div> - ) - - case "requestedAt": - return formatDateTime(new Date(row.original.requestedAt)) - - case "lastRespondedAt": - const lastRespondedAt = row.original.lastRespondedAt; - return lastRespondedAt ? formatDateTime(new Date(lastRespondedAt)) : "-"; - - default: - return row.getValue(cfg.id) ?? "" - } - }, - size: cfg.size, - minSize: cfg.minSize, - maxSize: cfg.maxSize, - } - - groupMap[groupName].push(columnDef) - }) - - // ---------------------------------------------------------------- - // 5) 그룹별 중첩 컬럼 생성 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<VendorRfqResponseSummary>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // 그룹이 없는 컬럼들은 직접 추가 - nestedColumns.push(...colDefs) - } else { - // 그룹이 있는 컬럼들은 중첩 구조로 추가 - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - actionsColumn, - ] -}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/vendor-responses-table.tsx b/lib/b-rfq/vendor-response/vendor-responses-table.tsx deleted file mode 100644 index 02a5fa59..00000000 --- a/lib/b-rfq/vendor-response/vendor-responses-table.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// lib/vendor-responses/table/vendor-responses-table.tsx -"use client" - -import * as React from "react" -import { type DataTableAdvancedFilterField, type DataTableFilterField, type 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 { Button } from "@/components/ui/button" -import { useRouter } from "next/navigation" -import { getColumns } from "./vendor-responses-table-columns" -import { VendorRfqResponseSummary } from "../validations" - -interface VendorResponsesTableProps { - promises: Promise<[{ data: VendorRfqResponseSummary[], pageCount: number, totalCount: number }]>; -} - -export function VendorResponsesTable({ promises }: VendorResponsesTableProps) { - const [{ data, pageCount, totalCount }] = React.use(promises); - const router = useRouter(); - - console.log(data, "vendor responses data") - - // 선택된 행 액션 상태 - - // 테이블 컬럼 정의 - const columns = React.useMemo(() => getColumns({ - router, - }), [router]); - - // 상태별 응답 수 계산 (전체 상태 기준) - const statusCounts = React.useMemo(() => { - return { - NOT_RESPONDED: data.filter(r => r.overallStatus === "NOT_RESPONDED").length, - RESPONDED: data.filter(r => r.overallStatus === "RESPONDED").length, - REVISION_REQUESTED: data.filter(r => r.overallStatus === "REVISION_REQUESTED").length, - WAIVED: data.filter(r => r.overallStatus === "WAIVED").length, - }; - }, [data]); - - - // 필터 필드 - const filterFields: DataTableFilterField<VendorRfqResponseSummary>[] = [ - { - id: "overallStatus", - label: "전체 상태", - options: [ - { label: "미응답", value: "NOT_RESPONDED", count: statusCounts.NOT_RESPONDED }, - { label: "응답완료", value: "RESPONDED", count: statusCounts.RESPONDED }, - { label: "수정요청", value: "REVISION_REQUESTED", count: statusCounts.REVISION_REQUESTED }, - { label: "포기", value: "WAIVED", count: statusCounts.WAIVED }, - ] - }, - - - ]; - - // 고급 필터 필드 - const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [ - - { - id: "overallStatus", - label: "전체 상태", - type: "multi-select", - options: [ - { label: "미응답", value: "NOT_RESPONDED" }, - { label: "응답완료", value: "RESPONDED" }, - { label: "수정요청", value: "REVISION_REQUESTED" }, - { label: "포기", value: "WAIVED" }, - ], - }, - { - id: "rfqType", - label: "RFQ 타입", - type: "multi-select", - options: [ - { label: "초기 RFQ", value: "INITIAL" }, - { label: "최종 RFQ", value: "FINAL" }, - ], - }, - { - id: "responseRate", - label: "응답률", - type: "number", - }, - { - id: "completionRate", - label: "완료율", - type: "number", - }, - { - id: "requestedAt", - label: "요청일", - type: "date", - }, - { - id: "lastRespondedAt", - label: "최종 응답일", - type: "date", - }, - ]; - - // useDataTable 훅 사용 - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - enableColumnResizing: true, - columnResizeMode: 'onChange', - initialState: { - sorting: [{ id: "updatedAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - defaultColumn: { - minSize: 50, - maxSize: 500, - }, - }); - - return ( - <div className="w-full"> - <div className="flex items-center justify-between py-4"> - <div className="flex items-center space-x-2"> - <span className="text-sm text-muted-foreground"> - 총 {totalCount}개의 응답 요청 - </span> - </div> - </div> - - <div className="overflow-x-auto"> - <DataTable - table={table} - className="min-w-full" - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - {/* 추가적인 액션 버튼들을 여기에 추가할 수 있습니다 */} - </DataTableAdvancedToolbar> - </DataTable> - </div> - </div> - ); -}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/waive-response-dialog.tsx b/lib/b-rfq/vendor-response/waive-response-dialog.tsx deleted file mode 100644 index 5ded4da3..00000000 --- a/lib/b-rfq/vendor-response/waive-response-dialog.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// components/rfq/waive-response-dialog.tsx -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Textarea } from "@/components/ui/textarea"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { FileX, Loader2, AlertTriangle } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { useRouter } from "next/navigation"; - -const waiveFormSchema = z.object({ - responseComment: z.string().min(1, "포기 사유를 입력해주세요"), - vendorComment: z.string().optional(), -}); - -type WaiveFormData = z.infer<typeof waiveFormSchema>; - -interface WaiveResponseDialogProps { - responseId: number; - attachmentType: string; - serialNo: string; - trigger?: React.ReactNode; - onSuccess?: () => void; -} - -export function WaiveResponseDialog({ - responseId, - attachmentType, - serialNo, - trigger, - onSuccess, -}: WaiveResponseDialogProps) { - const [open, setOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const { toast } = useToast(); - const router = useRouter(); - - const form = useForm<WaiveFormData>({ - resolver: zodResolver(waiveFormSchema), - defaultValues: { - responseComment: "", - vendorComment: "", - }, - }); - - const onSubmit = async (data: WaiveFormData) => { - setIsSubmitting(true); - - try { - const response = await fetch("/api/vendor-responses/waive", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - responseId, - responseComment: data.responseComment, - vendorComment: data.vendorComment, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || "응답 포기 처리 실패"); - } - - toast({ - title: "응답 포기 완료", - description: "해당 항목에 대한 응답이 포기 처리되었습니다.", - }); - - setOpen(false); - form.reset(); - - router.refresh(); - onSuccess?.(); - - } catch (error) { - console.error("Waive error:", error); - toast({ - title: "처리 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", - variant: "destructive", - }); - } finally { - setIsSubmitting(false); - } - }; - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - {trigger || ( - <Button size="sm" variant="outline"> - <FileX className="h-3 w-3 mr-1" /> - 포기 - </Button> - )} - </DialogTrigger> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2 text-orange-600"> - <FileX className="h-5 w-5" /> - 응답 포기 - </DialogTitle> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <Badge variant="outline">{serialNo}</Badge> - <span>{attachmentType}</span> - </div> - </DialogHeader> - - <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4"> - <div className="flex items-center gap-2 text-orange-800 text-sm font-medium mb-2"> - <AlertTriangle className="h-4 w-4" /> - 주의사항 - </div> - <p className="text-orange-700 text-sm"> - 응답을 포기하면 해당 항목에 대한 입찰 참여가 불가능합니다. - 포기 사유를 명확히 기입해 주세요. - </p> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - {/* 포기 사유 (필수) */} - <FormField - control={form.control} - name="responseComment" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-red-600"> - 포기 사유 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Textarea - placeholder="응답을 포기하는 사유를 구체적으로 입력하세요..." - className="resize-none" - rows={4} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 내부 코멘트 (선택) */} - <FormField - control={form.control} - name="vendorComment" - render={({ field }) => ( - <FormItem> - <FormLabel>내부 코멘트 (선택)</FormLabel> - <FormControl> - <Textarea - placeholder="내부 참고용 코멘트를 입력하세요..." - className="resize-none" - rows={2} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 버튼 */} - <div className="flex justify-end gap-2"> - <Button - type="button" - variant="outline" - onClick={() => setOpen(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - variant="destructive" - disabled={isSubmitting} - > - {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} - {isSubmitting ? "처리 중..." : "포기하기"} - </Button> - </div> - </form> - </Form> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/lib/cbe/table/cbe-table-columns.tsx b/lib/cbe/table/cbe-table-columns.tsx deleted file mode 100644 index 552a0249..00000000 --- a/lib/cbe/table/cbe-table-columns.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } 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, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" - - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (responseId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithTbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "responseStatus") { - const statusVal = row.original.responseStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) CBE Updated (날짜) - if (cfg.id === "respondedAt") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal, "KR") - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - -// 댓글 칼럼 -const commentsColumn: ColumnDef<VendorWithCbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // setRowAction() 로 type 설정 - setRowAction({ row, type: "comments" }) - // 필요하면 즉시 openCommentSheet() 직접 호출 - openCommentSheet(vendor.responseId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - minSize: 80, -} -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - commentsColumn, - // actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/cbe/table/cbe-table-toolbar-actions.tsx b/lib/cbe/table/cbe-table-toolbar-actions.tsx deleted file mode 100644 index 34b5b46c..00000000 --- a/lib/cbe/table/cbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { InviteVendorsDialog } from "./invite-vendors-dialog" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithCbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0 - ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))] - : []; - -const hasMultipleRfqIds = uniqueRfqIds.length > 1; - -const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.commercialResponseStatus === null); -}, [table.getFilteredSelectedRowModel().rows]); - -return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - hasMultipleRfqIds={hasMultipleRfqIds} - /> - )} - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - 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/cbe/table/cbe-table.tsx b/lib/cbe/table/cbe-table.tsx deleted file mode 100644 index 38a0a039..00000000 --- a/lib/cbe/table/cbe-table.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -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 { getColumns } from "./cbe-table-columns" -import { CommentSheet, CbeComment } from "./comments-sheet" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { fetchRfqAttachmentsbyCommentId, getAllCBE } from "@/lib/rfqs/service" -import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { VendorContactsDialog } from "@/lib/rfqs/cbe-table/vendor-contact-dialog" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 - - - -import { toast } from "sonner" - -interface VendorsTableProps { - promises: Promise<[ - Awaited<ReturnType<typeof getAllCBE>>, - ]> -} - -export function AllCbeTable({ promises }: VendorsTableProps) { - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const { data: session } = useSession() // 세션 정보 가져오기 - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - const currentUser = session?.user - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - // **router** 획득 - const router = useRouter() - // 댓글 시트 관련 state - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - - // ----------------------------------------------------------- - // 특정 action이 설정될 때마다 실행되는 effect - // ----------------------------------------------------------- - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - // ----------------------------------------------------------- - // 댓글 시트 열기 - // ----------------------------------------------------------- - async function openCommentSheet(responseId: number) { - setInitialComments([]) - setIsLoadingComments(true) - const comments = rowAction?.row.original.comments - const rfqId = rowAction?.row.original.rfqId - const vendorId = rowAction?.row.original.vendorId - try { - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 - setInitialComments(commentWithAttachments) - } - - if(vendorId){ setSelectedVendorId(vendorId)} - if(rfqId){ setSelectedRfqId(rfqId)} - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - }catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } -} - -const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) -} - - // ----------------------------------------------------------- - // 테이블 컬럼 - // ----------------------------------------------------------- - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), - [setRowAction, router] - ) - - // ----------------------------------------------------------- - // 필터 필드 - // ----------------------------------------------------------- - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [ - // 예: 표준 필터 - ] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "respondedAt", label: "Updated at", type: "date" }, - ] - - // ----------------------------------------------------------- - // 테이블 생성 훅 - // ----------------------------------------------------------- - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["comments"] }, - }, - getRowId: (originalRow) => (`${originalRow.vendorId}${originalRow.rfqId}`), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={selectedRfqId ?? 0} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 댓글 시트 */} - <CommentSheet - currentUserId={currentUserId} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - vendorId={selectedVendorId ?? 0} - rfqId={selectedRfqId ?? 0} - cbeId={selectedCbeId ?? 0} - isLoading={isLoadingComments} - initialComments={initialComments} - /> - - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={selectedRfqId ?? 0} - open={rowAction?.type === "invite"} - showTrigger={false} - currentUser={currentUser} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/cbe/table/comments-sheet.tsx b/lib/cbe/table/comments-sheet.tsx deleted file mode 100644 index b4647e7a..00000000 --- a/lib/cbe/table/comments-sheet.tsx +++ /dev/null @@ -1,345 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X ,Loader2} from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Textarea, -} from "@/components/ui/textarea" - -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput -} from "@/components/ui/dropzone" - -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell -} from "@/components/ui/table" - -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -// (실제 프로젝트에 맞춰 import를 수정하세요.) -import { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" - -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 -export interface CbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: CbeComment[] - currentUserId: number - rfqId: number - // tbeId?: number - cbeId?: number - vendorId: number - onCommentsUpdated?: (comments: CbeComment[]) => void - isLoading?: boolean // New prop -} - -// 새 코멘트 작성 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional() // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - // tbeId, - cbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - const [comments, setComments] = React.useState<CbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - - // RHF 세팅 - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [] - } - }) - - // formFieldArray 예시 (파일 목록) - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles" - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {/* 첨부파일 표시 */} - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt, "KR"): "-"}</TableCell> - <TableCell> - {c.commentedByEmail ?? "-"} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // 2) 새 파일 Drop - function handleDropAccepted(files: File[]) { - append(files) - } - - - // 3) 저장(Submit) - async function onSubmit(data: CommentFormValues) { - - if (!rfqId) return - startTransition(async () => { - try { - // console.log("rfqId", rfqId) - // console.log("vendorId", vendorId) - // console.log("cbeId", cbeId) - // console.log("currentUserId", currentUserId) - const res = await createRfqCommentWithAttachments({ - rfqId: rfqId, - vendorId: vendorId, // 필요시 세팅 - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, // 필요시 세팅 - cbeId: cbeId, - files: data.newFiles - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 - const newComment: CbeComment = { - id: res.commentId, // 서버에서 반환된 commentId - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || []) - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - // 폼 리셋 - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - {/* 기존 코멘트 목록 */} - <div className="max-h-[300px] overflow-y-auto"> - {renderExistingComments()} - </div> - - {/* 새 코멘트 작성 Form */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea - placeholder="Enter your comment..." - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Dropzone (파일 첨부) */} - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {/* 선택된 파일 목록 */} - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div key={field.id} className="flex items-center justify-between border rounded p-2"> - <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/cbe/table/invite-vendors-dialog.tsx b/lib/cbe/table/invite-vendors-dialog.tsx deleted file mode 100644 index 38edddc1..00000000 --- a/lib/cbe/table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,428 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Send, User } from "lucide-react" -import { toast } from "sonner" -import { z } from "zod" - -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 { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { type Row } from "@tanstack/react-table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { createCbeEvaluation } from "@/lib/rfqs/service" - -// 컴포넌트 내부에서 사용할 폼 스키마 정의 -const formSchema = z.object({ - paymentTerms: z.string().min(1, "지급 조건을 입력하세요"), - incoterms: z.string().min(1, "Incoterms를 입력하세요"), - deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), - notes: z.string().optional(), -}) - -type FormValues = z.infer<typeof formSchema> - -interface InviteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - vendors: Row<VendorWithCbeFields>["original"][] - currentUserId?: number - currentUser?: { - id: string - name?: string | null - email?: string | null - image?: string | null - companyId?: number | null - domain?: string | null - } - showTrigger?: boolean - onSuccess?: () => void - hasMultipleRfqIds?: boolean -} - -export function InviteVendorsDialog({ - rfqId, - vendors, - currentUserId, - currentUser, - showTrigger = true, - onSuccess, - hasMultipleRfqIds, - ...props -}: InviteVendorsDialogProps) { - const [files, setFiles] = React.useState<FileList | null>(null) - const isDesktop = useMediaQuery("(min-width: 640px)") - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // 로컬 스키마와 폼 값을 사용하도록 수정 - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - paymentTerms: "", - incoterms: "", - deliverySchedule: "", - notes: "", - }, - mode: "onChange", - }) - - // 폼 상태 감시 - const { formState } = form - const isValid = formState.isValid && - !!form.getValues("paymentTerms") && - !!form.getValues("incoterms") && - !!form.getValues("deliverySchedule") - - // 디버깅용 상태 트래킹 - React.useEffect(() => { - const subscription = form.watch((value) => { - // 폼 값이 변경될 때마다 실행되는 콜백 - console.log("Form values changed:", value); - }); - - return () => subscription.unsubscribe(); - }, [form]); - - async function onSubmit(data: FormValues) { - try { - setIsSubmitting(true) - - // 기본 FormData 생성 - const formData = new FormData() - - // rfqId 추가 - formData.append("rfqId", String(rfqId)) - - // 폼 데이터 추가 - Object.entries(data).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - formData.append(key, String(value)) - } - }) - - // 현재 사용자 ID 추가 - if (currentUserId) { - formData.append("evaluatedBy", String(currentUserId)) - } - - // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회) - vendors.forEach((vendor) => { - formData.append("vendorIds[]", String(vendor.vendorId)) - }) - - // 파일 추가 (있는 경우에만) - if (files && files.length > 0) { - for (let i = 0; i < files.length; i++) { - formData.append("files", files[i]) - } - } - - // 서버 액션 호출 - const response = await createCbeEvaluation(formData) - - if (response.error) { - toast.error(response.error) - return - } - - // 성공 처리 - toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`) - form.reset() - setFiles(null) - props.onOpenChange?.(false) - onSuccess?.() - } catch (error) { - console.error(error) - toast.error("CBE 평가 생성 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset() - setFiles(null) - } - props.onOpenChange?.(nextOpen) - } - - // 필수 필드 라벨에 추가할 요소 - const RequiredLabel = ( - <span className="text-destructive ml-1 font-medium">*</span> - ) - - const formContent = ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 선택된 협력업체 정보 표시 */} - <div className="space-y-2"> - <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> - <ScrollArea className="h-20 border rounded-md p-2"> - <div className="flex flex-wrap gap-2"> - {vendors.map((vendor, index) => ( - <Badge key={index} variant="secondary" className="py-1"> - {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} - </Badge> - ))} - </div> - </ScrollArea> - <FormDescription> - 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다. - </FormDescription> - </div> - - {/* 작성자 정보 (읽기 전용) */} - {currentUser && ( - <div className="border rounded-md p-3 space-y-2"> - <FormLabel>작성자</FormLabel> - <div className="flex items-center gap-3"> - {currentUser.image ? ( - <Avatar className="h-8 w-8"> - <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> - <AvatarFallback> - {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} - </AvatarFallback> - </Avatar> - ) : ( - <Avatar className="h-8 w-8"> - <AvatarFallback> - {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} - </AvatarFallback> - </Avatar> - )} - <div> - <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> - <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> - </div> - </div> - </div> - )} - - {/* 지급 조건 - 필수 필드 */} - <FormField - control={form.control} - name="paymentTerms" - render={({ field }) => ( - <FormItem> - <FormLabel> - 지급 조건{RequiredLabel} - </FormLabel> - <FormControl> - <Input {...field} placeholder="예: Net 30" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Incoterms - 필수 필드 */} - <FormField - control={form.control} - name="incoterms" - render={({ field }) => ( - <FormItem> - <FormLabel> - Incoterms{RequiredLabel} - </FormLabel> - <FormControl> - <Input {...field} placeholder="예: FOB, CIF" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 배송 일정 - 필수 필드 */} - <FormField - control={form.control} - name="deliverySchedule" - render={({ field }) => ( - <FormItem> - <FormLabel> - 배송 일정{RequiredLabel} - </FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="배송 일정 세부사항을 입력하세요" - rows={3} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 비고 - 선택적 필드 */} - <FormField - control={form.control} - name="notes" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="추가 비고 사항을 입력하세요" - rows={3} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 첨부 (옵션) */} - <div className="space-y-2"> - <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel> - <Input - id="files" - type="file" - multiple - onChange={(e) => setFiles(e.target.files)} - /> - {files && files.length > 0 && ( - <p className="text-sm text-muted-foreground"> - {files.length}개 파일이 첨부되었습니다 - </p> - )} - </div> - - {/* 필수 입력 항목 안내 */} - <div className="text-sm text-muted-foreground"> - <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다. - </div> - - {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */} - {isDesktop && ( - <DialogFooter className="gap-2 pt-4"> - <DialogClose asChild> - <Button - type="button" - variant="outline" - > - 취소 - </Button> - </DialogClose> - <Button - type="submit" - disabled={isSubmitting || !isValid} - > - {isSubmitting && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} - </Button> - </DialogFooter> - )} - </form> - </Form> - ) - if (hasMultipleRfqIds) { - toast.error("동일한 RFQ에 대해 선택해주세요"); - return; - } - // Desktop Dialog - if (isDesktop) { - return ( - <Dialog {...props} onOpenChange={handleDialogOpenChange}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - CBE 평가 전송 ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>CBE 평가 생성 및 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. - </DialogDescription> - </DialogHeader> - - {formContent} - </DialogContent> - </Dialog> - ) - } - - // Mobile Drawer - return ( - <Drawer {...props} onOpenChange={handleDialogOpenChange}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - CBE 평가 전송 ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle> - <DrawerDescription> - 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. - </DrawerDescription> - </DrawerHeader> - - <div className="px-4"> - {formContent} - </div> - - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - onClick={form.handleSubmit(onSubmit)} - disabled={isSubmitting || !isValid} - > - {isSubmitting && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/legal-review/service.ts b/lib/legal-review/service.ts deleted file mode 100644 index bc55a1fc..00000000 --- a/lib/legal-review/service.ts +++ /dev/null @@ -1,738 +0,0 @@ -'use server' - -import { revalidatePath, unstable_noStore } from "next/cache"; -import db from "@/db/db"; -import { legalWorks, legalWorkRequests, legalWorkResponses, legalWorkAttachments, vendors, legalWorksDetailView } from "@/db/schema"; -import { and, asc, count, desc, eq, ilike, or, SQL, inArray } from "drizzle-orm"; -import { CreateLegalWorkData, GetLegalWorksSchema, createLegalWorkSchema } from "./validations"; -import { filterColumns } from "@/lib/filter-columns"; -import { saveFile } from "../file-stroage"; - -interface CreateLegalWorkResult { - success: boolean; - data?: { - id: number; - message: string; - }; - error?: string; -} - - - -export async function createLegalWork( - data: CreateLegalWorkData -): Promise<CreateLegalWorkResult> { - unstable_noStore(); - - try { - // 1. 입력 데이터 검증 - const validatedData = createLegalWorkSchema.parse(data); - - // 2. 벤더 정보 조회 - const vendor = await db - .select({ - id: vendors.id, - vendorCode: vendors.vendorCode, - vendorName: vendors.vendorName, - }) - .from(vendors) - .where(eq(vendors.id, validatedData.vendorId)) - .limit(1); - - if (!vendor.length) { - return { - success: false, - error: "선택한 벤더를 찾을 수 없습니다.", - }; - } - - const selectedVendor = vendor[0]; - - // 3. 트랜잭션으로 데이터 삽입 - const result = await db.transaction(async (tx) => { - // 3-1. legal_works 테이블에 메인 데이터 삽입 - const [legalWorkResult] = await tx - .insert(legalWorks) - .values({ - category: validatedData.category, - status: "신규등록", // 초기 상태 - vendorId: validatedData.vendorId, - vendorCode: selectedVendor.vendorCode, - vendorName: selectedVendor.vendorName, - isUrgent: validatedData.isUrgent, - requestDate: validatedData.requestDate, - consultationDate: new Date().toISOString().split('T')[0], // 오늘 날짜 - hasAttachment: false, // 초기값 - reviewer: validatedData.reviewer, // 추후 할당 - legalResponder: null, // 추후 할당 - }) - .returning({ id: legalWorks.id }); - - const legalWorkId = legalWorkResult.id; - - - - return { legalWorkId }; - }); - - // 4. 캐시 재검증 - revalidatePath("/legal-works"); - - return { - success: true, - data: { - id: result.legalWorkId, - message: "법무업무가 성공적으로 등록되었습니다.", - }, - }; - - } catch (error) { - console.error("createLegalWork 오류:", error); - - // 데이터베이스 오류 처리 - if (error instanceof Error) { - // 외래키 제약 조건 오류 - if (error.message.includes('foreign key constraint')) { - return { - success: false, - error: "선택한 벤더가 유효하지 않습니다.", - }; - } - - // 중복 키 오류 등 기타 DB 오류 - return { - success: false, - error: "데이터베이스 오류가 발생했습니다.", - }; - } - - return { - success: false, - error: "알 수 없는 오류가 발생했습니다.", - }; - } -} - -// 법무업무 상태 업데이트 함수 (보너스) -export async function updateLegalWorkStatus( - legalWorkId: number, - status: string, - reviewer?: string, - legalResponder?: string -): Promise<CreateLegalWorkResult> { - unstable_noStore(); - - try { - const updateData: Partial<typeof legalWorks.$inferInsert> = { - status, - updatedAt: new Date(), - }; - - if (reviewer) updateData.reviewer = reviewer; - if (legalResponder) updateData.legalResponder = legalResponder; - - await db - .update(legalWorks) - .set(updateData) - .where(eq(legalWorks.id, legalWorkId)); - - revalidatePath("/legal-works"); - - return { - success: true, - data: { - id: legalWorkId, - message: "상태가 성공적으로 업데이트되었습니다.", - }, - }; - - } catch (error) { - console.error("updateLegalWorkStatus 오류:", error); - return { - success: false, - error: "상태 업데이트 중 오류가 발생했습니다.", - }; - } -} - -// 법무업무 삭제 함수 (보너스) -export async function deleteLegalWork(legalWorkId: number): Promise<CreateLegalWorkResult> { - unstable_noStore(); - - try { - await db.transaction(async (tx) => { - // 관련 요청 데이터 먼저 삭제 - await tx - .delete(legalWorkRequests) - .where(eq(legalWorkRequests.legalWorkId, legalWorkId)); - - // 메인 법무업무 데이터 삭제 - await tx - .delete(legalWorks) - .where(eq(legalWorks.id, legalWorkId)); - }); - - revalidatePath("/legal-works"); - - return { - success: true, - data: { - id: legalWorkId, - message: "법무업무가 성공적으로 삭제되었습니다.", - }, - }; - - } catch (error) { - console.error("deleteLegalWork 오류:", error); - return { - success: false, - error: "삭제 중 오류가 발생했습니다.", - }; - } -} - - -export async function getLegalWorks(input: GetLegalWorksSchema) { - unstable_noStore(); // ✅ 1. 캐싱 방지 추가 - - try { - const offset = (input.page - 1) * input.perPage; - - // ✅ 2. 안전한 필터 처리 (getEvaluationTargets와 동일) - let advancedWhere: SQL<unknown> | undefined = undefined; - - if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) { - console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`)); - - try { - advancedWhere = filterColumns({ - table: legalWorksDetailView, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - }); - - console.log("필터 조건 생성 완료"); - } catch (error) { - console.error("필터 조건 생성 오류:", error); - // ✅ 필터 오류 시에도 전체 데이터 반환 - advancedWhere = undefined; - } - } - - // ✅ 3. 안전한 글로벌 검색 처리 - let globalWhere: SQL<unknown> | undefined = undefined; - if (input.search) { - const searchTerm = `%${input.search}%`; - - const searchConditions: SQL<unknown>[] = [ - ilike(legalWorksDetailView.vendorCode, searchTerm), - ilike(legalWorksDetailView.vendorName, searchTerm), - ilike(legalWorksDetailView.title, searchTerm), - ilike(legalWorksDetailView.requestContent, searchTerm), - ilike(legalWorksDetailView.reviewer, searchTerm), - ilike(legalWorksDetailView.legalResponder, searchTerm) - ].filter(Boolean); - - if (searchConditions.length > 0) { - globalWhere = or(...searchConditions); - } - } - - // ✅ 4. 안전한 WHERE 조건 결합 - const whereConditions: SQL<unknown>[] = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - - const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // ✅ 5. 전체 데이터 수 조회 - const totalResult = await db - .select({ count: count() }) - .from(legalWorksDetailView) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - - if (total === 0) { - return { data: [], pageCount: 0, total: 0 }; - } - - console.log("총 데이터 수:", total); - - // ✅ 6. 정렬 및 페이징 처리 - const orderByColumns = input.sort.map((sort) => { - const column = sort.id as keyof typeof legalWorksDetailView.$inferSelect; - return sort.desc - ? desc(legalWorksDetailView[column]) - : asc(legalWorksDetailView[column]); - }); - - if (orderByColumns.length === 0) { - orderByColumns.push(desc(legalWorksDetailView.createdAt)); - } - - const legalWorksData = await db - .select() - .from(legalWorksDetailView) - .where(finalWhere) - .orderBy(...orderByColumns) - .limit(input.perPage) - .offset(offset); - - const pageCount = Math.ceil(total / input.perPage); - - console.log("반환 데이터 수:", legalWorksData.length); - - return { data: legalWorksData, pageCount, total }; - } catch (err) { - console.error("getLegalWorks 오류:", err); - return { data: [], pageCount: 0, total: 0 }; - } -} -// 특정 법무업무 상세 조회 -export async function getLegalWorkById(id: number) { - unstable_noStore(); - - try { - const result = await db - .select() - .from(legalWorksDetailView) - .where(eq(legalWorksDetailView.id , id)) - .limit(1); - - return result[0] || null; - } catch (error) { - console.error("getLegalWorkById 오류:", error); - return null; - } -} - -// 법무업무 통계 (뷰 테이블 사용) -export async function getLegalWorksStats() { - unstable_noStore(); - try { - // 전체 통계 - const totalStats = await db - .select({ - total: count(), - category: legalWorksDetailView.category, - status: legalWorksDetailView.status, - isUrgent: legalWorksDetailView.isUrgent, - }) - .from(legalWorksDetailView); - - // 통계 데이터 가공 - const stats = { - total: totalStats.length, - byCategory: {} as Record<string, number>, - byStatus: {} as Record<string, number>, - urgent: 0, - }; - - totalStats.forEach(stat => { - // 카테고리별 집계 - if (stat.category) { - stats.byCategory[stat.category] = (stats.byCategory[stat.category] || 0) + 1; - } - - // 상태별 집계 - if (stat.status) { - stats.byStatus[stat.status] = (stats.byStatus[stat.status] || 0) + 1; - } - - // 긴급 건수 - if (stat.isUrgent) { - stats.urgent++; - } - }); - - return stats; - } catch (error) { - console.error("getLegalWorksStatsSimple 오류:", error); - return { - total: 0, - byCategory: {}, - byStatus: {}, - urgent: 0, - }; - } -} - -// 검토요청 폼 데이터 타입 -interface RequestReviewData { - // 기본 설정 - dueDate: string - assignee?: string - notificationMethod: "email" | "internal" | "both" - - // 법무업무 상세 정보 - reviewDepartment: "준법문의" | "법무검토" - inquiryType?: "국내계약" | "국내자문" | "해외계약" | "해외자문" - - // 공통 필드 - title: string - requestContent: string - - // 준법문의 전용 필드 - isPublic?: boolean - - // 법무검토 전용 필드들 - contractProjectName?: string - contractType?: string - contractCounterparty?: string - counterpartyType?: "법인" | "개인" - contractPeriod?: string - contractAmount?: string - factualRelation?: string - projectNumber?: string - shipownerOrderer?: string - projectType?: string - governingLaw?: string -} - -// 첨부파일 업로드 함수 -async function uploadAttachment(file: File, legalWorkId: number, userId?: string) { - try { - console.log(`📎 첨부파일 업로드 시작: ${file.name} (${file.size} bytes)`) - - const result = await saveFile({ - file, - directory: "legal-works", - originalName: file.name, - userId: userId || "system" - }) - - if (!result.success) { - throw new Error(result.error || "파일 업로드 실패") - } - - console.log(`✅ 첨부파일 업로드 성공: ${result.fileName}`) - - return { - fileName: result.fileName!, - originalFileName: result.originalName!, - filePath: result.publicPath!, - fileSize: result.fileSize!, - mimeType: file.type, - securityChecks: result.securityChecks - } - } catch (error) { - console.error(`❌ 첨부파일 업로드 실패: ${file.name}`, error) - throw error - } -} - - -export async function requestReview( - legalWorkId: number, - formData: RequestReviewData, - attachments: File[] = [], - userId?: string -) { - try { - console.log(`🚀 검토요청 처리 시작 - 법무업무 #${legalWorkId}`) - - // 트랜잭션 시작 - const result = await db.transaction(async (tx) => { - // 1. legal_works 테이블 업데이트 - const [updatedWork] = await tx - .update(legalWorks) - .set({ - status: "검토요청", - expectedAnswerDate: formData.dueDate, - hasAttachment: attachments.length > 0, - updatedAt: new Date(), - }) - .where(eq(legalWorks.id, legalWorkId)) - .returning() - - if (!updatedWork) { - throw new Error("법무업무를 찾을 수 없습니다.") - } - - console.log(`📝 법무업무 상태 업데이트 완료: ${updatedWork.status}`) - - // 2. legal_work_requests 테이블에 데이터 삽입 - const [createdRequest] = await tx - .insert(legalWorkRequests) - .values({ - legalWorkId: legalWorkId, - reviewDepartment: formData.reviewDepartment, - inquiryType: formData.inquiryType || null, - title: formData.title, - requestContent: formData.requestContent, - - // 준법문의 관련 필드 - isPublic: formData.reviewDepartment === "준법문의" ? (formData.isPublic || false) : null, - - // 법무검토 관련 필드들 - contractProjectName: formData.contractProjectName || null, - contractType: formData.contractType || null, - contractAmount: formData.contractAmount ? parseFloat(formData.contractAmount) : null, - - // 국내계약 전용 필드들 - contractCounterparty: formData.contractCounterparty || null, - counterpartyType: formData.counterpartyType || null, - contractPeriod: formData.contractPeriod || null, - - // 자문 관련 필드 - factualRelation: formData.factualRelation || null, - - // 해외 관련 필드들 - projectNumber: formData.projectNumber || null, - shipownerOrderer: formData.shipownerOrderer || null, - governingLaw: formData.governingLaw || null, - projectType: formData.projectType || null, - }) - .returning() - - console.log(`📋 검토요청 정보 저장 완료: ${createdRequest.reviewDepartment}`) - - // 3. 첨부파일 처리 - const uploadedFiles = [] - const failedFiles = [] - - if (attachments.length > 0) { - console.log(`📎 첨부파일 처리 시작: ${attachments.length}개`) - - for (const file of attachments) { - try { - const uploadResult = await uploadAttachment(file, legalWorkId, userId) - - // DB에 첨부파일 정보 저장 - const [attachmentRecord] = await tx - .insert(legalWorkAttachments) - .values({ - legalWorkId: legalWorkId, - fileName: uploadResult.fileName, - originalFileName: uploadResult.originalFileName, - filePath: uploadResult.filePath, - fileSize: uploadResult.fileSize, - mimeType: uploadResult.mimeType, - attachmentType: 'request', - isAutoGenerated: false, - }) - .returning() - - uploadedFiles.push({ - id: attachmentRecord.id, - name: uploadResult.originalFileName, - size: uploadResult.fileSize, - securityChecks: uploadResult.securityChecks - }) - - } catch (fileError) { - console.error(`❌ 파일 업로드 실패: ${file.name}`, fileError) - failedFiles.push({ - name: file.name, - error: fileError instanceof Error ? fileError.message : "업로드 실패" - }) - } - } - - console.log(`✅ 파일 업로드 완료: 성공 ${uploadedFiles.length}개, 실패 ${failedFiles.length}개`) - } - - return { - updatedWork, - createdRequest, - uploadedFiles, - failedFiles, - totalFiles: attachments.length, - } - }) - - // 페이지 재검증 - revalidatePath("/legal-works") - - // 성공 메시지 구성 - let message = `검토요청이 성공적으로 발송되었습니다.` - - if (result.totalFiles > 0) { - message += ` (첨부파일: 성공 ${result.uploadedFiles.length}개` - if (result.failedFiles.length > 0) { - message += `, 실패 ${result.failedFiles.length}개` - } - message += `)` - } - - console.log(`🎉 검토요청 처리 완료 - 법무업무 #${legalWorkId}`) - - return { - success: true, - data: { - message, - legalWorkId: legalWorkId, - requestId: result.createdRequest.id, - uploadedFiles: result.uploadedFiles, - failedFiles: result.failedFiles, - } - } - - } catch (error) { - console.error(`💥 검토요청 처리 중 오류 - 법무업무 #${legalWorkId}:`, error) - - return { - success: false, - error: error instanceof Error ? error.message : "검토요청 처리 중 오류가 발생했습니다." - } - } -} - - -// FormData를 사용하는 버전 (파일 업로드용) -export async function requestReviewWithFiles(formData: FormData) { - try { - // 기본 데이터 추출 - const legalWorkId = parseInt(formData.get("legalWorkId") as string) - - const requestData: RequestReviewData = { - dueDate: formData.get("dueDate") as string, - assignee: formData.get("assignee") as string || undefined, - notificationMethod: formData.get("notificationMethod") as "email" | "internal" | "both", - reviewDepartment: formData.get("reviewDepartment") as "준법문의" | "법무검토", - inquiryType: formData.get("inquiryType") as "국내계약" | "국내자문" | "해외계약" | "해외자문" || undefined, - title: formData.get("title") as string, - requestContent: formData.get("requestContent") as string, - isPublic: formData.get("isPublic") === "true", - - // 법무검토 관련 필드들 - contractProjectName: formData.get("contractProjectName") as string || undefined, - contractType: formData.get("contractType") as string || undefined, - contractCounterparty: formData.get("contractCounterparty") as string || undefined, - counterpartyType: formData.get("counterpartyType") as "법인" | "개인" || undefined, - contractPeriod: formData.get("contractPeriod") as string || undefined, - contractAmount: formData.get("contractAmount") as string || undefined, - factualRelation: formData.get("factualRelation") as string || undefined, - projectNumber: formData.get("projectNumber") as string || undefined, - shipownerOrderer: formData.get("shipownerOrderer") as string || undefined, - projectType: formData.get("projectType") as string || undefined, - governingLaw: formData.get("governingLaw") as string || undefined, - } - - // 첨부파일 추출 - const attachments: File[] = [] - for (const [key, value] of formData.entries()) { - if (key.startsWith("attachment_") && value instanceof File && value.size > 0) { - attachments.push(value) - } - } - - return await requestReview(legalWorkId, requestData, attachments) - - } catch (error) { - console.error("FormData 처리 중 오류:", error) - return { - success: false, - error: "요청 데이터 처리 중 오류가 발생했습니다." - } - } -} - -// 검토요청 가능 여부 확인 -export async function canRequestReview(legalWorkId: number) { - try { - const [work] = await db - .select({ status: legalWorks.status }) - .from(legalWorks) - .where(eq(legalWorks.id, legalWorkId)) - .limit(1) - - if (!work) { - return { canRequest: false, reason: "법무업무를 찾을 수 없습니다." } - } - - if (work.status !== "신규등록") { - return { - canRequest: false, - reason: `현재 상태(${work.status})에서는 검토요청을 할 수 없습니다. 신규등록 상태에서만 가능합니다.` - } - } - - return { canRequest: true } - - } catch (error) { - console.error("검토요청 가능 여부 확인 중 오류:", error) - return { - canRequest: false, - reason: "상태 확인 중 오류가 발생했습니다." - } - } -} - -// 삭제 요청 타입 -interface RemoveLegalWorksInput { - ids: number[] -} - -// 응답 타입 -interface RemoveLegalWorksResponse { - error?: string - success?: boolean -} - -/** - * 법무업무 삭제 서버 액션 - */ -export async function removeLegalWorks({ - ids, -}: RemoveLegalWorksInput): Promise<RemoveLegalWorksResponse> { - try { - // 유효성 검사 - if (!ids || ids.length === 0) { - return { - error: "삭제할 법무업무를 선택해주세요.", - } - } - - // 삭제 가능한 상태인지 확인 (선택적) - const existingWorks = await db - .select({ id: legalWorks.id, status: legalWorks.status }) - .from(legalWorks) - .where(inArray(legalWorks.id, ids)) - - // 삭제 불가능한 상태 체크 (예: 진행중인 업무는 삭제 불가) - const nonDeletableWorks = existingWorks.filter( - work => work.status === "검토중" || work.status === "담당자배정" - ) - - if (nonDeletableWorks.length > 0) { - return { - error: "진행중인 법무업무는 삭제할 수 없습니다.", - } - } - - // 실제 삭제 실행 - const result = await db - .delete(legalWorks) - .where(inArray(legalWorks.id, ids)) - - // 결과 확인 - if (result.changes === 0) { - return { - error: "삭제할 법무업무를 찾을 수 없습니다.", - } - } - - // 캐시 재검증 - revalidatePath("/legal-works") // 실제 경로에 맞게 수정 - - return { - success: true, - } - - } catch (error) { - console.error("법무업무 삭제 중 오류 발생:", error) - - return { - error: "법무업무 삭제 중 오류가 발생했습니다. 다시 시도해주세요.", - } - } -} - -/** - * 단일 법무업무 삭제 (선택적) - */ -export async function removeLegalWork(id: number): Promise<RemoveLegalWorksResponse> { - return removeLegalWorks({ ids: [id] }) -}
\ No newline at end of file diff --git a/lib/legal-review/status/create-legal-work-dialog.tsx b/lib/legal-review/status/create-legal-work-dialog.tsx deleted file mode 100644 index 0ee1c430..00000000 --- a/lib/legal-review/status/create-legal-work-dialog.tsx +++ /dev/null @@ -1,506 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { Loader2, Check, ChevronsUpDown, Calendar, User } from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Switch } from "@/components/ui/switch" -import { cn } from "@/lib/utils" -import { getVendorsForSelection } from "@/lib/b-rfq/service" -import { createLegalWork } from "../service" -import { useSession } from "next-auth/react" - -interface CreateLegalWorkDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onSuccess?: () => void - onDataChange?: () => void -} - -// legalWorks 테이블에 맞춘 단순화된 폼 스키마 -const createLegalWorkSchema = z.object({ - category: z.enum(["CP", "GTC", "기타"]), - vendorId: z.number().min(1, "벤더를 선택해주세요"), - isUrgent: z.boolean().default(false), - requestDate: z.string().min(1, "답변요청일을 선택해주세요"), - expectedAnswerDate: z.string().optional(), - reviewer: z.string().min(1, "검토요청자를 입력해주세요"), -}) - -type CreateLegalWorkFormValues = z.infer<typeof createLegalWorkSchema> - -interface Vendor { - id: number - vendorName: string - vendorCode: string - country: string - taxId: string - status: string -} - -export function CreateLegalWorkDialog({ - open, - onOpenChange, - onSuccess, - onDataChange -}: CreateLegalWorkDialogProps) { - const router = useRouter() - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [vendorsLoading, setVendorsLoading] = React.useState(false) - const [vendorOpen, setVendorOpen] = React.useState(false) - const { data: session } = useSession() - - const userName = React.useMemo(() => { - return session?.user?.name || ""; - }, [session]); - - const userEmail = React.useMemo(() => { - return session?.user?.email || ""; - }, [session]); - - const defaultReviewer = React.useMemo(() => { - if (userName && userEmail) { - return `${userName} (${userEmail})`; - } else if (userName) { - return userName; - } else if (userEmail) { - return userEmail; - } - return ""; - }, [userName, userEmail]); - - const loadVendors = React.useCallback(async () => { - setVendorsLoading(true) - try { - const vendorList = await getVendorsForSelection() - setVendors(vendorList) - } catch (error) { - console.error("Failed to load vendors:", error) - toast.error("벤더 목록을 불러오는데 실패했습니다.") - } finally { - setVendorsLoading(false) - } - }, []) - - // 오늘 날짜 + 7일 후를 기본 답변요청일로 설정 - const getDefaultRequestDate = () => { - const date = new Date() - date.setDate(date.getDate() + 7) - return date.toISOString().split('T')[0] - } - - // 답변요청일 + 3일 후를 기본 답변예정일로 설정 - const getDefaultExpectedDate = (requestDate: string) => { - if (!requestDate) return "" - const date = new Date(requestDate) - date.setDate(date.getDate() + 3) - return date.toISOString().split('T')[0] - } - - const form = useForm<CreateLegalWorkFormValues>({ - resolver: zodResolver(createLegalWorkSchema), - defaultValues: { - category: "CP", - vendorId: 0, - isUrgent: false, - requestDate: getDefaultRequestDate(), - expectedAnswerDate: "", - reviewer: defaultReviewer, - }, - }) - - React.useEffect(() => { - if (open) { - loadVendors() - } - }, [open, loadVendors]) - - // 세션 정보가 로드되면 검토요청자 필드 업데이트 - React.useEffect(() => { - if (defaultReviewer) { - form.setValue("reviewer", defaultReviewer) - } - }, [defaultReviewer, form]) - - // 답변요청일 변경시 답변예정일 자동 설정 - const requestDate = form.watch("requestDate") - React.useEffect(() => { - if (requestDate) { - const expectedDate = getDefaultExpectedDate(requestDate) - form.setValue("expectedAnswerDate", expectedDate) - } - }, [requestDate, form]) - - // 폼 제출 - 서버 액션 적용 - async function onSubmit(data: CreateLegalWorkFormValues) { - console.log("Form submitted with data:", data) - setIsSubmitting(true) - - try { - // legalWorks 테이블에 맞춘 데이터 구조 - const legalWorkData = { - ...data, - // status는 서버에서 "검토요청"으로 설정 - // consultationDate는 서버에서 오늘 날짜로 설정 - // hasAttachment는 서버에서 false로 설정 - } - - const result = await createLegalWork(legalWorkData) - - if (result.success) { - toast.success(result.data?.message || "법무업무가 성공적으로 등록되었습니다.") - onOpenChange(false) - form.reset({ - category: "CP", - vendorId: 0, - isUrgent: false, - requestDate: getDefaultRequestDate(), - expectedAnswerDate: "", - reviewer: defaultReviewer, - }) - onSuccess?.() - onDataChange?.() - router.refresh() - } else { - toast.error(result.error || "등록 중 오류가 발생했습니다.") - } - } catch (error) { - console.error("Error creating legal work:", error) - toast.error("등록 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (open: boolean) => { - onOpenChange(open) - if (!open) { - form.reset({ - category: "CP", - vendorId: 0, - isUrgent: false, - requestDate: getDefaultRequestDate(), - expectedAnswerDate: "", - reviewer: defaultReviewer, - }) - } - } - - // 선택된 벤더 정보 - const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-2xl h-[80vh] p-0 flex flex-col"> - {/* 고정 헤더 */} - <div className="flex-shrink-0 p-6 border-b"> - <DialogHeader> - <DialogTitle>법무업무 신규 등록</DialogTitle> - <DialogDescription> - 새로운 법무업무를 등록합니다. 상세한 검토 요청은 등록 후 별도로 진행할 수 있습니다. - </DialogDescription> - </DialogHeader> - </div> - - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col flex-1 min-h-0" - > - {/* 스크롤 가능한 콘텐츠 영역 */} - <div className="flex-1 overflow-y-auto p-6"> - <div className="space-y-6"> - {/* 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">기본 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - {/* 구분 */} - <FormField - control={form.control} - name="category" - render={({ field }) => ( - <FormItem> - <FormLabel>구분</FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="구분 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="CP">CP</SelectItem> - <SelectItem value="GTC">GTC</SelectItem> - <SelectItem value="기타">기타</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 긴급여부 */} - <FormField - control={form.control} - name="isUrgent" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> - <div className="space-y-0.5"> - <FormLabel className="text-base">긴급 요청</FormLabel> - <div className="text-sm text-muted-foreground"> - 긴급 처리가 필요한 경우 체크 - </div> - </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - </div> - - {/* 벤더 선택 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem> - <FormLabel>벤더</FormLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className="w-full justify-between" - > - {selectedVendor ? ( - <span className="flex items-center gap-2"> - <Badge variant="outline">{selectedVendor.vendorCode}</Badge> - {selectedVendor.vendorName} - </span> - ) : ( - "벤더 선택..." - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0" align="start"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandList - onWheel={(e) => { - e.stopPropagation(); // 이벤트 전파 차단 - const target = e.currentTarget; - target.scrollTop += e.deltaY; // 직접 스크롤 처리 - }}> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorCode} ${vendor.vendorName}`} - onSelect={() => { - field.onChange(vendor.id) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - vendor.id === field.value ? "opacity-100" : "opacity-0" - )} - /> - <div className="flex items-center gap-2"> - <Badge variant="outline">{vendor.vendorCode}</Badge> - <span>{vendor.vendorName}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - - {/* 담당자 및 일정 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - <Calendar className="h-5 w-5" /> - 담당자 및 일정 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {/* 검토요청자 */} - <FormField - control={form.control} - name="reviewer" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center gap-2"> - <User className="h-4 w-4" /> - 검토요청자 - </FormLabel> - <FormControl> - <Input - placeholder={defaultReviewer || "검토요청자 이름을 입력하세요"} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - {/* 답변요청일 */} - <FormField - control={form.control} - name="requestDate" - render={({ field }) => ( - <FormItem> - <FormLabel>답변요청일</FormLabel> - <FormControl> - <Input - type="date" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 답변예정일 */} - <FormField - control={form.control} - name="expectedAnswerDate" - render={({ field }) => ( - <FormItem> - <FormLabel>답변예정일 (선택사항)</FormLabel> - <FormControl> - <Input - type="date" - {...field} - /> - </FormControl> - <div className="text-xs text-muted-foreground"> - 답변요청일 기준으로 자동 설정됩니다 - </div> - <FormMessage /> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - - {/* 안내 메시지 */} - <Card className="bg-blue-50 border-blue-200"> - <CardContent className="pt-6"> - <div className="flex items-start gap-3"> - <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div> - <div className="space-y-1"> - <p className="text-sm font-medium text-blue-900"> - 법무업무 등록 안내 - </p> - <p className="text-sm text-blue-700"> - 기본 정보 등록 후, 목록에서 해당 업무를 선택하여 상세한 검토 요청을 진행할 수 있습니다. - </p> - <p className="text-xs text-blue-600"> - • 상태: "검토요청"으로 자동 설정<br/> - • 의뢰일: 오늘 날짜로 자동 설정<br/> - • 법무답변자: 나중에 배정 - </p> - </div> - </div> - </CardContent> - </Card> - </div> - </div> - - {/* 고정 버튼 영역 */} - <div className="flex-shrink-0 border-t bg-background p-6"> - <div className="flex justify-end gap-3"> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - disabled={isSubmitting} - > - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - 등록 - </Button> - </div> - </div> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/legal-review/status/delete-legal-works-dialog.tsx b/lib/legal-review/status/delete-legal-works-dialog.tsx deleted file mode 100644 index 665dafc2..00000000 --- a/lib/legal-review/status/delete-legal-works-dialog.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client" - -import * as React from "react" -import { type LegalWorksDetailView } from "@/db/schema" -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 { useRouter } from "next/navigation" - -import { removeLegalWorks } from "../service" - -interface DeleteLegalWorksDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - legalWorks: Row<LegalWorksDetailView>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteLegalWorksDialog({ - legalWorks, - showTrigger = true, - onSuccess, - ...props -}: DeleteLegalWorksDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - const router = useRouter() - - function onDelete() { - startDeleteTransition(async () => { - const { error } = await removeLegalWorks({ - ids: legalWorks.map((work) => work.id), - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - router.refresh() - toast.success("법무업무가 삭제되었습니다") - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({legalWorks.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - <span className="font-medium">{legalWorks.length}</span> - 건의 법무업무가 완전히 삭제됩니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Delete selected legal works" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <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"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({legalWorks.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - <span className="font-medium">{legalWorks.length}</span> - 건의 법무업무가 완전히 삭제됩니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Delete selected legal works" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <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/legal-review/status/legal-table copy.tsx b/lib/legal-review/status/legal-table copy.tsx deleted file mode 100644 index 92abfaf6..00000000 --- a/lib/legal-review/status/legal-table copy.tsx +++ /dev/null @@ -1,583 +0,0 @@ -// ============================================================================ -// legal-works-table.tsx - EvaluationTargetsTable을 정확히 복사해서 수정 -// ============================================================================ -"use client"; - -import * as React from "react"; -import { useSearchParams } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -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 { getLegalWorks } from "../service"; -import { cn } from "@/lib/utils"; -import { useTablePresets } from "@/components/data-table/use-table-presets"; -import { TablePresetManager } from "@/components/data-table/data-table-preset"; -import { getLegalWorksColumns } from "./legal-works-columns"; -import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions"; -import { LegalWorkFilterSheet } from "./legal-work-filter-sheet"; -import { LegalWorksDetailView } from "@/db/schema"; -import { EditLegalWorkSheet } from "./update-legal-work-dialog"; -import { LegalWorkDetailDialog } from "./legal-work-detail-dialog"; -import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"; - -/* -------------------------------------------------------------------------- */ -/* Stats Card */ -/* -------------------------------------------------------------------------- */ -function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) { - const stats = React.useMemo(() => { - const total = data.length; - const pending = data.filter(item => item.status === '검토요청').length; - const assigned = data.filter(item => item.status === '담당자배정').length; - const inProgress = data.filter(item => item.status === '검토중').length; - const completed = data.filter(item => item.status === '답변완료').length; - const urgent = data.filter(item => item.isUrgent).length; - - return { total, pending, assigned, inProgress, completed, urgent }; - }, [data]); - - if (stats.total === 0) { - return ( - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> - <Card className="col-span-full"> - <CardContent className="pt-6 text-center text-sm text-muted-foreground"> - 등록된 법무업무가 없습니다. - </CardContent> - </Card> - </div> - ); - } - - return ( - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">총 건수</CardTitle> - <Badge variant="outline">전체</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - 긴급 {stats.urgent}건 - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">검토요청</CardTitle> - <Badge variant="secondary">대기</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">담당자배정</CardTitle> - <Badge variant="secondary">진행</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">검토중</CardTitle> - <Badge variant="secondary">진행</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">답변완료</CardTitle> - <Badge variant="default">완료</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - </div> - ); -} - -/* -------------------------------------------------------------------------- */ -/* LegalWorksTable */ -/* -------------------------------------------------------------------------- */ -interface LegalWorksTableProps { - promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>; - currentYear?: number; // ✅ EvaluationTargetsTable의 evaluationYear와 동일한 역할 - className?: string; -} - -export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null); - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); - const searchParams = useSearchParams(); - - // ✅ EvaluationTargetsTable과 정확히 동일한 외부 필터 상태 - const [externalFilters, setExternalFilters] = React.useState<any[]>([]); - const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); - - // ✅ EvaluationTargetsTable과 정확히 동일한 필터 핸들러 - const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { - console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); - setExternalFilters(filters); - setExternalJoinOperator(joinOperator); - setIsFilterPanelOpen(false); - }, []); - - const searchString = React.useMemo( - () => searchParams.toString(), - [searchParams] - ); - - const getSearchParam = React.useCallback( - (key: string, def = "") => - new URLSearchParams(searchString).get(key) ?? def, - [searchString] - ); - - // ✅ EvaluationTargetsTable과 정확히 동일한 URL 필터 변경 감지 및 데이터 새로고침 - React.useEffect(() => { - const refetchData = async () => { - try { - setIsDataLoading(true); - - // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 - const currentFilters = getSearchParam("filters"); - const currentJoinOperator = getSearchParam("joinOperator", "and"); - const currentPage = parseInt(getSearchParam("page", "1")); - const currentPerPage = parseInt(getSearchParam("perPage", "10")); - const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; - const currentSearch = getSearchParam("search", ""); - - const searchParams = { - filters: currentFilters ? JSON.parse(currentFilters) : [], - joinOperator: currentJoinOperator as "and" | "or", - page: currentPage, - perPage: currentPerPage, - sort: currentSort, - search: currentSearch, - // ✅ currentYear 추가 (EvaluationTargetsTable의 evaluationYear와 동일) - currentYear: currentYear - }; - - console.log("=== 새 데이터 요청 ===", searchParams); - - // 서버 액션 직접 호출 - const newData = await getLegalWorks(searchParams); - setTableData(newData); - - console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건"); - } catch (error) { - console.error("데이터 새로고침 오류:", error); - } finally { - setIsDataLoading(false); - } - }; - - // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) - const timeoutId = setTimeout(() => { - // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 - const hasChanges = getSearchParam("filters") || - getSearchParam("search") || - getSearchParam("page") !== "1" || - getSearchParam("perPage") !== "10" || - getSearchParam("sort"); - - if (hasChanges) { - refetchData(); - } - }, 300); // 디바운스 시간 단축 - - return () => clearTimeout(timeoutId); - }, [searchString, currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 정확히 동일한 의존성 - - const refreshData = React.useCallback(async () => { - try { - setIsDataLoading(true); - - // 현재 URL 파라미터로 데이터 새로고침 - const currentFilters = getSearchParam("filters"); - const currentJoinOperator = getSearchParam("joinOperator", "and"); - const currentPage = parseInt(getSearchParam("page", "1")); - const currentPerPage = parseInt(getSearchParam("perPage", "10")); - const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; - const currentSearch = getSearchParam("search", ""); - - const searchParams = { - filters: currentFilters ? JSON.parse(currentFilters) : [], - joinOperator: currentJoinOperator as "and" | "or", - page: currentPage, - perPage: currentPerPage, - sort: currentSort, - search: currentSearch, - currentYear: currentYear - }; - - const newData = await getLegalWorks(searchParams); - setTableData(newData); - - console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); - } catch (error) { - console.error("데이터 새로고침 오류:", error); - } finally { - setIsDataLoading(false); - } - }, [currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 동일한 의존성 - - /* --------------------------- layout refs --------------------------- */ - const containerRef = React.useRef<HTMLDivElement>(null); - const [containerTop, setContainerTop] = React.useState(0); - - const updateContainerBounds = React.useCallback(() => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect() - const newTop = rect.top - setContainerTop(prevTop => { - if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 - return newTop - } - return prevTop - }) - } - }, []) - - React.useEffect(() => { - updateContainerBounds(); - - const handleResize = () => { - updateContainerBounds(); - }; - - window.addEventListener('resize', handleResize); - window.addEventListener('scroll', updateContainerBounds); - - return () => { - window.removeEventListener('resize', handleResize); - window.removeEventListener('scroll', updateContainerBounds); - }; - }, [updateContainerBounds]); - - /* ---------------------- 데이터 상태 관리 ---------------------- */ - // 초기 데이터 설정 - const [initialPromiseData] = React.use(promises); - - // ✅ 테이블 데이터 상태 추가 - const [tableData, setTableData] = React.useState(initialPromiseData); - const [isDataLoading, setIsDataLoading] = React.useState(false); - - const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { - try { - const value = getSearchParam(key); - return value ? JSON.parse(value) : defaultValue; - } catch { - return defaultValue; - } - }, [getSearchParam]); - - const parseSearchParam = <T,>(key: string, defaultValue: T): T => { - return parseSearchParamHelper(key, defaultValue); - }; - - /* ---------------------- 초기 설정 ---------------------------- */ - const initialSettings = React.useMemo(() => ({ - page: parseInt(getSearchParam("page", "1")), - perPage: parseInt(getSearchParam("perPage", "10")), - sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], - filters: parseSearchParam("filters", []), - joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", - search: getSearchParam("search", ""), - columnVisibility: {}, - columnOrder: [], - pinnedColumns: { left: [], right: ["actions"] }, - groupBy: [], - expandedRows: [], - }), [getSearchParam, parseSearchParam]); - - /* --------------------- 프리셋 훅 ------------------------------ */ - const { - presets, - activePresetId, - hasUnsavedChanges, - isLoading: presetsLoading, - createPreset, - applyPreset, - updatePreset, - deletePreset, - setDefaultPreset, - renamePreset, - getCurrentSettings, - } = useTablePresets<LegalWorksDetailView>( - "legal-works-table", - initialSettings - ); - - /* --------------------- 컬럼 ------------------------------ */ - const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); - - /* 기본 필터 */ - const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [ - { id: "vendorCode", label: "벤더 코드" }, - { id: "vendorName", label: "벤더명" }, - { id: "status", label: "상태" }, - ]; - - /* 고급 필터 */ - const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [ - { - id: "category", label: "구분", type: "select", options: [ - { label: "CP", value: "CP" }, - { label: "GTC", value: "GTC" }, - { label: "기타", value: "기타" } - ] - }, - { - id: "status", label: "상태", type: "select", options: [ - { label: "검토요청", value: "검토요청" }, - { label: "담당자배정", value: "담당자배정" }, - { label: "검토중", value: "검토중" }, - { label: "답변완료", value: "답변완료" }, - { label: "재검토요청", value: "재검토요청" }, - { label: "보류", value: "보류" }, - { label: "취소", value: "취소" } - ] - }, - { id: "vendorCode", label: "벤더 코드", type: "text" }, - { id: "vendorName", label: "벤더명", type: "text" }, - { - id: "isUrgent", label: "긴급여부", type: "select", options: [ - { label: "긴급", value: "true" }, - { label: "일반", value: "false" } - ] - }, - { - id: "reviewDepartment", label: "검토부문", type: "select", options: [ - { label: "준법문의", value: "준법문의" }, - { label: "법무검토", value: "법무검토" } - ] - }, - { - id: "inquiryType", label: "문의종류", type: "select", options: [ - { label: "국내계약", value: "국내계약" }, - { label: "국내자문", value: "국내자문" }, - { label: "해외계약", value: "해외계약" }, - { label: "해외자문", value: "해외자문" } - ] - }, - { id: "reviewer", label: "검토요청자", type: "text" }, - { id: "legalResponder", label: "법무답변자", type: "text" }, - { id: "requestDate", label: "답변요청일", type: "date" }, - { id: "consultationDate", label: "의뢰일", type: "date" }, - { id: "expectedAnswerDate", label: "답변예정일", type: "date" }, - { id: "legalCompletionDate", label: "법무완료일", type: "date" }, - { id: "createdAt", label: "생성일", type: "date" }, - ]; - - /* current settings */ - const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); - - const initialState = React.useMemo(() => { - return { - sorting: initialSettings.sort.filter(sortItem => { - const columnExists = columns.some(col => col.accessorKey === sortItem.id) - return columnExists - }) as any, - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [currentSettings, initialSettings.sort, columns]) - - /* ----------------------- useDataTable ------------------------ */ - const { table } = useDataTable({ - data: tableData.data, - columns, - pageCount: tableData.pageCount, - rowCount: tableData.total || tableData.data.length, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - - /* ---------------------- helper ------------------------------ */ - const getActiveFilterCount = React.useCallback(() => { - try { - // URL에서 현재 필터 수 확인 - const filtersParam = getSearchParam("filters"); - if (filtersParam) { - const filters = JSON.parse(filtersParam); - return Array.isArray(filters) ? filters.length : 0; - } - return 0; - } catch { - return 0; - } - }, [getSearchParam]); - - const FILTER_PANEL_WIDTH = 400; - - /* ---------------------------- JSX ---------------------------- */ - return ( - <> - {/* Filter Panel */} - <div - className={cn( - "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", - isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" - )} - style={{ - width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", - top: `${containerTop}px`, - height: `calc(100vh - ${containerTop}px)` - }} - > - <LegalWorkFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onFiltersApply={handleFiltersApply} - isLoading={false} - /> - </div> - - {/* Main Container */} - <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}> - <div className="flex w-full h-full"> - <div - className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" - style={{ - width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", - marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", - }} - > - {/* Header */} - <div className="flex items-center justify-between p-4 bg-background shrink-0"> - <Button - variant="outline" - size="sm" - onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} - className="flex items-center shadow-sm" - > - {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} - {getActiveFilterCount() > 0 && ( - <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveFilterCount()} - </span> - )} - </Button> - <div className="text-sm text-muted-foreground"> - 총 {tableData.total || tableData.data.length}건 - </div> - </div> - - {/* Stats */} - <div className="px-4"> - <LegalWorksStats data={tableData.data} /> - </div> - - {/* Table */} - <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}> - {isDataLoading && ( - <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center"> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> - 필터링 중... - </div> - </div> - )} - <DataTable table={table} className="h-full"> - {/* ✅ EvaluationTargetsTable과 정확히 동일한 DataTableAdvancedToolbar */} - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - debounceMs={300} - shallow={false} - externalFilters={externalFilters} - externalJoinOperator={externalJoinOperator} - onFiltersChange={(filters, joinOperator) => { - console.log("=== 필터 변경 감지 ===", filters, joinOperator); - }} - > - <div className="flex items-center gap-2"> - <TablePresetManager<LegalWorksDetailView> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> - - <LegalWorksTableToolbarActions table={table} onRefresh={refreshData} /> - </div> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 편집 다이얼로그 */} - <EditLegalWorkSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - work={rowAction?.row.original ?? null} - onSuccess={() => { - rowAction?.row.toggleSelected(false); - refreshData(); - }} - /> - - <LegalWorkDetailDialog - open={rowAction?.type === "view"} - onOpenChange={(open) => !open && setRowAction(null)} - work={rowAction?.row.original || null} - /> - - <DeleteLegalWorksDialog - open={rowAction?.type === "delete"} - onOpenChange={(open) => !open && setRowAction(null)} - legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} - showTrigger={false} - onSuccess={() => { - setRowAction(null); - refreshData(); - }} - /> - </div> - </div> - </div> - </div> - </> - ); -}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx deleted file mode 100644 index 4df3568c..00000000 --- a/lib/legal-review/status/legal-table.tsx +++ /dev/null @@ -1,546 +0,0 @@ -// ============================================================================ -// components/evaluation-targets-table.tsx (CLIENT COMPONENT) -// ─ 정리된 버전 ─ -// ============================================================================ -"use client"; - -import * as React from "react"; -import { useSearchParams } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { HelpCircle, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -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 { cn } from "@/lib/utils"; -import { useTablePresets } from "@/components/data-table/use-table-presets"; -import { TablePresetManager } from "@/components/data-table/data-table-preset"; -import { LegalWorksDetailView } from "@/db/schema"; -import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions"; -import { getLegalWorks } from "../service"; -import { getLegalWorksColumns } from "./legal-works-columns"; -import { LegalWorkFilterSheet } from "./legal-work-filter-sheet"; -import { EditLegalWorkSheet } from "./update-legal-work-dialog"; -import { LegalWorkDetailDialog } from "./legal-work-detail-dialog"; -import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"; - - -/* -------------------------------------------------------------------------- */ -/* Stats Card */ -/* -------------------------------------------------------------------------- */ -function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) { - const stats = React.useMemo(() => { - const total = data.length; - const pending = data.filter(item => item.status === '검토요청').length; - const assigned = data.filter(item => item.status === '담당자배정').length; - const inProgress = data.filter(item => item.status === '검토중').length; - const completed = data.filter(item => item.status === '답변완료').length; - const urgent = data.filter(item => item.isUrgent).length; - - return { total, pending, assigned, inProgress, completed, urgent }; - }, [data]); - - if (stats.total === 0) { - return ( - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> - <Card className="col-span-full"> - <CardContent className="pt-6 text-center text-sm text-muted-foreground"> - 등록된 법무업무가 없습니다. - </CardContent> - </Card> - </div> - ); - } - - return ( - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">총 건수</CardTitle> - <Badge variant="outline">전체</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - 긴급 {stats.urgent}건 - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">검토요청</CardTitle> - <Badge variant="secondary">대기</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">담당자배정</CardTitle> - <Badge variant="secondary">진행</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">검토중</CardTitle> - <Badge variant="secondary">진행</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">답변완료</CardTitle> - <Badge variant="default">완료</Badge> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div> - <div className="text-xs text-muted-foreground mt-1"> - {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total - </div> - </CardContent> - </Card> - </div> - ); -} - -/* -------------------------------------------------------------------------- */ -/* EvaluationTargetsTable */ -/* -------------------------------------------------------------------------- */ -interface LegalWorksTableProps { - promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>; - currentYear: number; - className?: string; -} - -export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null); - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); - const searchParams = useSearchParams(); - - // ✅ 외부 필터 상태 (폼에서 전달받은 필터) - const [externalFilters, setExternalFilters] = React.useState<any[]>([]); - const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); - - // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 - const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { - console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); - setExternalFilters(filters); - setExternalJoinOperator(joinOperator); - // 필터 적용 후 패널 닫기 - setIsFilterPanelOpen(false); - }, []); - - - const searchString = React.useMemo( - () => searchParams.toString(), - [searchParams] - ); - - const getSearchParam = React.useCallback( - (key: string, def = "") => - new URLSearchParams(searchString).get(key) ?? def, - [searchString] - ); - - - // ✅ URL 필터 변경 감지 및 데이터 새로고침 - React.useEffect(() => { - const refetchData = async () => { - try { - setIsDataLoading(true); - - // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 - const currentFilters = getSearchParam("filters"); - const currentJoinOperator = getSearchParam("joinOperator", "and"); - const currentPage = parseInt(getSearchParam("page", "1")); - const currentPerPage = parseInt(getSearchParam("perPage", "10")); - const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; - const currentSearch = getSearchParam("search", ""); - - const searchParams = { - filters: currentFilters ? JSON.parse(currentFilters) : [], - joinOperator: currentJoinOperator as "and" | "or", - page: currentPage, - perPage: currentPerPage, - sort: currentSort, - search: currentSearch, - currentYear: currentYear - }; - - console.log("=== 새 데이터 요청 ===", searchParams); - - // 서버 액션 직접 호출 - const newData = await getLegalWorks(searchParams); - setTableData(newData); - - console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건"); - } catch (error) { - console.error("데이터 새로고침 오류:", error); - } finally { - setIsDataLoading(false); - } - }; - - /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ - - // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) - const timeoutId = setTimeout(() => { - // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 - const hasChanges = getSearchParam("filters") || - getSearchParam("search") || - getSearchParam("page") !== "1" || - getSearchParam("perPage") !== "10" || - getSearchParam("sort"); - - if (hasChanges) { - refetchData(); - } - }, 300); // 디바운스 시간 단축 - - return () => clearTimeout(timeoutId); - }, [searchString, currentYear, getSearchParam]); - - const refreshData = React.useCallback(async () => { - try { - setIsDataLoading(true); - - // 현재 URL 파라미터로 데이터 새로고침 - const currentFilters = getSearchParam("filters"); - const currentJoinOperator = getSearchParam("joinOperator", "and"); - const currentPage = parseInt(getSearchParam("page", "1")); - const currentPerPage = parseInt(getSearchParam("perPage", "10")); - const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; - const currentSearch = getSearchParam("search", ""); - - const searchParams = { - filters: currentFilters ? JSON.parse(currentFilters) : [], - joinOperator: currentJoinOperator as "and" | "or", - page: currentPage, - perPage: currentPerPage, - sort: currentSort, - search: currentSearch, - currentYear: currentYear - }; - - const newData = await getLegalWorks(searchParams); - setTableData(newData); - - console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); - } catch (error) { - console.error("데이터 새로고침 오류:", error); - } finally { - setIsDataLoading(false); - } - }, [currentYear, getSearchParam]); - - /* --------------------------- layout refs --------------------------- */ - const containerRef = React.useRef<HTMLDivElement>(null); - const [containerTop, setContainerTop] = React.useState(0); - - const updateContainerBounds = React.useCallback(() => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect() - const newTop = rect.top - setContainerTop(prevTop => { - if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 - return newTop - } - return prevTop - }) - } - }, []) - React.useEffect(() => { - updateContainerBounds(); - - const handleResize = () => { - updateContainerBounds(); - }; - - window.addEventListener('resize', handleResize); - window.addEventListener('scroll', updateContainerBounds); - - return () => { - window.removeEventListener('resize', handleResize); - window.removeEventListener('scroll', updateContainerBounds); - }; - }, [updateContainerBounds]); - - /* ---------------------- 데이터 상태 관리 ---------------------- */ - // 초기 데이터 설정 - const [initialPromiseData] = React.use(promises); - - // ✅ 테이블 데이터 상태 추가 - const [tableData, setTableData] = React.useState(initialPromiseData); - const [isDataLoading, setIsDataLoading] = React.useState(false); - - const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { - try { - const value = getSearchParam(key); - return value ? JSON.parse(value) : defaultValue; - } catch { - return defaultValue; - } - }, [getSearchParam]); - - const parseSearchParam = <T,>(key: string, defaultValue: T): T => { - return parseSearchParamHelper(key, defaultValue); - }; - - /* ---------------------- 초기 설정 ---------------------------- */ - const initialSettings = React.useMemo(() => ({ - page: parseInt(getSearchParam("page", "1")), - perPage: parseInt(getSearchParam("perPage", "10")), - sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], - filters: parseSearchParam("filters", []), - joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", - search: getSearchParam("search", ""), - columnVisibility: {}, - columnOrder: [], - pinnedColumns: { left: [], right: ["actions"] }, - groupBy: [], - expandedRows: [], - }), [getSearchParam, parseSearchParam]); - - /* --------------------- 프리셋 훅 ------------------------------ */ - const { - presets, - activePresetId, - hasUnsavedChanges, - isLoading: presetsLoading, - createPreset, - applyPreset, - updatePreset, - deletePreset, - setDefaultPreset, - renamePreset, - getCurrentSettings, - } = useTablePresets<LegalWorksDetailView>( - "legal-review-table", - initialSettings - ); - - - - /* --------------------- 컬럼 ------------------------------ */ - const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); - - /* 기본 필터 */ - const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [ - { id: "vendorCode", label: "벤더 코드" }, - { id: "vendorName", label: "벤더명" }, - { id: "status", label: "상태" }, - ]; - - /* 고급 필터 */ - const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [ - ]; - - /* current settings */ - const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); - - const initialState = React.useMemo(() => { - return { - sorting: initialSettings.sort.filter(sortItem => { - const columnExists = columns.some(col => col.accessorKey === sortItem.id) - return columnExists - }) as any, - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [currentSettings, initialSettings.sort, columns]) - - /* ----------------------- useDataTable ------------------------ */ - const { table } = useDataTable({ - data: tableData.data, - columns, - pageCount: tableData.pageCount, - rowCount: tableData.total || tableData.data.length, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - - /* ---------------------- helper ------------------------------ */ - const getActiveFilterCount = React.useCallback(() => { - try { - // URL에서 현재 필터 수 확인 - const filtersParam = getSearchParam("filters"); - if (filtersParam) { - const filters = JSON.parse(filtersParam); - return Array.isArray(filters) ? filters.length : 0; - } - return 0; - } catch { - return 0; - } - }, [getSearchParam]); - - const FILTER_PANEL_WIDTH = 400; - - /* ---------------------------- JSX ---------------------------- */ - return ( - <> - {/* Filter Panel */} - <div - className={cn( - "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", - isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" - )} - style={{ - width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", - top: `${containerTop}px`, - height: `calc(100vh - ${containerTop}px)` - }} - > - <LegalWorkFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 - isLoading={false} - /> - </div> - - {/* Main Container */} - <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}> - <div className="flex w-full h-full"> - <div - className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" - style={{ - width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", - marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", - }} - > - {/* Header */} - <div className="flex items-center justify-between p-4 bg-background shrink-0"> - <Button - variant="outline" - size="sm" - onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} - className="flex items-center shadow-sm" - > - {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} - {getActiveFilterCount() > 0 && ( - <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveFilterCount()} - </span> - )} - </Button> - <div className="text-sm text-muted-foreground"> - 총 {tableData.total || tableData.data.length}건 - </div> - </div> - - {/* Stats */} - <div className="px-4"> - <LegalWorksStats data={tableData.data} /> - - </div> - - {/* Table */} - <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}> - {isDataLoading && ( - <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center"> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> - 필터링 중... - </div> - </div> - )} - <DataTable table={table} className="h-full"> - {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */} - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - debounceMs={300} - shallow={false} - externalFilters={externalFilters} - externalJoinOperator={externalJoinOperator} - onFiltersChange={(filters, joinOperator) => { - console.log("=== 필터 변경 감지 ===", filters, joinOperator); - }} - > - <div className="flex items-center gap-2"> - <TablePresetManager<LegalWorksDetailView> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> - - <LegalWorksTableToolbarActions table={table}onRefresh={refreshData} /> - </div> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 다이얼로그들 */} - <EditLegalWorkSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - work={rowAction?.row.original || null} - onSuccess={() => { - rowAction?.row.toggleSelected(false); - refreshData(); - }} - /> - - <LegalWorkDetailDialog - open={rowAction?.type === "view"} - onOpenChange={(open) => !open && setRowAction(null)} - work={rowAction?.row.original || null} - /> - - <DeleteLegalWorksDialog - open={rowAction?.type === "delete"} - onOpenChange={(open) => !open && setRowAction(null)} - legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} - showTrigger={false} - onSuccess={() => { - setRowAction(null); - refreshData(); - }} - /> - - </div> - </div> - </div> - </div> - </> - ); -}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-work-detail-dialog.tsx b/lib/legal-review/status/legal-work-detail-dialog.tsx deleted file mode 100644 index 23ceccb2..00000000 --- a/lib/legal-review/status/legal-work-detail-dialog.tsx +++ /dev/null @@ -1,409 +0,0 @@ -"use client"; - -import * as React from "react"; -import { - Eye, - FileText, - Building, - User, - Calendar, - Clock, - MessageSquare, - CheckCircle, - ShieldCheck, -} from "lucide-react"; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { formatDate } from "@/lib/utils"; -import { LegalWorksDetailView } from "@/db/schema"; - -// ----------------------------------------------------------------------------- -// TYPES -// ----------------------------------------------------------------------------- - -type LegalWorkData = LegalWorksDetailView; - -interface LegalWorkDetailDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - work: LegalWorkData | null; -} - -// ----------------------------------------------------------------------------- -// HELPERS -// ----------------------------------------------------------------------------- - -// 상태별 배지 스타일 -const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "검토요청": - return "bg-blue-100 text-blue-800 border-blue-200"; - case "담당자배정": - return "bg-yellow-100 text-yellow-800 border-yellow-200"; - case "검토중": - return "bg-orange-100 text-orange-800 border-orange-200"; - case "답변완료": - return "bg-green-100 text-green-800 border-green-200"; - case "재검토요청": - return "bg-purple-100 text-purple-800 border-purple-200"; - case "보류": - return "bg-gray-100 text-gray-800 border-gray-200"; - case "취소": - return "bg-red-100 text-red-800 border-red-200"; - default: - return "bg-gray-100 text-gray-800 border-gray-200"; - } -}; - -export function LegalWorkDetailDialog({ - open, - onOpenChange, - work, -}: LegalWorkDetailDialogProps) { - if (!work) return null; - - // --------------------------------------------------------------------------- - // CONDITIONAL FLAGS - // --------------------------------------------------------------------------- - - const isLegalReview = work.reviewDepartment === "법무검토"; - const isCompliance = work.reviewDepartment === "준법문의"; - - const isDomesticContract = work.inquiryType === "국내계약"; - const isDomesticAdvisory = work.inquiryType === "국내자문"; - const isOverseasContract = work.inquiryType === "해외계약"; - const isOverseasAdvisory = work.inquiryType === "해외자문"; - - const isContractTypeActive = - isDomesticContract || isOverseasContract || isOverseasAdvisory; - const isDomesticContractFieldsActive = isDomesticContract; - const isFactualRelationActive = isDomesticAdvisory || isOverseasAdvisory; - const isOverseasFieldsActive = isOverseasContract || isOverseasAdvisory; - - // --------------------------------------------------------------------------- - // RENDER - // --------------------------------------------------------------------------- - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col"> - {/* 헤더 */} - <div className="flex-shrink-0 p-6 border-b"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Eye className="h-5 w-5" /> 법무업무 상세보기 - </DialogTitle> - <DialogDescription> - 법무업무 #{work.id}의 상세 정보를 확인합니다. - </DialogDescription> - </DialogHeader> - </div> - - {/* 본문 */} - <ScrollArea className="flex-1 p-6"> - <div className="space-y-6"> - {/* 1. 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-lg"> - <FileText className="h-5 w-5" /> 기본 정보 - </CardTitle> - </CardHeader> - <CardContent> - <div className="grid grid-cols-2 gap-6 text-sm"> - <div className="space-y-4"> - <div className="flex items-center gap-2"> - <span className="font-medium text-muted-foreground">업무 ID:</span> - <Badge variant="outline">#{work.id}</Badge> - </div> - <div className="flex items-center gap-2"> - <span className="font-medium text-muted-foreground">구분:</span> - <Badge - variant={ - work.category === "CP" - ? "default" - : work.category === "GTC" - ? "secondary" - : "outline" - } - > - {work.category} - </Badge> - {work.isUrgent && ( - <Badge variant="destructive" className="text-xs"> - 긴급 - </Badge> - )} - </div> - <div className="flex items-center gap-2"> - <span className="font-medium text-muted-foreground">상태:</span> - <Badge - className={getStatusBadgeVariant(work.status)} - variant="outline" - > - {work.status} - </Badge> - </div> - </div> - <div className="space-y-4"> - <div className="flex items-center gap-2"> - <Building className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium text-muted-foreground">벤더:</span> - <span> - {work.vendorCode} - {work.vendorName} - </span> - </div> - <div className="flex items-center gap-2"> - <Calendar className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium text-muted-foreground">의뢰일:</span> - <span>{formatDate(work.consultationDate, "KR")}</span> - </div> - <div className="flex items-center gap-2"> - <Clock className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium text-muted-foreground">답변요청일:</span> - <span>{formatDate(work.requestDate, "KR")}</span> - </div> - </div> - </div> - </CardContent> - </Card> - - {/* 2. 담당자 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-lg"> - <User className="h-5 w-5" /> 담당자 정보 - </CardTitle> - </CardHeader> - <CardContent> - <div className="grid grid-cols-2 gap-6 text-sm"> - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">검토요청자</span> - <p>{work.reviewer || "미지정"}</p> - </div> - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">법무답변자</span> - <p>{work.legalResponder || "미배정"}</p> - </div> - {work.expectedAnswerDate && ( - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">답변예정일</span> - <p>{formatDate(work.expectedAnswerDate, "KR")}</p> - </div> - )} - {work.legalCompletionDate && ( - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">법무완료일</span> - <p>{formatDate(work.legalCompletionDate, "KR")}</p> - </div> - )} - </div> - </CardContent> - </Card> - - {/* 3. 법무업무 상세 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-lg"> - <ShieldCheck className="h-5 w-5" /> 법무업무 상세 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4 text-sm"> - <div className="grid grid-cols-2 gap-6"> - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">검토부문</span> - <Badge variant="outline">{work.reviewDepartment}</Badge> - </div> - {work.inquiryType && ( - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">문의종류</span> - <Badge variant="secondary">{work.inquiryType}</Badge> - </div> - )} - {isCompliance && ( - <div className="space-y-2 col-span-2"> - <span className="font-medium text-muted-foreground">공개여부</span> - <Badge variant={work.isPublic ? "default" : "outline"}> - {work.isPublic ? "공개" : "비공개"} - </Badge> - </div> - )} - </div> - - {/* 법무검토 전용 필드 */} - {isLegalReview && ( - <> - {work.contractProjectName && ( - <> - <Separator className="my-2" /> - <div className="space-y-2"> - <span className="font-medium text-muted-foreground"> - 계약명 / 프로젝트명 - </span> - <p>{work.contractProjectName}</p> - </div> - </> - )} - - {/* 계약서 종류 */} - {isContractTypeActive && work.contractType && ( - <div className="space-y-2"> - <span className="font-medium text-muted-foreground">계약서 종류</span> - <Badge variant="outline" className="max-w-max"> - {work.contractType} - </Badge> - </div> - )} - - {/* 국내계약 전용 필드 */} - {isDomesticContractFieldsActive && ( - <div className="grid grid-cols-2 gap-6 mt-4"> - {work.contractCounterparty && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground"> - 계약상대방 - </span> - <p>{work.contractCounterparty}</p> - </div> - )} - {work.counterpartyType && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground"> - 계약상대방 구분 - </span> - <p>{work.counterpartyType}</p> - </div> - )} - {work.contractPeriod && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">계약기간</span> - <p>{work.contractPeriod}</p> - </div> - )} - {work.contractAmount && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">계약금액</span> - <p>{work.contractAmount}</p> - </div> - )} - </div> - )} - - {/* 사실관계 */} - {isFactualRelationActive && work.factualRelation && ( - <div className="space-y-2 mt-4"> - <span className="font-medium text-muted-foreground">사실관계</span> - <p className="whitespace-pre-wrap">{work.factualRelation}</p> - </div> - )} - - {/* 해외 전용 필드 */} - {isOverseasFieldsActive && ( - <div className="grid grid-cols-2 gap-6 mt-4"> - {work.projectNumber && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">프로젝트번호</span> - <p>{work.projectNumber}</p> - </div> - )} - {work.shipownerOrderer && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">선주 / 발주처</span> - <p>{work.shipownerOrderer}</p> - </div> - )} - {work.projectType && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">프로젝트종류</span> - <p>{work.projectType}</p> - </div> - )} - {work.governingLaw && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">준거법</span> - <p>{work.governingLaw}</p> - </div> - )} - </div> - )} - </> - )} - </CardContent> - </Card> - - {/* 4. 요청 내용 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-lg"> - <MessageSquare className="h-5 w-5" /> 요청 내용 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4 text-sm"> - {work.title && ( - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">제목</span> - <p className="font-medium">{work.title}</p> - </div> - )} - <Separator /> - <div className="space-y-1"> - <span className="font-medium text-muted-foreground">상세 내용</span> - <div className="bg-muted/30 rounded-lg p-4"> - {work.requestContent ? ( - <div className="prose prose-sm max-w-none"> - <div - dangerouslySetInnerHTML={{ __html: work.requestContent }} - /> - </div> - ) : ( - <p className="italic text-muted-foreground">요청 내용이 없습니다.</p> - )} - </div> - </div> - {work.attachmentCount > 0 && ( - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4" /> 첨부파일 {work.attachmentCount}개 - </div> - )} - </CardContent> - </Card> - - {/* 5. 답변 내용 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-lg"> - <CheckCircle className="h-5 w-5" /> 답변 내용 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4 text-sm"> - <div className="bg-green-50 border border-green-200 rounded-lg p-4"> - {work.responseContent ? ( - <div className="prose prose-sm max-w-none"> - <div - dangerouslySetInnerHTML={{ __html: work.responseContent }} - /> - </div> - ) : ( - <p className="italic text-muted-foreground"> - 아직 답변이 등록되지 않았습니다. - </p> - )} - </div> - </CardContent> - </Card> - </div> - </ScrollArea> - </DialogContent> - </Dialog> - ); -} diff --git a/lib/legal-review/status/legal-work-filter-sheet.tsx b/lib/legal-review/status/legal-work-filter-sheet.tsx deleted file mode 100644 index 4ac877a9..00000000 --- a/lib/legal-review/status/legal-work-filter-sheet.tsx +++ /dev/null @@ -1,897 +0,0 @@ -"use client" - -import { useTransition, useState } from "react" -import { useRouter } from "next/navigation" -import { z } from "zod" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Search, X } from "lucide-react" -import { customAlphabet } from "nanoid" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { LEGAL_WORK_FILTER_OPTIONS } from "@/types/legal" - -// nanoid 생성기 -const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) - -// 법무업무 필터 스키마 정의 -const legalWorkFilterSchema = z.object({ - category: z.string().optional(), - status: z.string().optional(), - isUrgent: z.string().optional(), - reviewDepartment: z.string().optional(), - inquiryType: z.string().optional(), - reviewer: z.string().optional(), - legalResponder: z.string().optional(), - vendorCode: z.string().optional(), - vendorName: z.string().optional(), - requestDateFrom: z.string().optional(), - requestDateTo: z.string().optional(), - consultationDateFrom: z.string().optional(), - consultationDateTo: z.string().optional(), -}) - -type LegalWorkFilterFormValues = z.infer<typeof legalWorkFilterSchema> - -interface LegalWorkFilterSheetProps { - isOpen: boolean; - onClose: () => void; - onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void; - isLoading?: boolean; -} - -export function LegalWorkFilterSheet({ - isOpen, - onClose, - onFiltersApply, - isLoading = false -}: LegalWorkFilterSheetProps) { - const router = useRouter() - const [isPending, startTransition] = useTransition() - const [joinOperator, setJoinOperator] = useState<"and" | "or">("and") - - // 폼 상태 초기화 - const form = useForm<LegalWorkFilterFormValues>({ - resolver: zodResolver(legalWorkFilterSchema), - defaultValues: { - category: "", - status: "", - isUrgent: "", - reviewDepartment: "", - inquiryType: "", - reviewer: "", - legalResponder: "", - vendorCode: "", - vendorName: "", - requestDateFrom: "", - requestDateTo: "", - consultationDateFrom: "", - consultationDateTo: "", - }, - }) - - // ✅ 폼 제출 핸들러 - 필터 배열 생성 및 전달 - async function onSubmit(data: LegalWorkFilterFormValues) { - startTransition(async () => { - try { - const newFilters = [] - - // 구분 필터 - if (data.category?.trim()) { - newFilters.push({ - id: "category", - value: data.category.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // 상태 필터 - if (data.status?.trim()) { - newFilters.push({ - id: "status", - value: data.status.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // 긴급여부 필터 - if (data.isUrgent?.trim()) { - newFilters.push({ - id: "isUrgent", - value: data.isUrgent.trim() === "true", - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // 검토부문 필터 - if (data.reviewDepartment?.trim()) { - newFilters.push({ - id: "reviewDepartment", - value: data.reviewDepartment.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // 문의종류 필터 - if (data.inquiryType?.trim()) { - newFilters.push({ - id: "inquiryType", - value: data.inquiryType.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // 요청자 필터 - if (data.reviewer?.trim()) { - newFilters.push({ - id: "reviewer", - value: data.reviewer.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - // 법무답변자 필터 - if (data.legalResponder?.trim()) { - newFilters.push({ - id: "legalResponder", - value: data.legalResponder.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - // 벤더 코드 필터 - if (data.vendorCode?.trim()) { - newFilters.push({ - id: "vendorCode", - value: data.vendorCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - // 벤더명 필터 - if (data.vendorName?.trim()) { - newFilters.push({ - id: "vendorName", - value: data.vendorName.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - // 검토 요청일 범위 필터 - if (data.requestDateFrom?.trim() && data.requestDateTo?.trim()) { - // 범위 필터 (시작일과 종료일 모두 있는 경우) - newFilters.push({ - id: "requestDate", - value: [data.requestDateFrom.trim(), data.requestDateTo.trim()], - type: "date", - operator: "between", - rowId: generateId() - }) - } else if (data.requestDateFrom?.trim()) { - // 시작일만 있는 경우 (이후 날짜) - newFilters.push({ - id: "requestDate", - value: data.requestDateFrom.trim(), - type: "date", - operator: "gte", - rowId: generateId() - }) - } else if (data.requestDateTo?.trim()) { - // 종료일만 있는 경우 (이전 날짜) - newFilters.push({ - id: "requestDate", - value: data.requestDateTo.trim(), - type: "date", - operator: "lte", - rowId: generateId() - }) - } - - // 의뢰일 범위 필터 - if (data.consultationDateFrom?.trim() && data.consultationDateTo?.trim()) { - // 범위 필터 (시작일과 종료일 모두 있는 경우) - newFilters.push({ - id: "consultationDate", - value: [data.consultationDateFrom.trim(), data.consultationDateTo.trim()], - type: "date", - operator: "between", - rowId: generateId() - }) - } else if (data.consultationDateFrom?.trim()) { - // 시작일만 있는 경우 (이후 날짜) - newFilters.push({ - id: "consultationDate", - value: data.consultationDateFrom.trim(), - type: "date", - operator: "gte", - rowId: generateId() - }) - } else if (data.consultationDateTo?.trim()) { - // 종료일만 있는 경우 (이전 날짜) - newFilters.push({ - id: "consultationDate", - value: data.consultationDateTo.trim(), - type: "date", - operator: "lte", - rowId: generateId() - }) - } - - console.log("=== 생성된 필터들 ===", newFilters); - console.log("=== 조인 연산자 ===", joinOperator); - - // ✅ 부모 컴포넌트에 필터 전달 - onFiltersApply(newFilters, joinOperator); - - console.log("=== 필터 적용 완료 ==="); - } catch (error) { - console.error("법무업무 필터 적용 오류:", error); - } - }) - } - - // ✅ 필터 초기화 핸들러 - function handleReset() { - // 1. 폼 초기화 - form.reset({ - category: "", - status: "", - isUrgent: "", - reviewDepartment: "", - inquiryType: "", - reviewer: "", - legalResponder: "", - vendorCode: "", - vendorName: "", - requestDateFrom: "", - requestDateTo: "", - consultationDateFrom: "", - consultationDateTo: "", - }); - - // 2. 조인 연산자 초기화 - setJoinOperator("and"); - - // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정) - const currentUrl = new URL(window.location.href); - const newSearchParams = new URLSearchParams(currentUrl.search); - - // 필터 관련 파라미터 초기화 - newSearchParams.set("filters", JSON.stringify([])); - newSearchParams.set("joinOperator", "and"); - newSearchParams.set("page", "1"); - newSearchParams.delete("search"); // 검색어 제거 - - // URL 업데이트 - router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`); - - // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해) - onFiltersApply([], "and"); - - console.log("=== 필터 완전 초기화 완료 ==="); - } - - if (!isOpen) { - return null; - } - - return ( - <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> - {/* Filter Panel Header */} - <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> - <h3 className="text-lg font-semibold whitespace-nowrap">법무업무 검색 필터</h3> - <Button - variant="ghost" - size="icon" - onClick={onClose} - className="h-8 w-8" - > - <X className="size-4" /> - </Button> - </div> - - {/* Join Operator Selection */} - <div className="px-6 shrink-0"> - <label className="text-sm font-medium">조건 결합 방식</label> - <Select - value={joinOperator} - onValueChange={(value: "and" | "or") => setJoinOperator(value)} - > - <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> - <SelectValue placeholder="조건 결합 방식" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> - <SelectItem value="or">하나라도 충족 (OR)</SelectItem> - </SelectContent> - </Select> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> - {/* Scrollable content area */} - <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> - <div className="space-y-4 pt-2"> - - {/* 구분 */} - <FormField - control={form.control} - name="category" - render={({ field }) => ( - <FormItem> - <FormLabel>구분</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="구분 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("category", ""); - }} - > - <X className="size-3" /> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {LEGAL_WORK_FILTER_OPTIONS.categories.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 상태 */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>상태</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="상태 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("status", ""); - }} - > - <X className="size-3" /> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {LEGAL_WORK_FILTER_OPTIONS.statuses.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 긴급여부 */} - <FormField - control={form.control} - name="isUrgent" - render={({ field }) => ( - <FormItem> - <FormLabel>긴급여부</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="긴급여부 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("isUrgent", ""); - }} - > - <X className="size-3" /> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="true">긴급</SelectItem> - <SelectItem value="false">일반</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 검토부문 */} - <FormField - control={form.control} - name="reviewDepartment" - render={({ field }) => ( - <FormItem> - <FormLabel>검토부문</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="검토부문 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("reviewDepartment", ""); - }} - > - <X className="size-3" /> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {LEGAL_WORK_FILTER_OPTIONS.reviewDepartments.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 문의종류 */} - <FormField - control={form.control} - name="inquiryType" - render={({ field }) => ( - <FormItem> - <FormLabel>문의종류</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="문의종류 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("inquiryType", ""); - }} - > - <X className="size-3" /> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {LEGAL_WORK_FILTER_OPTIONS.inquiryTypes.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 요청자 */} - <FormField - control={form.control} - name="reviewer" - render={({ field }) => ( - <FormItem> - <FormLabel>요청자</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="요청자명 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("reviewer", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 법무답변자 */} - <FormField - control={form.control} - name="legalResponder" - render={({ field }) => ( - <FormItem> - <FormLabel>법무답변자</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="법무답변자명 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("legalResponder", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 벤더 코드 */} - <FormField - control={form.control} - name="vendorCode" - render={({ field }) => ( - <FormItem> - <FormLabel>벤더 코드</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="벤더 코드 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("vendorCode", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 벤더명 */} - <FormField - control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel>벤더명</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder="벤더명 입력" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("vendorName", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 검토 요청일 범위 */} - <div className="space-y-2"> - <label className="text-sm font-medium">검토 요청일</label> - - {/* 시작일 */} - <FormField - control={form.control} - name="requestDateFrom" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel> - <FormControl> - <div className="relative"> - <Input - type="date" - placeholder="시작일 선택" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("requestDateFrom", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 종료일 */} - <FormField - control={form.control} - name="requestDateTo" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel> - <FormControl> - <div className="relative"> - <Input - type="date" - placeholder="종료일 선택" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("requestDateTo", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* 의뢰일 범위 */} - <div className="space-y-2"> - <label className="text-sm font-medium">의뢰일</label> - - {/* 시작일 */} - <FormField - control={form.control} - name="consultationDateFrom" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel> - <FormControl> - <div className="relative"> - <Input - type="date" - placeholder="시작일 선택" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("consultationDateFrom", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 종료일 */} - <FormField - control={form.control} - name="consultationDateTo" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel> - <FormControl> - <div className="relative"> - <Input - type="date" - placeholder="종료일 선택" - {...field} - className={cn(field.value && "pr-8", "bg-white")} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("consultationDateTo", ""); - }} - > - <X className="size-3.5" /> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - </div> - </div> - - {/* Fixed buttons at bottom */} - <div className="p-4 shrink-0"> - <div className="flex gap-2 justify-end"> - <Button - type="button" - variant="outline" - onClick={handleReset} - disabled={isPending} - className="px-4" - > - 초기화 - </Button> - <Button - type="submit" - variant="default" - disabled={isPending || isLoading} - className="px-4 bg-blue-600 hover:bg-blue-700 text-white" - > - <Search className="size-4 mr-2" /> - {isPending || isLoading ? "조회 중..." : "조회"} - </Button> - </div> - </div> - </form> - </Form> - </div> - ) -}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-works-columns.tsx b/lib/legal-review/status/legal-works-columns.tsx deleted file mode 100644 index c94b414d..00000000 --- a/lib/legal-review/status/legal-works-columns.tsx +++ /dev/null @@ -1,222 +0,0 @@ -// components/legal-works/legal-works-columns.tsx -"use client"; - -import * as React from "react"; -import { type ColumnDef } from "@tanstack/react-table"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Ellipsis, Paperclip } from "lucide-react"; - -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; -import type { DataTableRowAction } from "@/types/table"; -import { formatDate } from "@/lib/utils"; -import { LegalWorksDetailView } from "@/db/schema"; - -// ──────────────────────────────────────────────────────────────────────────── -// 타입 -// ──────────────────────────────────────────────────────────────────────────── -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null> - >; -} - -// ──────────────────────────────────────────────────────────────────────────── -// 헬퍼 -// ──────────────────────────────────────────────────────────────────────────── -const statusVariant = (status: string) => { - const map: Record<string, string> = { - 검토요청: "bg-blue-100 text-blue-800 border-blue-200", - 담당자배정: "bg-yellow-100 text-yellow-800 border-yellow-200", - 검토중: "bg-orange-100 text-orange-800 border-orange-200", - 답변완료: "bg-green-100 text-green-800 border-green-200", - 재검토요청: "bg-purple-100 text-purple-800 border-purple-200", - 보류: "bg-gray-100 text-gray-800 border-gray-200", - 취소: "bg-red-100 text-red-800 border-red-200", - }; - return map[status] ?? "bg-gray-100 text-gray-800 border-gray-200"; -}; - -const categoryBadge = (category: string) => ( - <Badge - variant={ - category === "CP" ? "default" : category === "GTC" ? "secondary" : "outline" - } - > - {category} - </Badge> -); - -const urgentBadge = (isUrgent: boolean) => - isUrgent ? ( - <Badge variant="destructive" className="text-xs px-1 py-0"> - 긴급 - </Badge> - ) : null; - -const header = (title: string) => - ({ column }: { column: any }) => - <DataTableColumnHeaderSimple column={column} title={title} />; - -// ──────────────────────────────────────────────────────────────────────────── -// 기본 컬럼 -// ──────────────────────────────────────────────────────────────────────────── -const BASE_COLUMNS: ColumnDef<LegalWorksDetailView>[] = [ - // 선택 체크박스 - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} - aria-label="select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(v) => row.toggleSelected(!!v)} - aria-label="select row" - className="translate-y-0.5" - /> - ), - enableSorting: false, - enableHiding: false, - size: 40, - }, - - // 번호, 구분, 상태 - { - accessorKey: "id", - header: header("No."), - cell: ({ row }) => ( - <div className="w-[60px] text-center font-medium">{row.getValue("id")}</div> - ), - size: 80, - }, - { - accessorKey: "category", - header: header("구분"), - cell: ({ row }) => categoryBadge(row.getValue("category")), - size: 80, - }, - { - accessorKey: "status", - header: header("상태"), - cell: ({ row }) => ( - <Badge className={statusVariant(row.getValue("status"))} variant="outline"> - {row.getValue("status")} - </Badge> - ), - size: 120, - }, - - // 벤더 코드·이름 - { - accessorKey: "vendorCode", - header: header("벤더 코드"), - cell: ({ row }) => <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>, - size: 120, - }, - { - accessorKey: "vendorName", - header: header("벤더명"), - cell: ({ row }) => { - const name = row.getValue<string>("vendorName"); - return ( - <div className="flex items-center gap-2 truncate max-w-[200px]" title={name}> - {urgentBadge(row.original.isUrgent)} - {name} - </div> - ); - }, - size: 200, - }, - - // 날짜·첨부 - { - accessorKey: "requestDate", - header: header("답변요청일"), - cell: ({ row }) => ( - <span className="text-sm">{formatDate(row.getValue("requestDate"), "KR")}</span> - ), - size: 100, - }, - { - accessorKey: "hasAttachment", - header: header("첨부"), - cell: ({ row }) => - row.getValue<boolean>("hasAttachment") ? ( - <Paperclip className="h-4 w-4 text-muted-foreground" /> - ) : ( - <span className="text-muted-foreground">-</span> - ), - size: 60, - enableSorting: false, - }, -]; - -// ──────────────────────────────────────────────────────────────────────────── -// 액션 컬럼 -// ──────────────────────────────────────────────────────────────────────────── -const createActionsColumn = ( - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null> - > -): ColumnDef<LegalWorksDetailView> => ({ - id: "actions", - enableHiding: false, - size: 40, - minSize: 40, - cell: ({ row }) => ( - <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" /> - </Button> - </DropdownMenuTrigger> - - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem onSelect={() => setRowAction({ row, type: "view" })}> - 상세보기 - </DropdownMenuItem> - {row.original.status === "신규등록" && ( - <> - <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}> - 편집 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}> - 삭제하기 - </DropdownMenuItem> - </> - )} - </DropdownMenuContent> - </DropdownMenu> - ), -}); - -// ──────────────────────────────────────────────────────────────────────────── -// 메인 함수 -// ──────────────────────────────────────────────────────────────────────────── -export function getLegalWorksColumns({ - setRowAction, -}: GetColumnsProps): ColumnDef<LegalWorksDetailView>[] { - return [...BASE_COLUMNS, createActionsColumn(setRowAction)]; -} diff --git a/lib/legal-review/status/legal-works-toolbar-actions.tsx b/lib/legal-review/status/legal-works-toolbar-actions.tsx deleted file mode 100644 index 82fbc80a..00000000 --- a/lib/legal-review/status/legal-works-toolbar-actions.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { - Plus, - Send, - Download, - RefreshCw, - FileText, - MessageSquare -} from "lucide-react" -import { toast } from "sonner" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { CreateLegalWorkDialog } from "./create-legal-work-dialog" -import { RequestReviewDialog } from "./request-review-dialog" -import { exportTableToExcel } from "@/lib/export" -import { getLegalWorks } from "../service" -import { LegalWorksDetailView } from "@/db/schema" -import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog" - -type LegalWorkData = LegalWorksDetailView - -interface LegalWorksTableToolbarActionsProps { - table: Table<LegalWorkData> - onRefresh?: () => void -} - -export function LegalWorksTableToolbarActions({ - table, - onRefresh -}: LegalWorksTableToolbarActionsProps) { - const [isLoading, setIsLoading] = React.useState(false) - const [createDialogOpen, setCreateDialogOpen] = React.useState(false) - const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) - const router = useRouter() - const { data: session } = useSession() - - // 사용자 ID 가져오기 - const userId = React.useMemo(() => { - return session?.user?.id ? Number(session.user.id) : 1 - }, [session]) - - // 선택된 행들 - 단일 선택만 허용 - const selectedRows = table.getFilteredSelectedRowModel().rows - const hasSelection = selectedRows.length > 0 - const isSingleSelection = selectedRows.length === 1 - const isMultipleSelection = selectedRows.length > 1 - - // 선택된 단일 work - const selectedWork = isSingleSelection ? selectedRows[0].original : null - - // const canDeleateReview = selectedRows.filter(v=>v.status === '신규등록') - - - const deletableRows = React.useMemo(() => { - return selectedRows.filter(row => { - const status = row.original.status - return status ==="신규등록" - }) - }, [selectedRows]) - - const hasDeletableRows = deletableRows.length > 0 - - // 선택된 work의 상태 확인 - const canRequestReview = selectedWork?.status === "신규등록" - const canAssign = selectedWork?.status === "신규등록" - - // ---------------------------------------------------------------- - // 신규 생성 - // ---------------------------------------------------------------- - const handleCreateNew = React.useCallback(() => { - setCreateDialogOpen(true) - }, []) - - // ---------------------------------------------------------------- - // 검토 요청 (단일 선택만) - // ---------------------------------------------------------------- - const handleRequestReview = React.useCallback(() => { - if (!isSingleSelection) { - toast.error("검토요청은 한 건씩만 가능합니다. 하나의 항목만 선택해주세요.") - return - } - - if (!canRequestReview) { - toast.error("신규등록 상태인 항목만 검토요청이 가능합니다.") - return - } - - setReviewDialogOpen(true) - }, [isSingleSelection, canRequestReview]) - - // ---------------------------------------------------------------- - // 다이얼로그 성공 핸들러 - // ---------------------------------------------------------------- - const handleActionSuccess = React.useCallback(() => { - table.resetRowSelection() - onRefresh?.() - router.refresh() - }, [table, onRefresh, router]) - - // ---------------------------------------------------------------- - // 내보내기 핸들러 - // ---------------------------------------------------------------- - const handleExport = React.useCallback(() => { - exportTableToExcel(table, { - filename: "legal-works-list", - excludeColumns: ["select", "actions"], - }) - }, [table]) - - // ---------------------------------------------------------------- - // 새로고침 핸들러 - // ---------------------------------------------------------------- - const handleRefresh = React.useCallback(async () => { - setIsLoading(true) - try { - onRefresh?.() - toast.success("데이터를 새로고침했습니다.") - } catch (error) { - console.error("새로고침 오류:", error) - toast.error("새로고침 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - }, [onRefresh]) - - return ( - <> - <div className="flex items-center gap-2"> - - {hasDeletableRows&&( - <DeleteLegalWorksDialog - legalWorks={deletableRows.map(row => row.original)} - showTrigger={hasDeletableRows} - onSuccess={() => { - table.toggleAllRowsSelected(false) - // onRefresh?.() - }} - /> - )} - {/* 신규 생성 버튼 */} - <Button - variant="default" - size="sm" - className="gap-2" - onClick={handleCreateNew} - disabled={isLoading} - > - <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">신규 등록</span> - </Button> - - {/* 유틸리티 버튼들 */} - <div className="flex items-center gap-1 border-l pl-2 ml-2"> - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - disabled={isLoading} - className="gap-2" - > - <RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" /> - <span className="hidden sm:inline">새로고침</span> - </Button> - - <Button - variant="outline" - size="sm" - onClick={handleExport} - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">내보내기</span> - </Button> - </div> - - {/* 선택된 항목 액션 버튼들 */} - {hasSelection && ( - <div className="flex items-center gap-1 border-l pl-2 ml-2"> - {/* 다중 선택 경고 메시지 */} - {isMultipleSelection && ( - <div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded border border-amber-200"> - 검토요청은 한 건씩만 가능합니다 - </div> - )} - - {/* 검토 요청 버튼 (단일 선택시만) */} - {isSingleSelection && ( - <Button - variant="default" - size="sm" - className="gap-2 bg-blue-600 hover:bg-blue-700" - onClick={handleRequestReview} - disabled={isLoading || !canRequestReview} - > - <Send className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - {canRequestReview ? "검토요청" : "검토불가"} - </span> - </Button> - )} - - {/* 추가 액션 드롭다운 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={isLoading} - > - <MessageSquare className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">추가 작업</span> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => toast.info("담당자 배정 기능을 준비 중입니다.")} - disabled={!isSingleSelection || !canAssign} - > - <FileText className="size-4 mr-2" /> - 담당자 배정 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onClick={() => toast.info("상태 변경 기능을 준비 중입니다.")} - disabled={!isSingleSelection} - > - <RefreshCw className="size-4 mr-2" /> - 상태 변경 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - )} - - {/* 선택된 항목 정보 표시 */} - {hasSelection && ( - <div className="flex items-center gap-1 border-l pl-2 ml-2"> - <div className="text-xs text-muted-foreground"> - {isSingleSelection ? ( - <> - 선택: #{selectedWork?.id} ({selectedWork?.category}) - {selectedWork?.vendorName && ` | ${selectedWork.vendorName}`} - {selectedWork?.status && ` | ${selectedWork.status}`} - </> - ) : ( - `선택: ${selectedRows.length}건 (개별 처리 필요)` - )} - </div> - </div> - )} - </div> - - {/* 다이얼로그들 */} - {/* 신규 생성 다이얼로그 */} - <CreateLegalWorkDialog - open={createDialogOpen} - onOpenChange={setCreateDialogOpen} - onSuccess={handleActionSuccess} - onDataChange={onRefresh} - /> - - {/* 검토 요청 다이얼로그 - 단일 work 전달 */} - {selectedWork && ( - <RequestReviewDialog - open={reviewDialogOpen} - onOpenChange={setReviewDialogOpen} - work={selectedWork} // 단일 객체로 변경 - onSuccess={handleActionSuccess} - /> - )} - </> - ) -}
\ No newline at end of file diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx deleted file mode 100644 index d99fc0e3..00000000 --- a/lib/legal-review/status/request-review-dialog.tsx +++ /dev/null @@ -1,983 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { Loader2, Send, FileText, Clock, Upload, X, Building, User, Calendar } from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Switch } from "@/components/ui/switch" -import TiptapEditor from "@/components/qna/tiptap-editor" -import { canRequestReview, requestReview } from "../service" -import { LegalWorksDetailView } from "@/db/schema" - -type LegalWorkData = LegalWorksDetailView - -interface RequestReviewDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - work: LegalWorkData | null - onSuccess?: () => void -} - -// 검토요청 폼 스키마 -const requestReviewSchema = z.object({ - // 기본 검토 설정 - dueDate: z.string().min(1, "검토 완료 희망일을 선택해주세요"), - assignee: z.string().optional(), - notificationMethod: z.enum(["email", "internal", "both"]).default("both"), - - // 법무업무 상세 정보 - reviewDepartment: z.enum(["준법문의", "법무검토"]), - inquiryType: z.enum(["국내계약", "국내자문", "해외계약", "해외자문"]).optional(), - - // 공통 필드 - title: z.string().min(1, "제목을 선택해주세요"), - requestContent: z.string().min(1, "요청내용을 입력해주세요"), - - // 준법문의 전용 필드 - isPublic: z.boolean().default(false), - - // 법무검토 전용 필드들 - contractProjectName: z.string().optional(), - contractType: z.string().optional(), - contractCounterparty: z.string().optional(), - counterpartyType: z.enum(["법인", "개인"]).optional(), - contractPeriod: z.string().optional(), - contractAmount: z.string().optional(), - factualRelation: z.string().optional(), - projectNumber: z.string().optional(), - shipownerOrderer: z.string().optional(), - projectType: z.string().optional(), - governingLaw: z.string().optional(), -}).refine((data) => { - // 법무검토 선택시 문의종류 필수 - if (data.reviewDepartment === "법무검토" && !data.inquiryType) { - return false; - } - return true; -}, { - message: "법무검토 선택시 문의종류를 선택해주세요", - path: ["inquiryType"] -}); - -type RequestReviewFormValues = z.infer<typeof requestReviewSchema> - -export function RequestReviewDialog({ - open, - onOpenChange, - work, - onSuccess -}: RequestReviewDialogProps) { - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [attachments, setAttachments] = React.useState<File[]>([]) - const [editorContent, setEditorContent] = React.useState("") - const [canRequest, setCanRequest] = React.useState(true) - const [requestCheckMessage, setRequestCheckMessage] = React.useState("") - const [isCustomTitle, setIsCustomTitle] = React.useState(false) - - // work의 category에 따라 기본 reviewDepartment 결정 - const getDefaultReviewDepartment = () => { - return work?.category === "CP" ? "준법문의" : "법무검토" - } - - const form = useForm<RequestReviewFormValues>({ - resolver: zodResolver(requestReviewSchema), - defaultValues: { - dueDate: "", - assignee: "", - notificationMethod: "both", - reviewDepartment: getDefaultReviewDepartment(), - title: getDefaultReviewDepartment() === "준법문의" ? "CP검토" : "GTC검토", - requestContent: "", - isPublic: false, - }, - }) - - // work 변경시 검토요청 가능 여부 확인 - React.useEffect(() => { - if (work && open) { - canRequestReview(work.id).then((result) => { - setCanRequest(result.canRequest) - setRequestCheckMessage(result.reason || "") - }) - - const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토" - form.setValue("reviewDepartment", defaultDepartment) - } - }, [work, open, form]) - - // 검토부문 감시 - const reviewDepartment = form.watch("reviewDepartment") - const inquiryType = form.watch("inquiryType") - const titleValue = form.watch("title") - - // 조건부 필드 활성화 로직 - const isContractTypeActive = inquiryType && ["국내계약", "해외계약", "해외자문"].includes(inquiryType) - const isDomesticContractFieldsActive = inquiryType === "국내계약" - const isFactualRelationActive = inquiryType && ["국내자문", "해외자문"].includes(inquiryType) - const isOverseasFieldsActive = inquiryType && ["해외계약", "해외자문"].includes(inquiryType) - - // 제목 "기타" 선택 여부 확인 - // const isTitleOther = titleValue === "기타" - - // 검토부문 변경시 관련 필드 초기화 - React.useEffect(() => { - if (reviewDepartment === "준법문의") { - setIsCustomTitle(false) - form.setValue("inquiryType", undefined) - // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) - const currentTitle = form.getValues("title") - if (!currentTitle || currentTitle === "GTC검토") { - form.setValue("title", "CP검토") - } - // 법무검토 전용 필드들 초기화 - form.setValue("contractProjectName", "") - form.setValue("contractType", "") - form.setValue("contractCounterparty", "") - form.setValue("counterpartyType", undefined) - form.setValue("contractPeriod", "") - form.setValue("contractAmount", "") - form.setValue("factualRelation", "") - form.setValue("projectNumber", "") - form.setValue("shipownerOrderer", "") - form.setValue("projectType", "") - form.setValue("governingLaw", "") - } else { - setIsCustomTitle(false) - // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) - const currentTitle = form.getValues("title") - if (!currentTitle || currentTitle === "CP검토") { - form.setValue("title", "GTC검토") - } - form.setValue("isPublic", false) - } - }, [reviewDepartment, form]) - - // 문의종류 변경시 관련 필드 초기화 - React.useEffect(() => { - if (inquiryType) { - // 계약서 종류 초기화 (옵션이 달라지므로) - form.setValue("contractType", "") - - // 조건에 맞지 않는 필드들 초기화 - if (!isDomesticContractFieldsActive) { - form.setValue("contractCounterparty", "") - form.setValue("counterpartyType", undefined) - form.setValue("contractPeriod", "") - form.setValue("contractAmount", "") - } - - if (!isFactualRelationActive) { - form.setValue("factualRelation", "") - } - - if (!isOverseasFieldsActive) { - form.setValue("projectNumber", "") - form.setValue("shipownerOrderer", "") - form.setValue("projectType", "") - form.setValue("governingLaw", "") - } - } - }, [inquiryType, isDomesticContractFieldsActive, isFactualRelationActive, isOverseasFieldsActive, form]) - - // 에디터 내용이 변경될 때 폼에 반영 - React.useEffect(() => { - form.setValue("requestContent", editorContent) - }, [editorContent, form]) - - // 첨부파일 처리 - const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { - const files = Array.from(event.target.files || []) - setAttachments(prev => [...prev, ...files]) - } - - const removeAttachment = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)) - } - - // 폼 제출 - async function onSubmit(data: RequestReviewFormValues) { - if (!work) return - - console.log("Request review data:", data) - console.log("Work to review:", work) - console.log("Attachments:", attachments) - setIsSubmitting(true) - - try { - const result = await requestReview(work.id, data, attachments) - - if (result.success) { - toast.success(result.data?.message || `법무업무 #${work.id}에 대한 검토요청이 완료되었습니다.`) - onOpenChange(false) - handleReset() - onSuccess?.() - } else { - toast.error(result.error || "검토요청 중 오류가 발생했습니다.") - } - } catch (error) { - console.error("Error requesting review:", error) - toast.error("검토요청 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - // 폼 리셋 함수 - const handleReset = () => { - const defaultDepartment = getDefaultReviewDepartment() - setIsCustomTitle(false) // 추가 - - form.reset({ - dueDate: "", - assignee: "", - notificationMethod: "both", - reviewDepartment: defaultDepartment, - title: defaultDepartment === "준법문의" ? "CP검토" : "GTC검토", - requestContent: "", - isPublic: false, - }) - setAttachments([]) - setEditorContent("") - } - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (open: boolean) => { - onOpenChange(open) - if (!open) { - handleReset() - } - } - - // 제목 옵션 (검토부문에 따라 다름) - const getTitleOptions = () => { - if (reviewDepartment === "준법문의") { - return [ - { value: "CP검토", label: "CP검토" }, - { value: "기타", label: "기타 (직접입력)" } - ] - } else { - return [ - { value: "GTC검토", label: "GTC검토" }, - { value: "기타", label: "기타 (직접입력)" } - ] - } - } - - // 계약서 종류 옵션 (문의종류에 따라 다름) - const getContractTypeOptions = () => { - if (inquiryType === "국내계약") { - return [ - { value: "공사도급계약", label: "공사도급계약" }, - { value: "제작납품계약", label: "제작납품계약" }, - { value: "자재매매계약", label: "자재매매계약" }, - { value: "용역위탁계약", label: "용역위탁계약" }, - { value: "기술사용 및 개발계약", label: "기술사용 및 개발계약" }, - { value: "운송 및 자재관리 계약", label: "운송 및 자재관리 계약" }, - { value: "자문 등 위임계약", label: "자문 등 위임계약" }, - { value: "양해각서", label: "양해각서" }, - { value: "양수도 계약", label: "양수도 계약" }, - { value: "합의서", label: "합의서" }, - { value: "공동도급(운영)협약서", label: "공동도급(운영)협약서" }, - { value: "협정서", label: "협정서" }, - { value: "약정서", label: "약정서" }, - { value: "협의서", label: "협의서" }, - { value: "기타", label: "기타" }, - { value: "비밀유지계약서", label: "비밀유지계약서" }, - { value: "분양계약서", label: "분양계약서" }, - ] - } else { - // 해외계약/해외자문 - return [ - { value: "Shipbuilding Contract", label: "Shipbuilding Contract" }, - { value: "Offshore Contract (EPCI, FEED)", label: "Offshore Contract (EPCI, FEED)" }, - { value: "Supplementary / Addendum", label: "Supplementary / Addendum" }, - { value: "Subcontract / GTC / PTC / PO", label: "Subcontract / GTC / PTC / PO" }, - { value: "Novation / Assignment", label: "Novation / Assignment" }, - { value: "NDA (Confidential, Secrecy)", label: "NDA (Confidential, Secrecy)" }, - { value: "Warranty", label: "Warranty" }, - { value: "Waiver and Release", label: "Waiver and Release" }, - { value: "Bond (PG, RG, Advanced Payment)", label: "Bond (PG, RG, Advanced Payment)" }, - { value: "MOU / LOI / LOA", label: "MOU / LOI / LOA" }, - { value: "Power of Attorney (POA)", label: "Power of Attorney (POA)" }, - { value: "Commission Agreement", label: "Commission Agreement" }, - { value: "Consortium Agreement", label: "Consortium Agreement" }, - { value: "JV / JDP Agreement", label: "JV / JDP Agreement" }, - { value: "Engineering Service Contract", label: "Engineering Service Contract" }, - { value: "Consultancy Service Agreement", label: "Consultancy Service Agreement" }, - { value: "Purchase / Lease Agreement", label: "Purchase / Lease Agreement" }, - { value: "Financial / Loan / Covenant", label: "Financial / Loan / Covenant" }, - { value: "Other Contract / Agreement", label: "Other Contract / Agreement" }, - ] - } - } - - // 프로젝트 종류 옵션 - const getProjectTypeOptions = () => { - return [ - { value: "BARGE VESSEL", label: "BARGE VESSEL" }, - { value: "BULK CARRIER", label: "BULK CARRIER" }, - { value: "CHEMICAL CARRIER", label: "CHEMICAL CARRIER" }, - { value: "FULL CONTAINER", label: "FULL CONTAINER" }, - { value: "CRUDE OIL TANKER", label: "CRUDE OIL TANKER" }, - { value: "CRUISE SHIP", label: "CRUISE SHIP" }, - { value: "DRILL SHIP", label: "DRILL SHIP" }, - { value: "FIELD DEVELOPMENT SHIP", label: "FIELD DEVELOPMENT SHIP" }, - { value: "FLOATING PRODUCTION STORAGE OFFLOADING", label: "FLOATING PRODUCTION STORAGE OFFLOADING" }, - { value: "CAR-FERRY & PASSENGER VESSEL", label: "CAR-FERRY & PASSENGER VESSEL" }, - { value: "FLOATING STORAGE OFFLOADING", label: "FLOATING STORAGE OFFLOADING" }, - { value: "HEAVY DECK CARGO", label: "HEAVY DECK CARGO" }, - { value: "PRODUCT OIL TANKER", label: "PRODUCT OIL TANKER" }, - { value: "HIGH SPEED LINER", label: "HIGH SPEED LINER" }, - { value: "JACK-UP", label: "JACK-UP" }, - { value: "LIQUEFIED NATURAL GAS CARRIER", label: "LIQUEFIED NATURAL GAS CARRIER" }, - { value: "LIQUEFIED PETROLEUM GAS CARRIER", label: "LIQUEFIED PETROLEUM GAS CARRIER" }, - { value: "MULTIPURPOSE CARGO CARRIER", label: "MULTIPURPOSE CARGO CARRIER" }, - { value: "ORE-BULK-OIL CARRIER", label: "ORE-BULK-OIL CARRIER" }, - { value: "OIL TANKER", label: "OIL TANKER" }, - { value: "OTHER VESSEL", label: "OTHER VESSEL" }, - { value: "PURE CAR CARRIER", label: "PURE CAR CARRIER" }, - { value: "PRODUCT CARRIER", label: "PRODUCT CARRIER" }, - { value: "PLATFORM", label: "PLATFORM" }, - { value: "PUSHER", label: "PUSHER" }, - { value: "REEFER TRANSPORT VESSEL", label: "REEFER TRANSPORT VESSEL" }, - { value: "ROLL-ON ROLL-OFF VESSEL", label: "ROLL-ON ROLL-OFF VESSEL" }, - { value: "SEMI RIG", label: "SEMI RIG" }, - { value: "SUPPLY ANCHOR HANDLING VESSEL", label: "SUPPLY ANCHOR HANDLING VESSEL" }, - { value: "SHUTTLE TANKER", label: "SHUTTLE TANKER" }, - { value: "SUPPLY VESSEL", label: "SUPPLY VESSEL" }, - { value: "TOPSIDE", label: "TOPSIDE" }, - { value: "TUG SUPPLY VESSEL", label: "TUG SUPPLY VESSEL" }, - { value: "VERY LARGE CRUDE OIL CARRIER", label: "VERY LARGE CRUDE OIL CARRIER" }, - { value: "WELL INTERVENTION SHIP", label: "WELL INTERVENTION SHIP" }, - { value: "WIND TURBINE INSTALLATION VESSEL", label: "WIND TURBINE INSTALLATION VESSEL" }, - { value: "기타", label: "기타" }, - ] - } - - if (!work) { - return null - } - - // 검토요청 불가능한 경우 안내 메시지 - if (!canRequest) { - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2 text-amber-600"> - <FileText className="h-5 w-5" /> - 검토요청 불가 - </DialogTitle> - <DialogDescription className="pt-4"> - {requestCheckMessage} - </DialogDescription> - </DialogHeader> - <div className="flex justify-end pt-4"> - <Button onClick={() => onOpenChange(false)}>확인</Button> - </div> - </DialogContent> - </Dialog> - ) - } - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col"> - {/* 고정 헤더 */} - <div className="flex-shrink-0 p-6 border-b"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Send className="h-5 w-5" /> - 검토요청 발송 - </DialogTitle> - <DialogDescription> - 법무업무 #{work.id}에 대한 상세한 검토를 요청합니다. - </DialogDescription> - </DialogHeader> - </div> - - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col flex-1 min-h-0" - > - {/* 스크롤 가능한 콘텐츠 영역 */} - <div className="flex-1 overflow-y-auto p-6"> - <div className="space-y-6"> - {/* 선택된 업무 정보 */} - <Card className="bg-blue-50 border-blue-200"> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - <FileText className="h-5 w-5" /> - 검토 대상 업무 - </CardTitle> - </CardHeader> - <CardContent> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <span className="font-medium">업무 ID:</span> - <Badge variant="outline">#{work.id}</Badge> - </div> - <div className="flex items-center gap-2"> - <span className="font-medium">구분:</span> - <Badge variant={work.category === "CP" ? "default" : "secondary"}> - {work.category} - </Badge> - {work.isUrgent && ( - <Badge variant="destructive" className="text-xs"> - 긴급 - </Badge> - )} - </div> - <div className="flex items-center gap-2"> - <Building className="h-4 w-4" /> - <span className="font-medium">벤더:</span> - <span>{work.vendorCode} - {work.vendorName}</span> - </div> - </div> - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <User className="h-4 w-4" /> - <span className="font-medium">요청자:</span> - <span>{work.reviewer || "미지정"}</span> - </div> - <div className="flex items-center gap-2"> - <Calendar className="h-4 w-4" /> - <span className="font-medium">답변요청일:</span> - <span>{work.requestDate || "미설정"}</span> - </div> - <div className="flex items-center gap-2"> - <span className="font-medium">상태:</span> - <Badge variant="outline">{work.status}</Badge> - </div> - </div> - </div> - </CardContent> - </Card> - - {/* 기본 설정 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">기본 설정</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {/* 검토 완료 희망일 */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center gap-2"> - <Clock className="h-4 w-4" /> - 검토 완료 희망일 - </FormLabel> - <FormControl> - <Input type="date" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - - {/* 법무업무 상세 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">법무업무 상세 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {/* 검토부문 */} - <FormField - control={form.control} - name="reviewDepartment" - render={({ field }) => ( - <FormItem> - <FormLabel>검토부문</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="검토부문 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="준법문의">준법문의</SelectItem> - <SelectItem value="법무검토">법무검토</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 문의종류 (법무검토 선택시만) */} - {reviewDepartment === "법무검토" && ( - <FormField - control={form.control} - name="inquiryType" - render={({ field }) => ( - <FormItem> - <FormLabel>문의종류</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="문의종류 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="국내계약">국내계약</SelectItem> - <SelectItem value="국내자문">국내자문</SelectItem> - <SelectItem value="해외계약">해외계약</SelectItem> - <SelectItem value="해외자문">해외자문</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* 제목 - 조건부 렌더링 */} - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel>제목</FormLabel> - {!isCustomTitle ? ( - // Select 모드 - <Select - onValueChange={(value) => { - if (value === "기타") { - setIsCustomTitle(true) - field.onChange("") // 빈 값으로 초기화 - } else { - field.onChange(value) - } - }} - value={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="제목 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {getTitleOptions().map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - ) : ( - // Input 모드 (기타 선택시) - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <Badge variant="outline" className="text-xs">기타</Badge> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => { - const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토" - form.setValue("title", defaultTitle) - setIsCustomTitle(false) // 상태 초기화 - }} - className="h-6 text-xs" - > - 선택 모드로 돌아가기 - </Button> - </div> - <FormControl> - <Input - placeholder="제목을 직접 입력하세요" - value={field.value} - onChange={(e) => field.onChange(e.target.value)} - autoFocus - /> - </FormControl> - </div> - )} - <FormMessage /> - </FormItem> - )} -/> - - {/* 준법문의 전용 필드들 */} - {reviewDepartment === "준법문의" && ( - <FormField - control={form.control} - name="isPublic" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> - <div className="space-y-0.5"> - <FormLabel className="text-base">공개여부</FormLabel> - <div className="text-sm text-muted-foreground"> - 준법문의 공개 설정 - </div> - </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - )} - - {/* 법무검토 전용 필드들 */} - {reviewDepartment === "법무검토" && ( - <div className="space-y-4"> - {/* 계약명/프로젝트명 */} - <FormField - control={form.control} - name="contractProjectName" - render={({ field }) => ( - <FormItem> - <FormLabel>계약명/프로젝트명</FormLabel> - <FormControl> - <Input placeholder="계약명 또는 프로젝트명 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 계약서 종류 - 조건부 활성화 */} - {isContractTypeActive && ( - <FormField - control={form.control} - name="contractType" - render={({ field }) => ( - <FormItem> - <FormLabel>계약서 종류</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="계약서 종류 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {getContractTypeOptions().map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* 국내계약 전용 필드들 */} - {isDomesticContractFieldsActive && ( - <div className="grid grid-cols-2 gap-4"> - {/* 계약상대방 */} - <FormField - control={form.control} - name="contractCounterparty" - render={({ field }) => ( - <FormItem> - <FormLabel>계약상대방</FormLabel> - <FormControl> - <Input placeholder="계약상대방 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 계약상대방 구분 */} - <FormField - control={form.control} - name="counterpartyType" - render={({ field }) => ( - <FormItem> - <FormLabel>계약상대방 구분</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="구분 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="법인">법인</SelectItem> - <SelectItem value="개인">개인</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 계약기간 */} - <FormField - control={form.control} - name="contractPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>계약기간</FormLabel> - <FormControl> - <Input placeholder="계약기간 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 계약금액 */} - <FormField - control={form.control} - name="contractAmount" - render={({ field }) => ( - <FormItem> - <FormLabel>계약금액</FormLabel> - <FormControl> - <Input placeholder="계약금액 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - )} - - {/* 사실관계 - 조건부 활성화 */} - {isFactualRelationActive && ( - <FormField - control={form.control} - name="factualRelation" - render={({ field }) => ( - <FormItem> - <FormLabel>사실관계</FormLabel> - <FormControl> - <Textarea - placeholder="사실관계를 상세히 입력해주세요" - className="min-h-[80px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* 해외 관련 필드들 - 조건부 활성화 */} - {isOverseasFieldsActive && ( - <div className="grid grid-cols-2 gap-4"> - {/* 프로젝트번호 */} - <FormField - control={form.control} - name="projectNumber" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트번호</FormLabel> - <FormControl> - <Input placeholder="프로젝트번호 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 선주/발주처 */} - <FormField - control={form.control} - name="shipownerOrderer" - render={({ field }) => ( - <FormItem> - <FormLabel>선주/발주처</FormLabel> - <FormControl> - <Input placeholder="선주/발주처 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 프로젝트종류 */} - <FormField - control={form.control} - name="projectType" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트종류</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="프로젝트종류 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {getProjectTypeOptions().map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 준거법 */} - <FormField - control={form.control} - name="governingLaw" - render={({ field }) => ( - <FormItem> - <FormLabel>준거법</FormLabel> - <FormControl> - <Input placeholder="준거법 입력" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - )} - </div> - )} - - {/* 요청내용 - TiptapEditor로 교체 */} - <FormField - control={form.control} - name="requestContent" - render={({ field }) => ( - <FormItem> - <FormLabel>요청내용</FormLabel> - <FormControl> - <div className="min-h-[250px]"> - <TiptapEditor - content={editorContent} - setContent={setEditorContent} - disabled={isSubmitting} - height="250px" - /> - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 첨부파일 */} - <div className="space-y-2"> - <FormLabel>첨부파일</FormLabel> - <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-4"> - <input - type="file" - multiple - onChange={handleFileChange} - className="hidden" - id="file-upload" - /> - <label - htmlFor="file-upload" - className="flex flex-col items-center justify-center cursor-pointer" - > - <Upload className="h-8 w-8 text-muted-foreground mb-2" /> - <span className="text-sm text-muted-foreground"> - 파일을 선택하거나 여기로 드래그하세요 - </span> - </label> - </div> - - {/* 선택된 파일 목록 */} - {attachments.length > 0 && ( - <div className="space-y-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center justify-between bg-muted/50 p-2 rounded"> - <span className="text-sm truncate">{file.name}</span> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeAttachment(index)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - )} - </div> - </CardContent> - </Card> - </div> - </div> - - {/* 고정 버튼 영역 */} - <div className="flex-shrink-0 border-t p-6"> - <div className="flex justify-end gap-3"> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - disabled={isSubmitting} - className="bg-blue-600 hover:bg-blue-700" - > - {isSubmitting ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 발송 중... - </> - ) : ( - <> - <Send className="mr-2 h-4 w-4" /> - 검토요청 발송 - </> - )} - </Button> - </div> - </div> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/legal-review/status/update-legal-work-dialog.tsx b/lib/legal-review/status/update-legal-work-dialog.tsx deleted file mode 100644 index d9157d3c..00000000 --- a/lib/legal-review/status/update-legal-work-dialog.tsx +++ /dev/null @@ -1,385 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { Loader2, Check, ChevronsUpDown, Edit } from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Switch } from "@/components/ui/switch" -import { ScrollArea } from "@/components/ui/scroll-area" -import { cn } from "@/lib/utils" -import { getVendorsForSelection } from "@/lib/b-rfq/service" -import { LegalWorksDetailView } from "@/db/schema" -// import { updateLegalWork } from "../service" - -type LegalWorkData = LegalWorksDetailView - -interface EditLegalWorkSheetProps { - open: boolean - onOpenChange: (open: boolean) => void - work: LegalWorkData | null - onSuccess?: () => void - onDataChange?: () => void -} - -// 편집용 폼 스키마 (신규등록 상태에서만 기본 정보만 편집) -const editLegalWorkSchema = z.object({ - category: z.enum(["CP", "GTC", "기타"]), - vendorId: z.number().min(1, "벤더를 선택해주세요"), - isUrgent: z.boolean().default(false), - requestDate: z.string().min(1, "답변요청일을 선택해주세요"), -}) - -type EditLegalWorkFormValues = z.infer<typeof editLegalWorkSchema> - -interface Vendor { - id: number - vendorName: string - vendorCode: string - country: string - taxId: string - status: string -} - -export function EditLegalWorkSheet({ - open, - onOpenChange, - work, - onSuccess, - onDataChange -}: EditLegalWorkSheetProps) { - const router = useRouter() - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [vendorsLoading, setVendorsLoading] = React.useState(false) - const [vendorOpen, setVendorOpen] = React.useState(false) - - const loadVendors = React.useCallback(async () => { - setVendorsLoading(true) - try { - const vendorList = await getVendorsForSelection() - setVendors(vendorList) - } catch (error) { - console.error("Failed to load vendors:", error) - toast.error("벤더 목록을 불러오는데 실패했습니다.") - } finally { - setVendorsLoading(false) - } - }, []) - - const form = useForm<EditLegalWorkFormValues>({ - resolver: zodResolver(editLegalWorkSchema), - defaultValues: { - category: "CP", - vendorId: 0, - isUrgent: false, - requestDate: "", - }, - }) - - // work 데이터가 변경될 때 폼 값 업데이트 - React.useEffect(() => { - if (work && open) { - form.reset({ - category: work.category as "CP" | "GTC" | "기타", - vendorId: work.vendorId || 0, - isUrgent: work.isUrgent || false, - requestDate: work.requestDate ? new Date(work.requestDate).toISOString().split('T')[0] : "", - }) - } - }, [work, open, form]) - - React.useEffect(() => { - if (open) { - loadVendors() - } - }, [open, loadVendors]) - - // 폼 제출 - async function onSubmit(data: EditLegalWorkFormValues) { - if (!work) return - - console.log("Updating legal work with data:", data) - setIsSubmitting(true) - - try { - const result = await updateLegalWork(work.id, data) - - if (result.success) { - toast.success(result.data?.message || "법무업무가 성공적으로 수정되었습니다.") - onOpenChange(false) - onSuccess?.() - onDataChange?.() - router.refresh() - } else { - toast.error(result.error || "수정 중 오류가 발생했습니다.") - } - } catch (error) { - console.error("Error updating legal work:", error) - toast.error("수정 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - // 시트 닫기 핸들러 - const handleOpenChange = (openState: boolean) => { - onOpenChange(openState) - if (!openState) { - form.reset() - } - } - - // 선택된 벤더 정보 - const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) - - if (!work) { - return null - } - - return ( - <Sheet open={open} onOpenChange={handleOpenChange}> - <SheetContent className="w-[600px] sm:w-[800px] p-0 flex flex-col" style={{maxWidth:900}}> - {/* 고정 헤더 */} - <SheetHeader className="flex-shrink-0 p-6 border-b"> - <SheetTitle className="flex items-center gap-2"> - <Edit className="h-5 w-5" /> - 법무업무 편집 - </SheetTitle> - <SheetDescription> - 법무업무 #{work.id}의 기본 정보를 수정합니다. (신규등록 상태에서만 편집 가능) - </SheetDescription> - </SheetHeader> - - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col flex-1 min-h-0" - > - {/* 스크롤 가능한 콘텐츠 영역 */} - <ScrollArea className="flex-1 p-6"> - <div className="space-y-6"> - {/* 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">기본 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {/* 구분 */} - <FormField - control={form.control} - name="category" - render={({ field }) => ( - <FormItem> - <FormLabel>구분</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="구분 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="CP">CP</SelectItem> - <SelectItem value="GTC">GTC</SelectItem> - <SelectItem value="기타">기타</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 긴급여부 */} - <FormField - control={form.control} - name="isUrgent" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> - <div className="space-y-0.5"> - <FormLabel className="text-base">긴급 요청</FormLabel> - <div className="text-sm text-muted-foreground"> - 긴급 처리가 필요한 경우 체크 - </div> - </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - - {/* 벤더 선택 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem> - <FormLabel>벤더</FormLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className="w-full justify-between" - > - {selectedVendor ? ( - <span className="flex items-center gap-2"> - <Badge variant="outline">{selectedVendor.vendorCode}</Badge> - {selectedVendor.vendorName} - </span> - ) : ( - "벤더 선택..." - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-full p-0" align="start"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandList> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorCode} ${vendor.vendorName}`} - onSelect={() => { - field.onChange(vendor.id) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - vendor.id === field.value ? "opacity-100" : "opacity-0" - )} - /> - <div className="flex items-center gap-2"> - <Badge variant="outline">{vendor.vendorCode}</Badge> - <span>{vendor.vendorName}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 답변요청일 */} - <FormField - control={form.control} - name="requestDate" - render={({ field }) => ( - <FormItem> - <FormLabel>답변요청일</FormLabel> - <FormControl> - <Input type="date" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - - {/* 안내 메시지 */} - <Card className="bg-blue-50 border-blue-200"> - <CardContent className="pt-6"> - <div className="flex items-start gap-3"> - <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div> - <div className="space-y-1"> - <p className="text-sm font-medium text-blue-900"> - 편집 제한 안내 - </p> - <p className="text-sm text-blue-700"> - 기본 정보는 '신규등록' 상태에서만 편집할 수 있습니다. 검토요청이 발송된 후에는 담당자를 통해 변경해야 합니다. - </p> - </div> - </div> - </CardContent> - </Card> - </div> - </ScrollArea> - - {/* 고정 버튼 영역 */} - <SheetFooter className="flex-shrink-0 border-t bg-background p-6"> - <div className="flex justify-end gap-3 w-full"> - <SheetClose asChild> - <Button - type="button" - variant="outline" - disabled={isSubmitting} - > - 취소 - </Button> - </SheetClose> - <Button - type="submit" - disabled={isSubmitting} - > - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - 저장 - </Button> - </div> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/legal-review/validations.ts b/lib/legal-review/validations.ts deleted file mode 100644 index 4f41016e..00000000 --- a/lib/legal-review/validations.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, -} from "nuqs/server"; -import * as z from "zod"; -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; -import { legalWorksDetailView } from "@/db/schema"; - -export const SearchParamsCacheLegalWorks = createSearchParamsCache({ - // UI 모드나 플래그 관련 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 (createdAt 기준 내림차순) - sort: getSortingStateParser<typeof legalWorksDetailView>().withDefault([ - { id: "createdAt", desc: true }]), - - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - search: parseAsString.withDefault(""), -}); -export type GetLegalWorksSchema = Awaited<ReturnType<typeof SearchParamsCacheLegalWorks.parse>>; - -export const createLegalWorkSchema = z.object({ - category: z.enum(["CP", "GTC", "기타"]), - vendorId: z.number().min(1, "벤더를 선택해주세요"), - isUrgent: z.boolean().default(false), - requestDate: z.string().min(1, "답변요청일을 선택해주세요"), - expectedAnswerDate: z.string().optional(), - reviewer: z.string().min(1, "검토요청자를 입력해주세요"), - }); - -export type CreateLegalWorkData = z.infer<typeof createLegalWorkSchema>; -
\ No newline at end of file diff --git a/lib/procurement-rfqs/repository.ts b/lib/procurement-rfqs/repository.ts deleted file mode 100644 index eb48bc42..00000000 --- a/lib/procurement-rfqs/repository.ts +++ /dev/null @@ -1,50 +0,0 @@ -// src/lib/tasks/repository.ts -import db from "@/db/db"; -import { procurementRfqsView } from "@/db/schema"; -import { - eq, - inArray, - not, - asc, - desc, - and, - ilike, - gte, - lte, - count, - gt, sql -} from "drizzle-orm"; -import { PgTransaction } from "drizzle-orm/pg-core"; - -/** - * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 - * - 트랜잭션(tx)을 받아서 사용하도록 구현 - */ -export async function selectPORfqs( - tx: PgTransaction<any, any, any>, - params: { - where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; - offset?: number; - limit?: number; - } -) { - const { where, orderBy, offset = 0, limit = 10 } = params; - - return tx - .select() - .from(procurementRfqsView) - .where(where ?? undefined) - .orderBy(...(orderBy ?? [])) - .offset(offset) - .limit(limit); -} -/** 총 개수 count */ -export async function countPORfqs( - tx: PgTransaction<any, any, any>, - where?: any -) { - const res = await tx.select({ count: count() }).from(procurementRfqsView).where(where); - return res[0]?.count ?? 0; -} - diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts deleted file mode 100644 index 9cca4c73..00000000 --- a/lib/procurement-rfqs/services.ts +++ /dev/null @@ -1,2050 +0,0 @@ -"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) - -import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; -import db from "@/db/db"; - -import { filterColumns } from "@/lib/filter-columns"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { getErrorMessage } from "@/lib/handle-error"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -import { GetPORfqsSchema, GetQuotationsSchema } from "./validations"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count, between } from "drizzle-orm"; -import { incoterms, paymentTerms, prItems, prItemsView, procurementAttachments, procurementQuotationItems, procurementRfqComments, procurementRfqDetails, procurementRfqDetailsView, procurementRfqs, procurementRfqsView, procurementVendorQuotations } from "@/db/schema/procurementRFQ"; -import { countPORfqs, selectPORfqs } from "./repository"; -import { writeFile, mkdir } from "fs/promises" -import { join } from "path" -import { v4 as uuidv4 } from "uuid" -import { items, projects, users, vendors } from "@/db/schema"; -import { formatISO } from "date-fns"; -import { sendEmail } from "../mail/sendEmail"; -import { formatDate } from "../utils"; - -async function getAuthenticatedUser() { - const session = await getServerSession(authOptions); - - if (!session || !session.user?.id) { - throw new Error("인증이 필요합니다"); - } - - return { - userId: session.user.id, - user: session.user - }; -} - - -export async function getPORfqs(input: GetPORfqsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 기본 필터 처리 - RFQFilterBox에서 오는 필터 - const basicFilters = input.basicFilters || []; - const basicJoinOperator = input.basicJoinOperator || "and"; - - // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 - const advancedFilters = input.filters || []; - const advancedJoinOperator = input.joinOperator || "and"; - - // 기본 필터 조건 생성 - let basicWhere; - if (basicFilters.length > 0) { - basicWhere = filterColumns({ - table: procurementRfqsView, - filters: basicFilters, - joinOperator: basicJoinOperator, - }); - } - - // 고급 필터 조건 생성 - let advancedWhere; - if (advancedFilters.length > 0) { - advancedWhere = filterColumns({ - table: procurementRfqsView, - filters: advancedFilters, - joinOperator: advancedJoinOperator, - }); - } - - // 전역 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(procurementRfqsView.rfqCode, s), - ilike(procurementRfqsView.projectCode, s), - ilike(procurementRfqsView.projectName, s), - ilike(procurementRfqsView.dueDate, s), - ilike(procurementRfqsView.status, s), - // 발주담당 검색 추가 - ilike(procurementRfqsView.picCode, s) - ); - } - - // 날짜 범위 필터링을 위한 특별 처리 (RFQFilterBox는 이미 basicFilters에 포함) - // 이 코드는 기존 처리와의 호환성을 위해 유지 - let dateRangeWhere; - if (input.filters) { - const rfqSendDateFilter = input.filters.find(f => f.id === "rfqSendDate" && Array.isArray(f.value)); - - if (rfqSendDateFilter && Array.isArray(rfqSendDateFilter.value)) { - const [fromDate, toDate] = rfqSendDateFilter.value; - - if (fromDate && toDate) { - // 시작일과 종료일이 모두 있는 경우 - dateRangeWhere = between( - procurementRfqsView.rfqSendDate, - new Date(fromDate), - new Date(toDate) - ); - } else if (fromDate) { - // 시작일만 있는 경우 - dateRangeWhere = sql`${procurementRfqsView.rfqSendDate} >= ${new Date(fromDate)}`; - } - } - } - - // 모든 조건 결합 - let whereConditions = []; - if (basicWhere) whereConditions.push(basicWhere); - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - if (dateRangeWhere) whereConditions.push(dateRangeWhere); - - // 조건이 있을 때만 and() 사용 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - - - // 정렬 조건 - 안전하게 처리 - const orderBy = - input.sort && input.sort.length > 0 - ? input.sort.map((item) => - item.desc - ? desc(procurementRfqsView[item.id]) - : asc(procurementRfqsView[item.id]) - ) - : [desc(procurementRfqsView.updatedAt)] - - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectPORfqs(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countPORfqs(tx, finalWhere); - return { data, total }; - }); - - console.log(total) - - console.log("쿼리 결과 데이터:", data.length); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount ,total }; - } catch (err) { - console.error("getRfqs 에러:", err); - - // 에러 세부 정보 더 자세히 로깅 - if (err instanceof Error) { - console.error("에러 메시지:", err.message); - console.error("에러 스택:", err.stack); - - if ('code' in err) { - console.error("SQL 에러 코드:", (err as any).code); - } - } - - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: [`rfqs-po`], - } - )(); -} - -// RFQ 디테일 데이터를 가져오는 함수 -export async function getRfqDetails(rfqId: number) { - return unstable_cache( - async () => { - try { - unstable_noStore(); - - // SQL 쿼리 직접 실행 - const data = await db - .select() - .from(procurementRfqDetailsView) - .where(eq(procurementRfqDetailsView.rfqId, rfqId)) - - console.log(`RFQ 디테일 SQL 조회 완료: ${rfqId}, ${data?.length}건`); - - return { data }; - } catch (err) { - console.error("RFQ 디테일 SQL 조회 오류:", err); - - if (err instanceof Error) { - console.error("에러 메시지:", err.message); - console.error("에러 스택:", err.stack); - } - - return { data: [] }; - } - }, - [`rfq-details-sql-${rfqId}`], - { - revalidate: 60, - tags: [`rfq-details-${rfqId}`], - } - )(); -} - -// RFQ ID로 디테일 데이터를 가져오는 서버 액션 -export async function fetchRfqDetails(rfqId: number) { - "use server"; - - try { - const result = await getRfqDetails(rfqId); - return result; - } catch (error) { - console.error("RFQ 디테일 서버 액션 오류:", error); - return { data: [] }; - } -} - -// RFQ ID로 PR 상세 항목들을 가져오는 함수 -export async function getPrItemsByRfqId(rfqId: number) { - return unstable_cache( - async () => { - try { - unstable_noStore(); - - - const data = await db - .select() - .from(prItemsView) - .where(eq(prItemsView.procurementRfqsId, rfqId)) - - - console.log(`PR 항목 조회 완료: ${rfqId}, ${data.length}건`); - - return { data }; - } catch (err) { - console.error("PR 항목 조회 오류:", err); - - if (err instanceof Error) { - console.error("에러 메시지:", err.message); - console.error("에러 스택:", err.stack); - } - - return { data: [] }; - } - }, - [`pr-items-${rfqId}`], - { - revalidate: 60, // 1분 캐시 - tags: [`pr-items-${rfqId}`], - } - )(); -} - -// 서버 액션으로 노출할 함수 -export async function fetchPrItemsByRfqId(rfqId: number) { - "use server"; - - try { - const result = await getPrItemsByRfqId(rfqId); - return result; - } catch (error) { - console.error("PR 항목 서버 액션 오류:", error); - return { data: [] }; - } -} - -export async function addVendorToRfq(formData: FormData) { - try { - // 현재 사용자 정보 가져오기 - const { userId, user } = await getAuthenticatedUser(); - console.log("userId", userId); - // rfqId 가져오기 - const rfqId = Number(formData.get("rfqId")) - - if (!rfqId) { - return { - success: false, - message: "RFQ ID가 필요합니다", - } - } - - // 폼 데이터 추출 및 기본 검증 (기존과 동일) - const vendorId = Number(formData.get("vendorId")) - const currency = formData.get("currency") as string - const paymentTermsCode = formData.get("paymentTermsCode") as string - const incotermsCode = formData.get("incotermsCode") as string - const incotermsDetail = formData.get("incotermsDetail") as string || null - const deliveryDate = formData.get("deliveryDate") ? new Date(formData.get("deliveryDate") as string) : null - const taxCode = formData.get("taxCode") as string || null - const placeOfShipping = formData.get("placeOfShipping") as string || null - const placeOfDestination = formData.get("placeOfDestination") as string || null - const materialPriceRelatedYn = formData.get("materialPriceRelatedYn") === "true" - - if (!vendorId || !currency || !paymentTermsCode || !incotermsCode) { - return { - success: false, - message: "필수 항목이 누락되었습니다", - } - } - - // 트랜잭션 시작 - return await db.transaction(async (tx) => { - // 0. 먼저 RFQ 상태 확인 - const rfq = await tx.query.procurementRfqs.findFirst({ - where: eq(procurementRfqs.id, rfqId), - columns: { - id: true, - status: true - } - }); - - if (!rfq) { - throw new Error("RFQ를 찾을 수 없습니다"); - } - console.log("rfq.status", rfq.status); - // 1. RFQ 상세 정보 저장 - const insertedDetails = await tx.insert(procurementRfqDetails).values({ - procurementRfqsId: rfqId, - vendorsId: vendorId, - currency, - paymentTermsCode, - incotermsCode, - incotermsDetail, - deliveryDate: deliveryDate || new Date(), // null이면 현재 날짜 사용 - taxCode, - placeOfShipping, - placeOfDestination, - materialPriceRelatedYn, - updatedBy: Number(userId), - updatedAt: new Date(), - }).returning({ id: procurementRfqDetails.id }); - - if (!insertedDetails || insertedDetails.length === 0) { - throw new Error("RFQ 상세 정보 저장에 실패했습니다"); - } - - const detailId = insertedDetails[0].id; - - - - // 2. RFQ 상태가 "RFQ Created"인 경우 "RFQ Vendor Assignned"로 업데이트 - let statusUpdated = false; - if (rfq.status === "RFQ Created") { - console.log("rfq 상태 업데이트 시작") - await tx.update(procurementRfqs) - .set({ - status: "RFQ Vendor Assignned", - updatedBy: Number(userId), - updatedAt: new Date() - }) - .where(eq(procurementRfqs.id, rfqId)); - - statusUpdated = true; - } - - // 3. 첨부 파일 처리 - const filePromises = []; - const uploadDir = join(process.cwd(), "public", "rfq", rfqId.toString(), "vendors", detailId.toString()); - - // 업로드 디렉토리 생성 - try { - await mkdir(uploadDir, { recursive: true }); - } catch (error) { - console.error("디렉토리 생성 오류:", error); - } - - // FormData에서 file 타입 항목 찾기 - for (const [key, value] of formData.entries()) { - if (key.startsWith("attachment-") && value instanceof File) { - const file = value as File; - - // 파일 크기가 0이면 건너뛰기 - if (file.size === 0) continue; - - // 파일 이름 생성 - const uniqueId = uuidv4(); - const fileName = `${uniqueId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`; - const filePath = join(uploadDir, fileName); - - // 파일을 버퍼로 변환 - const buffer = Buffer.from(await file.arrayBuffer()); - - // 파일 저장 - await writeFile(filePath, buffer); - - // DB에 첨부 파일 정보 저장 - filePromises.push( - tx.insert(procurementAttachments).values({ - attachmentType: 'VENDOR_SPECIFIC', - procurementRfqsId: null, - procurementRfqDetailsId: detailId, - fileName: fileName, - originalFileName: file.name, - filePath: `/uploads/rfq/${rfqId}/vendors/${detailId}/${fileName}`, - fileSize: file.size, - fileType: file.type, - description: `${file.name} - 벤더 ID ${vendorId}용 첨부파일`, - createdBy: Number(userId), - createdAt: new Date(), - }) - ); - } - } - - // 첨부 파일이 있으면 처리 - if (filePromises.length > 0) { - await Promise.all(filePromises); - } - - // 캐시 무효화 (여러 경로 지정 가능) - revalidateTag(`rfq-details-${rfqId}`); - revalidateTag(`rfqs-po`); - - return { - success: true, - message: "벤더 정보가 성공적으로 추가되었습니다", - data: { - id: detailId, - statusUpdated: statusUpdated - }, - }; - }); - - } catch (error) { - console.error("벤더 추가 오류:", error); - return { - success: false, - message: "벤더 추가 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : String(error)), - }; - } -} - - -// 벤더 데이터 조회 서버 액션 -export async function fetchVendors() { - try { - const data = await db.select().from(vendors) - - return { - success: true, - data, - } - } catch (error) { - console.error("벤더 데이터 로드 오류:", error) - return { - success: false, - message: "벤더 데이터를 불러오는 데 실패했습니다", - data: [] - } - } -} - -// 통화 데이터 조회 서버 액션 -export async function fetchCurrencies() { - try { - // 통화 테이블이 별도로 없다면 여기서 하드코딩하거나 설정 파일에서 가져올 수도 있습니다 - const data = [ - { code: "KRW", name: "Korean Won" }, - { code: "USD", name: "US Dollar" }, - { code: "EUR", name: "Euro" }, - { code: "JPY", name: "Japanese Yen" }, - { code: "CNY", name: "Chinese Yuan" }, - ] - - return { - success: true, - data, - } - } catch (error) { - console.error("통화 데이터 로드 오류:", error) - return { - success: false, - message: "통화 데이터를 불러오는 데 실패했습니다", - data: [] - } - } -} - -// 지불 조건 데이터 조회 서버 액션 -export async function fetchPaymentTerms() { - try { - const data = await db.select().from(paymentTerms) - - return { - success: true, - data, - } - } catch (error) { - console.error("지불 조건 데이터 로드 오류:", error) - return { - success: false, - message: "지불 조건 데이터를 불러오는 데 실패했습니다", - data: [] - } - } -} - -// 인코텀즈 데이터 조회 서버 액션 -export async function fetchIncoterms() { - try { - const data = await db.select().from(incoterms) - - return { - success: true, - data, - } - } catch (error) { - console.error("인코텀즈 데이터 로드 오류:", error) - return { - success: false, - message: "인코텀즈 데이터를 불러오는 데 실패했습니다", - data: [] - } - } -} - -export async function deleteRfqDetail(detailId: number) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return { - success: false, - message: "인증이 필요합니다", - }; - } - - // DB에서 항목 삭제 - await db.delete(procurementRfqDetails) - .where(eq(procurementRfqDetails.id, detailId)); - - // 캐시 무효화 - revalidateTag(`rfq-details-${detailId}`); - - return { - success: true, - message: "RFQ 벤더 정보가 삭제되었습니다", - }; - } catch (error) { - console.error("RFQ 벤더 정보 삭제 오류:", error); - return { - success: false, - message: "RFQ 벤더 정보 삭제 중 오류가 발생했습니다", - }; - } -} - -// RFQ 상세 정보 수정 -export async function updateRfqDetail(detailId: number, data: any) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return { - success: false, - message: "인증이 필요합니다", - }; - } - - const userId = Number(session.user.id); - - // 필요한 데이터 추출 - const { - vendorId, - currency, - paymentTermsCode, - incotermsCode, - incotermsDetail, - deliveryDate, - taxCode, - placeOfShipping, - placeOfDestination, - materialPriceRelatedYn, - } = data; - - // DB 업데이트 - await db.update(procurementRfqDetails) - .set({ - vendorsId: Number(vendorId), - currency, - paymentTermsCode, - incotermsCode, - incotermsDetail: incotermsDetail || null, - deliveryDate: deliveryDate ? new Date(deliveryDate) : new Date(), - taxCode: taxCode || null, - placeOfShipping: placeOfShipping || null, - placeOfDestination: placeOfDestination || null, - materialPriceRelatedYn, - updatedBy: userId, - updatedAt: new Date(), - }) - .where(eq(procurementRfqDetails.id, detailId)); - - // 캐시 무효화 - revalidateTag(`rfq-details-${detailId}`); - - return { - success: true, - message: "RFQ 벤더 정보가 수정되었습니다", - }; - } catch (error) { - console.error("RFQ 벤더 정보 수정 오류:", error); - return { - success: false, - message: "RFQ 벤더 정보 수정 중 오류가 발생했습니다", - }; - } -} - -export async function updateRfqRemark(rfqId: number, remark: string) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return { - success: false, - message: "인증이 필요합니다", - }; - } - - console.log(rfqId, remark) - - // DB 업데이트 - await db.update(procurementRfqs) - .set({ - remark, - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(procurementRfqs.id, rfqId)); - - // 캐시 무효화 - revalidateTag(`rfqs-po`); - revalidatePath("/evcp/po-rfq"); // 경로도 함께 무효화 - - return { - success: true, - message: "비고가 업데이트되었습니다", - }; - } catch (error) { - console.error("비고 업데이트 오류:", error); - return { - success: false, - message: "비고 업데이트 중 오류가 발생했습니다", - }; - } -} - -export async function sealRfq(rfqId: number) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return { - success: false, - message: "인증이 필요합니다", - }; - } - - // DB 업데이트 - await db.update(procurementRfqs) - .set({ - rfqSealedYn: true, - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(procurementRfqs.id, rfqId)); - - // 캐시 무효화 - revalidateTag(`rfqs-po`); - - return { - success: true, - message: "RFQ가 성공적으로 밀봉되었습니다", - }; - } catch (error) { - console.error("RFQ 밀봉 오류:", error); - return { - success: false, - message: "RFQ 밀봉 중 오류가 발생했습니다", - }; - } -} - -// RFQ 전송 서버 액션 -export async function sendRfq(rfqId: number) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - return { - success: false, - message: "인증이 필요합니다", - } - } - - // 현재 RFQ 상태 확인 - // RFQ 및 관련 정보 조회 - const rfq = await db.query.procurementRfqs.findFirst({ - where: eq(procurementRfqs.id, rfqId), - columns: { - id: true, - rfqCode: true, - status: true, - dueDate: true, - rfqSendDate: true, - remark: true, - rfqSealedYn: true, - itemCode: true, - itemName: true, - }, - with: { - project: { - columns: { - id: true, - code: true, - name: true, - } - }, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - }, - prItems: { - columns: { - id: true, - rfqItem: true, // 아이템 번호 - materialCode: true, - materialDescription: true, - quantity: true, - uom: true, - prNo: true, - majorYn: true, - } - } - } - }); - - if (!rfq) { - return { - success: false, - message: "RFQ를 찾을 수 없습니다", - } - } - - if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { - return { - success: false, - message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", - } - } - - const isResend = rfq.status === "RFQ Sent"; - - // 현재 사용자 정보 조회 (CC 용) - const sender = await db.query.users.findFirst({ - where: eq(users.id, Number(session.user.id)), - columns: { - id: true, - email: true, - name: true, - } - }); - - if (!sender || !sender.email) { - return { - success: false, - message: "보내는 사람의 이메일 정보를 찾을 수 없습니다", - } - } - - // RFQ에 할당된 벤더 목록 조회 - const rfqDetails = await db.query.procurementRfqDetails.findMany({ - where: eq(procurementRfqDetails.procurementRfqsId, rfqId), - columns: { - id: true, - vendorsId: true, - currency: true, - paymentTermsCode: true, - incotermsCode: true, - incotermsDetail: true, - deliveryDate: true, - }, - with: { - vendor: { - columns: { - id: true, - vendorName: true, - vendorCode: true, - } - }, - paymentTerms: { - columns: { - code: true, - description: true, - } - }, - incoterms: { - columns: { - code: true, - description: true, - } - } - } - }); - - if (rfqDetails.length === 0) { - return { - success: false, - message: "할당된 벤더가 없습니다", - } - } - - // 트랜잭션 시작 - await db.transaction(async (tx) => { - // 1. RFQ 상태 업데이트 - await tx.update(procurementRfqs) - .set({ - status: "RFQ Sent", - rfqSendDate: new Date(), - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(procurementRfqs.id, rfqId)); - - // 2. 각 벤더에 대해 초기 견적서 레코드 생성 및 이메일 발송 - for (const detail of rfqDetails) { - if (!detail.vendorsId || !detail.vendor) continue; - - // 기존 Draft 견적서가 있는지 확인 - const existingQuotation = await tx.query.procurementVendorQuotations.findFirst({ - where: and( - eq(procurementVendorQuotations.rfqId, rfqId), - eq(procurementVendorQuotations.vendorId, detail.vendorsId) - ), - orderBy: [desc(procurementVendorQuotations.quotationVersion)] - }); - - // 견적서 코드 (기존 것 재사용 또는 신규 생성) - const quotationCode = existingQuotation?.quotationCode || `${rfq.rfqCode}-${detail.vendorsId}`; - - // 버전 관리 - 재전송인 경우 버전 증가 - const quotationVersion = existingQuotation ? ((existingQuotation.quotationVersion? existingQuotation.quotationVersion: 0 )+ 1) : 1; - - // 견적서 레코드 생성 - const insertedQuotation = await tx.insert(procurementVendorQuotations).values({ - rfqId, - vendorId: detail.vendorsId, - quotationCode, - quotationVersion, - totalItemsCount: rfq.prItems.length, - subTotal: "0", - taxTotal: "0", - discountTotal: "0", - totalPrice: "0", - currency: detail.currency || "USD", - // 납품일은 RFQ 납품일보다 조금 이전으로 설정 (기본값) - estimatedDeliveryDate: detail.deliveryDate ? - new Date(detail.deliveryDate.getTime() - 7 * 24 * 60 * 60 * 1000) : // 1주일 전 - undefined, - paymentTermsCode: detail.paymentTermsCode, - incotermsCode: detail.incotermsCode, - incotermsDetail: detail.incotermsDetail, - status: "Draft", - createdBy: Number(session.user.id), - updatedBy: Number(session.user.id), - createdAt: new Date(), - updatedAt: new Date(), - }).returning({ id: procurementVendorQuotations.id }); - - // 새로 생성된 견적서 ID - const quotationId = insertedQuotation[0].id; - - // 3. 각 PR 아이템에 대해 견적 아이템 생성 - for (const prItem of rfq.prItems) { - // procurementQuotationItems에 레코드 생성 - await tx.insert(procurementQuotationItems).values({ - quotationId, - prItemId: prItem.id, - materialCode: prItem.materialCode, - materialDescription: prItem.materialDescription, - quantity: prItem.quantity, - uom: prItem.uom, - // 기본값으로 설정된 필드 - unitPrice: 0, - totalPrice: 0, - currency: detail.currency || "USD", - // 나머지 필드는 null 또는 기본값 사용 - createdAt: new Date(), - updatedAt: new Date(), - }); - } - - // 벤더에 속한 모든 사용자 조회 - const vendorUsers = await db.query.users.findMany({ - where: eq(users.companyId, detail.vendorsId), - columns: { - id: true, - email: true, - name: true, - language: true - } - }); - - // 유효한 이메일 주소만 필터링 - const vendorEmailsString = vendorUsers - .filter(user => user.email) - .map(user => user.email) - .join(", "); - - if (vendorEmailsString) { - // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) - const language = vendorUsers[0]?.language || "en"; - - // 이메일 컨텍스트 구성 - const emailContext = { - language: language, - rfq: { - id: rfq.id, - code: rfq.rfqCode, - title: rfq.item?.itemName || '', - projectCode: rfq.project?.code || '', - projectName: rfq.project?.name || '', - description: rfq.remark || '', - dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A', - deliveryDate: detail.deliveryDate ? formatDate(detail.deliveryDate, "KR") : 'N/A', - }, - vendor: { - id: detail.vendor.id, - code: detail.vendor.vendorCode || '', - name: detail.vendor.vendorName, - }, - sender: { - fullName: sender.name || '', - email: sender.email, - }, - items: rfq.prItems.map(item => ({ - itemNumber: item.rfqItem || '', - materialCode: item.materialCode || '', - description: item.materialDescription || '', - quantity: item.quantity, - uom: item.uom || '', - })), - details: { - currency: detail.currency || 'USD', - paymentTerms: detail.paymentTerms?.description || detail.paymentTermsCode || 'N/A', - incoterms: detail.incoterms ? - `${detail.incoterms.code} ${detail.incotermsDetail || ''}` : - detail.incotermsCode ? `${detail.incotermsCode} ${detail.incotermsDetail || ''}` : 'N/A', - }, - quotationCode: existingQuotation?.quotationCode || `QUO-${rfqId}-${detail.vendorsId}`, - systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', - isResend: isResend, - quotationVersion: quotationVersion, - versionInfo: isResend ? `(버전 ${quotationVersion})` : '', - }; - - // 이메일 전송 (모든 벤더 이메일을 to 필드에 배열로 전달) - await sendEmail({ - to: vendorEmailsString, - subject: isResend - ? `[RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}` - : `[RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`, - template: 'rfq-notification', - context: emailContext, - cc: sender.email, // 발신자를 CC에 추가 - }); - } - } - }); - - // 캐시 무효화 - revalidateTag(`rfqs-po`); - - return { - success: true, - message: "RFQ가 성공적으로 전송되었습니다", - } - } catch (error) { - console.error("RFQ 전송 오류:", error); - return { - success: false, - message: "RFQ 전송 중 오류가 발생했습니다", - } - } -} -/** - * 첨부파일 타입 정의 - */ -export interface Attachment { - id: number - fileName: string - fileSize: number - fileType: string | null // <- null 허용 - filePath: string - uploadedAt: Date -} - -/** - * 코멘트 타입 정의 - */ -export interface Comment { - id: number - rfqId: number - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string - isVendorComment: boolean | null // null 허용으로 변경 - createdAt: Date - updatedAt: Date - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[] - isRead: boolean | null // null 허용으로 변경 -} - - -/** - * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 - * - * @param rfqId RFQ ID - * @param vendorId 벤더 ID - * @returns 코멘트 목록 - */ -export async function fetchVendorComments(rfqId: number, vendorId?: number): Promise<Comment[]> { - if (!vendorId) { - return [] - } - - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - throw new Error("인증이 필요합니다") - } - - // 코멘트 쿼리 - const comments = await db.query.procurementRfqComments.findMany({ - where: and( - eq(procurementRfqComments.rfqId, rfqId), - eq(procurementRfqComments.vendorId, vendorId) - ), - orderBy: [procurementRfqComments.createdAt], - with: { - user: { - columns: { - name: true - } - }, - vendor: { - columns: { - vendorName: true - } - }, - attachments: true, - } - }) - - // 결과 매핑 - return comments.map(comment => ({ - id: comment.id, - rfqId: comment.rfqId, - vendorId: comment.vendorId, - userId: comment.userId || undefined, - content: comment.content, - isVendorComment: comment.isVendorComment, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - userName: comment.user?.name, - vendorName: comment.vendor?.vendorName, - isRead: comment.isRead, - attachments: comment.attachments.map(att => ({ - id: att.id, - fileName: att.fileName, - fileSize: att.fileSize, - fileType: att.fileType, - filePath: att.filePath, - uploadedAt: att.uploadedAt - })) - })) - } catch (error) { - console.error('벤더 코멘트 가져오기 오류:', error) - throw error - } -} - -/** - * 코멘트를 읽음 상태로 표시하는 서버 액션 - * - * @param rfqId RFQ ID - * @param vendorId 벤더 ID - */ -export async function markMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> { - if (!vendorId) { - return - } - - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - throw new Error("인증이 필요합니다") - } - - // 벤더가 작성한 읽지 않은 코멘트 업데이트 - await db.update(procurementRfqComments) - .set({ isRead: true }) - .where( - and( - eq(procurementRfqComments.rfqId, rfqId), - eq(procurementRfqComments.vendorId, vendorId), - eq(procurementRfqComments.isVendorComment, true), - eq(procurementRfqComments.isRead, false) - ) - ) - - // 캐시 무효화 - revalidateTag(`rfq-${rfqId}-comments`) - } catch (error) { - console.error('메시지 읽음 표시 오류:', error) - throw error - } -} - -/** - * 읽지 않은 메시지 개수 가져오기 서버 액션 - * - * @param rfqId RFQ ID - */ -export async function fetchUnreadMessages(rfqId: number): Promise<Record<number, number>> { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user) { - throw new Error("인증이 필요합니다"); - } - - // 쿼리 빌더 방식으로 카운트 조회 - 타입 안전 방식 - const result = await db - .select({ - vendorId: procurementRfqComments.vendorId, - unreadCount: count() - }) - .from(procurementRfqComments) - .where( - and( - eq(procurementRfqComments.rfqId, rfqId), - eq(procurementRfqComments.isVendorComment, true), - eq(procurementRfqComments.isRead, false) - ) - ) - .groupBy(procurementRfqComments.vendorId); - - // 결과 매핑 - const unreadMessages: Record<number, number> = {}; - result.forEach(row => { - if (row.vendorId) { - unreadMessages[row.vendorId] = Number(row.unreadCount); - } - }); - - return unreadMessages; - } catch (error) { - console.error('읽지 않은 메시지 개수 가져오기 오류:', error); - throw error; - } -} - - -/** - * 견적서 업데이트 서버 액션 - */ -export async function updateVendorQuotation(data: { - id: number - quotationVersion?: number - currency?: string - validUntil?: Date - estimatedDeliveryDate?: Date - paymentTermsCode?: string - incotermsCode?: string - incotermsDetail?: string - remark?: string - subTotal?: string - taxTotal?: string - discountTotal?: string - totalPrice?: string - totalItemsCount?: number -}) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - return { - success: false, - message: "인증이 필요합니다", - } - } - - // 견적서 존재 확인 - const quotation = await db.query.procurementVendorQuotations.findFirst({ - where: eq(procurementVendorQuotations.id, data.id), - }) - - if (!quotation) { - return { - success: false, - message: "견적서를 찾을 수 없습니다", - } - } - - // 권한 확인 (벤더 또는 관리자만 수정 가능) - const isAuthorized = - (session.user.domain === "partners" && session.user.companyId === quotation.vendorId) - - if (!isAuthorized) { - return { - success: false, - message: "견적서 수정 권한이 없습니다", - } - } - - // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능) - if (quotation.status !== "Draft" && quotation.status !== "Rejected") { - return { - success: false, - message: "제출되었거나 승인된 견적서는 수정할 수 없습니다", - } - } - - // 업데이트할 데이터 구성 - const updateData: Record<string, any> = { - updatedBy: Number(session.user.id), - updatedAt: new Date(), - } - - // 필드 추가 - if (data.currency) updateData.currency = data.currency - if (data.validUntil) updateData.validUntil = data.validUntil - if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate - if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode - if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode - if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail - if (data.remark !== undefined) updateData.remark = data.remark - if (data.subTotal) updateData.subTotal = data.subTotal - if (data.taxTotal) updateData.taxTotal = data.taxTotal - if (data.discountTotal) updateData.discountTotal = data.discountTotal - if (data.totalPrice) updateData.totalPrice = data.totalPrice - if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount - - // Rejected 상태에서 수정 시 Draft 상태로 변경 - if (quotation.status === "Rejected") { - updateData.status = "Draft" - - // 버전 증가 - if (data.quotationVersion) { - updateData.quotationVersion = data.quotationVersion + 1 - } else { - updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1 - } - } - - // 견적서 업데이트 - await db.update(procurementVendorQuotations) - .set(updateData) - .where(eq(procurementVendorQuotations.id, data.id)) - - // 캐시 무효화 - revalidateTag(`quotation-${data.id}`) - revalidateTag(`rfq-${quotation.rfqId}`) - - return { - success: true, - message: "견적서가 업데이트되었습니다", - } - } catch (error) { - console.error("견적서 업데이트 오류:", error) - return { - success: false, - message: "견적서 업데이트 중 오류가 발생했습니다", - } - } -} - -interface QuotationItem { - unitPrice: number; - deliveryDate: Date | null; - status: "Draft" | "Rejected" | "Submitted" | "Approved"; // 상태를 유니온 타입으로 정의 - - // 필요한 다른 속성들도 추가 -} - -/** - * 견적서 제출 서버 액션 - */ -export async function submitVendorQuotation(data: { - id: number - quotationVersion?: number - currency?: string - validUntil?: Date - estimatedDeliveryDate?: Date - paymentTermsCode?: string - incotermsCode?: string - incotermsDetail?: string - remark?: string - subTotal?: string - taxTotal?: string - discountTotal?: string - totalPrice?: string - totalItemsCount?: number -}) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - return { - success: false, - message: "인증이 필요합니다", - } - } - - // 견적서 존재 확인 - const quotation = await db.query.procurementVendorQuotations.findFirst({ - where: eq(procurementVendorQuotations.id, data.id), - with: { - items: true, - } - }) - - if (!quotation) { - return { - success: false, - message: "견적서를 찾을 수 없습니다", - } - } - - // 권한 확인 (벤더 또는 관리자만 제출 가능) - const isAuthorized = - (session.user.domain === "partners" && session.user.companyId === quotation.vendorId) - - if (!isAuthorized) { - return { - success: false, - message: "견적서 제출 권한이 없습니다", - } - } - - // 상태 확인 (Draft 또는 Rejected 상태만 제출 가능) - if (quotation.status !== "Draft" && quotation.status !== "Rejected") { - return { - success: false, - message: "이미 제출되었거나 승인된 견적서는 다시 제출할 수 없습니다", - } - } - - // 견적 항목 검증 - if (!quotation.items || (quotation.items as QuotationItem[]).length === 0) { - return { - success: false, - message: "견적 항목이 없습니다", - } - } - - // 필수 항목 검증 - const hasEmptyItems = (quotation.items as QuotationItem[]).some(item => - item.unitPrice <= 0 || !item.deliveryDate - ) - - if (hasEmptyItems) { - return { - success: false, - message: "모든 항목의 단가와 납품일을 입력해주세요", - } - } - - // 필수 정보 검증 - if (!data.validUntil || !data.estimatedDeliveryDate) { - return { - success: false, - message: "견적 유효기간과 예상 납품일은 필수 항목입니다", - } - } - - // 업데이트할 데이터 구성 - const updateData: Record<string, any> = { - status: "Submitted", - submittedAt: new Date(), - updatedBy: Number(session.user.id), - updatedAt: new Date(), - } - - // 필드 추가 - if (data.currency) updateData.currency = data.currency - if (data.validUntil) updateData.validUntil = data.validUntil - if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate - if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode - if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode - if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail - if (data.remark !== undefined) updateData.remark = data.remark - if (data.subTotal) updateData.subTotal = data.subTotal - if (data.taxTotal) updateData.taxTotal = data.taxTotal - if (data.discountTotal) updateData.discountTotal = data.discountTotal - if (data.totalPrice) updateData.totalPrice = data.totalPrice - if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount - - // Rejected 상태에서 제출 시 버전 증가 - if (quotation.status === "Rejected") { - updateData.status = "Revised" - - if (data.quotationVersion) { - updateData.quotationVersion = data.quotationVersion + 1 - } else { - updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1 - } - } - - // 견적서 업데이트 - await db.update(procurementVendorQuotations) - .set(updateData) - .where(eq(procurementVendorQuotations.id, data.id)) - - // 캐시 무효화 - revalidateTag(`quotation-${data.id}`) - revalidateTag(`rfq-${quotation.rfqId}`) - - return { - success: true, - message: "견적서가 성공적으로 제출되었습니다", - } - } catch (error) { - console.error("견적서 제출 오류:", error) - return { - success: false, - message: "견적서 제출 중 오류가 발생했습니다", - } - } -} - -/** - * 견적 항목 업데이트 서버 액션 - */ -export async function updateQuotationItem(data: { - id: number - unitPrice?: number - totalPrice?: number - vendorMaterialCode?: string - vendorMaterialDescription?: string - deliveryDate?: Date | null - leadTimeInDays?: number - taxRate?: number - taxAmount?: number - discountRate?: number - discountAmount?: number - remark?: string - isAlternative?: boolean - isRecommended?: boolean -}) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - return { - success: false, - message: "인증이 필요합니다", - } - } - - // 항목 존재 확인 - const item = await db.query.procurementQuotationItems.findFirst({ - where: eq(procurementQuotationItems.id, data.id), - with: { - quotation: true, - } - }) - - if (!item || !item.quotation) { - return { - success: false, - message: "견적 항목을 찾을 수 없습니다", - } - } - - // 권한 확인 (벤더 또는 관리자만 수정 가능) - const isAuthorized = ( - session.user.domain === "partners" && - session.user.companyId === (item.quotation as { vendorId: number }).vendorId - ) - - if (!isAuthorized) { - return { - success: false, - message: "견적 항목 수정 권한이 없습니다", - } - } - - const quotation = item.quotation as Quotation; - - // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능) - if (quotation.status !== "Draft" && quotation.status !== "Rejected") { - return { - success: false, - message: "제출되었거나 승인된 견적서의 항목은 수정할 수 없습니다", - } - } - - // 업데이트할 데이터 구성 - const updateData: Record<string, any> = { - updatedAt: new Date(), - } - - // 필드 추가 - if (data.unitPrice !== undefined) updateData.unitPrice = data.unitPrice - if (data.totalPrice !== undefined) updateData.totalPrice = data.totalPrice - if (data.vendorMaterialCode !== undefined) updateData.vendorMaterialCode = data.vendorMaterialCode - if (data.vendorMaterialDescription !== undefined) updateData.vendorMaterialDescription = data.vendorMaterialDescription - if (data.deliveryDate !== undefined) updateData.deliveryDate = data.deliveryDate - if (data.leadTimeInDays !== undefined) updateData.leadTimeInDays = data.leadTimeInDays - if (data.taxRate !== undefined) updateData.taxRate = data.taxRate - if (data.taxAmount !== undefined) updateData.taxAmount = data.taxAmount - if (data.discountRate !== undefined) updateData.discountRate = data.discountRate - if (data.discountAmount !== undefined) updateData.discountAmount = data.discountAmount - if (data.remark !== undefined) updateData.remark = data.remark - if (data.isAlternative !== undefined) updateData.isAlternative = data.isAlternative - if (data.isRecommended !== undefined) updateData.isRecommended = data.isRecommended - - // 항목 업데이트 - await db.update(procurementQuotationItems) - .set(updateData) - .where(eq(procurementQuotationItems.id, data.id)) - - // 캐시 무효화 - revalidateTag(`quotation-${item.quotationId}`) - - return { - success: true, - message: "견적 항목이 업데이트되었습니다", - } - } catch (error) { - console.error("견적 항목 업데이트 오류:", error) - return { - success: false, - message: "견적 항목 업데이트 중 오류가 발생했습니다", - } - } -} - - -// Quotation 상태 타입 정의 -export type QuotationStatus = "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"; - -// 인터페이스 정의 -export interface Quotation { - id: number; - quotationCode: string; - status: QuotationStatus; - totalPrice: string; - currency: string; - submittedAt: string | null; - validUntil: string | null; - vendorId: number; - rfq?: { - rfqCode: string; - } | null; - vendor?: any; -} - - -/** - * 벤더별 견적서 목록 조회 - */ -export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: string) { - return unstable_cache( - async () => { - try { - // 페이지네이션 설정 - const page = input.page || 1; - const perPage = input.perPage || 10; - const offset = (page - 1) * perPage; - - // 필터링 설정 - // advancedTable 모드로 where 절 구성 - const advancedWhere = filterColumns({ - table: procurementVendorQuotations, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - // 글로벌 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(procurementVendorQuotations.quotationCode, s), - ilike(procurementVendorQuotations.status, s), - ilike(procurementVendorQuotations.totalPrice, s) - ); - } - - // 벤더 ID 조건 - const vendorIdWhere = vendorId ? - eq(procurementVendorQuotations.vendorId, Number(vendorId)) : - undefined; - - // 모든 조건 결합 - let whereConditions = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - if (vendorIdWhere) whereConditions.push(vendorIdWhere); - - // 최종 조건 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - // 정렬 설정 - const orderBy = input.sort && input.sort.length > 0 - ? input.sort.map((item) => { - // @ts-ignore - 동적 속성 접근 - return item.desc ? desc(procurementVendorQuotations[item.id]) : asc(procurementVendorQuotations[item.id]); - }) - : [asc(procurementVendorQuotations.updatedAt)]; - - // 쿼리 실행 - const quotations = await db.query.procurementVendorQuotations.findMany({ - where: finalWhere, - orderBy, - offset, - limit: perPage, - with: { - rfq:true, - vendor: true, - } - }); - // 전체 개수 조회 - const { totalCount } = await db - .select({ totalCount: count() }) - .from(procurementVendorQuotations) - .where(finalWhere || undefined) - .then(rows => rows[0]); - - - // 페이지 수 계산 - const pageCount = Math.ceil(Number(totalCount) / perPage); - - return { - data: quotations as Quotation[], - pageCount - }; - } catch (err) { - console.error("getVendorQuotations 에러:", err); - return { data: [], pageCount: 0 }; - } - }, - [`vendor-quotations-${vendorId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`vendor-quotations-${vendorId}`], - } - )(); -} - -/** - * 견적서 상태별 개수 조회 - */ -export async function getQuotationStatusCounts(vendorId: string) { - return unstable_cache( - async () => { - try { - const initial: Record<QuotationStatus, number> = { - Draft: 0, - Submitted: 0, - Revised: 0, - Rejected: 0, - Accepted: 0, - }; - - // 벤더 ID 조건 - const whereCondition = vendorId ? - eq(procurementVendorQuotations.vendorId, Number(vendorId)) : - undefined; - - // 상태별 그룹핑 쿼리 - const rows = await db - .select({ - status: procurementVendorQuotations.status, - count: count(), - }) - .from(procurementVendorQuotations) - .where(whereCondition) - .groupBy(procurementVendorQuotations.status); - - // 결과 처리 - const result = rows.reduce<Record<QuotationStatus, number>>((acc, { status, count }) => { - if (status) { - acc[status as QuotationStatus] = Number(count); - } - return acc; - }, initial); - - return result; - } catch (err) { - console.error("getQuotationStatusCounts 에러:", err); - return {} as Record<QuotationStatus, number>; - } - }, - [`quotation-status-counts-${vendorId}`], - { - revalidate: 3600, - } - )(); -} - - -/** - * 벤더 입장에서 구매자와의 커뮤니케이션 메시지를 가져오는 서버 액션 - * - * @param rfqId RFQ ID - * @param vendorId 벤더 ID - * @returns 코멘트 목록 - */ -export async function fetchBuyerVendorComments(rfqId: number, vendorId: number): Promise<Comment[]> { - if (!rfqId || !vendorId) { - return []; - } - - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - throw new Error("인증이 필요합니다"); - } - - // 벤더 접근 권한 확인 (벤더 사용자이며 해당 벤더의 ID와 일치해야 함) - if ( - session.user.domain === "partners" && - ((session.user.companyId ?? 0) !== Number(vendorId)) - ) { - throw new Error("접근 권한이 없습니다"); - } - - // 코멘트 쿼리 - const comments = await db.query.procurementRfqComments.findMany({ - where: and( - eq(procurementRfqComments.rfqId, rfqId), - eq(procurementRfqComments.vendorId, vendorId) - ), - orderBy: [procurementRfqComments.createdAt], - with: { - user: { - columns: { - name: true - } - }, - vendor: { - columns: { - vendorName: true - } - }, - attachments: true, - } - }); - - // 벤더가 접근하는 경우, 벤더 메시지를 읽음 상태로 표시 - if (session.user.domain === "partners") { - // 읽지 않은 구매자 메시지를 읽음 상태로 업데이트 - await db.update(procurementRfqComments) - .set({ isRead: true }) - .where( - and( - eq(procurementRfqComments.rfqId, rfqId), - eq(procurementRfqComments.vendorId, vendorId), - eq(procurementRfqComments.isVendorComment, false), // 구매자가 보낸 메시지 - eq(procurementRfqComments.isRead, false) - ) - ) - .execute(); - } - - // 결과 매핑 - return comments.map(comment => ({ - id: comment.id, - rfqId: comment.rfqId, - vendorId: comment.vendorId, - userId: comment.userId || undefined, - content: comment.content, - isVendorComment: comment.isVendorComment, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - userName: comment.user?.name, - vendorName: comment.vendor?.vendorName, - isRead: comment.isRead, - attachments: comment.attachments.map(att => ({ - id: att.id, - fileName: att.fileName, - fileSize: att.fileSize, - fileType: att.fileType, - filePath: att.filePath, - uploadedAt: att.uploadedAt - })) - })); - } catch (error) { - console.error('벤더-구매자 커뮤니케이션 가져오기 오류:', error); - throw error; - } -} - - -const getRandomProject = async () => { - const allProjects = await db.select().from(projects).limit(10); - const randomIndex = Math.floor(Math.random() * allProjects.length); - return allProjects[randomIndex] || null; -}; - -const getRandomItem = async () => { - const allItems = await db.select().from(items).limit(10); - const randomIndex = Math.floor(Math.random() * allItems.length); - return allItems[randomIndex] || null; -}; - -// 외부 시스템에서 RFQ 가져오기 서버 액션 -export async function fetchExternalRfqs() { - try { - // 현재 로그인한 사용자의 세션 정보 가져오기 - const session = await getServerSession(authOptions); - - if (!session || !session.user || !session.user.id) { - return { - success: false, - message: '인증된 사용자 정보를 찾을 수 없습니다' - }; - } - - const userId = session.user.id; - - const randomProject = await getRandomProject(); - const randomItem = await getRandomItem(); - - if (!randomProject || !randomItem) { - return { - success: false, - message: '임의 데이터를 생성하는 데 필요한 기본 데이터가 없습니다' - }; - } - - // 현재 날짜 기준 임의 날짜 생성 - const today = new Date(); - const dueDate = new Date(today); - dueDate.setDate(today.getDate() + Math.floor(Math.random() * 30) + 15); // 15-45일 후 - - - // RFQ 코드 생성 (현재 연도 + 3자리 숫자) - const currentYear = today.getFullYear(); - const randomNum = Math.floor(Math.random() * 900) + 100; // 100-999 - const rfqCode = `R${currentYear}${randomNum}`; - const seriesOptions = ["SS", "II", ""]; - const randomSeriesIndex = Math.floor(Math.random() * seriesOptions.length); - const seriesValue = seriesOptions[randomSeriesIndex]; - - // RFQ 생성 - 로그인한 사용자 ID 사용 - const newRfq = await db.insert(procurementRfqs).values({ - rfqCode, - projectId: randomProject.id, - series:seriesValue, - itemCode: randomItem.itemCode || `ITEM-${Math.floor(Math.random() * 1000)}`, // itemId 대신 itemCode 사용 - itemName: randomItem.itemName || `임의 아이템 ${Math.floor(Math.random() * 100)}`, // itemName 추가 - dueDate, - rfqSendDate: null, // null로 설정/ - status: "RFQ Created", - rfqSealedYn: false, - picCode: `PIC-${Math.floor(Math.random() * 1000)}`, - remark: "테스트용으로 아무말이나 들어간 것으로 실제로는 SAP에 있는 값이 옵니다. 오해 ㄴㄴ", - createdBy: userId, - updatedBy: userId, - }).returning(); - - if (newRfq.length === 0) { - return { - success: false, - message: 'RFQ 생성에 실패했습니다' - }; - } - - // PR 항목 생성 (1-3개 임의 생성) - const prItemsCount = Math.floor(Math.random() * 3) + 1; - const createdPrItems = []; - - for (let i = 0; i < prItemsCount; i++) { - const deliveryDate = new Date(today); - deliveryDate.setDate(today.getDate() + Math.floor(Math.random() * 60) + 30); // 30-90일 후 - - const randomTwoDigits = String(Math.floor(Math.random() * 100)).padStart(2, '0'); - // 프로젝트와 아이템 코드가 있다고 가정하고, 없을 경우 기본값 사용 - const projectCode = randomProject.code || 'PROJ'; - const itemCode = randomItem.itemCode || 'ITEM'; - const materialCode = `${projectCode}${itemCode}${randomTwoDigits}`; - const isMajor = i === 0 ? true : Math.random() > 0.7; - - const newPrItem = await db.insert(prItems).values({ - procurementRfqsId: newRfq[0].id, - rfqItem: `RFQI-${Math.floor(Math.random() * 1000)}`, - prItem: `PRI-${Math.floor(Math.random() * 1000)}`, - prNo: `PRN-${Math.floor(Math.random() * 1000)}`, - // itemId: randomItem.id, - materialCode, - materialCategory: "Standard", - acc: `ACC-${Math.floor(Math.random() * 100)}`, - materialDescription: `${['알루미늄', '구리', '철', '실리콘'][Math.floor(Math.random() * 4)]} 재질 부품`, - size: `${Math.floor(Math.random() * 100) + 10}x${Math.floor(Math.random() * 100) + 10}`, - deliveryDate, - quantity: Math.floor(Math.random() * 100) + 1, - uom: ['EA', 'KG', 'M', 'L'][Math.floor(Math.random() * 4)], - grossWeight: Math.floor(Math.random() * 1000) / 10, - gwUom: ['KG', 'T'][Math.floor(Math.random() * 2)], - specNo: `SPEC-${Math.floor(Math.random() * 1000)}`, - majorYn:isMajor, // 30% 확률로 true - remark: "외부 시스템에서 가져온 PR 항목", - }).returning(); - - createdPrItems.push(newPrItem[0]); - } - - revalidateTag(`rfqs-po`) - - return { - success: true, - message: '외부 RFQ를 성공적으로 가져왔습니다', - data: { - rfq: newRfq[0], - prItems: createdPrItems - } - }; - - } catch (error) { - console.error('외부 RFQ 가져오기 오류:', error); - return { - success: false, - message: '외부 RFQ를 가져오는 중 오류가 발생했습니다' - }; - } -} - -/** - * RFQ ID에 해당하는 모든 벤더 견적 정보를 조회하는 서버 액션 - * @param rfqId RFQ ID - * @returns 견적 정보 목록 - */ -export async function fetchVendorQuotations(rfqId: number) { - try { - // 벤더 정보와 함께 견적 정보 조회 - const quotations = await db - .select({ - // 견적 기본 정보 - id: procurementVendorQuotations.id, - rfqId: procurementVendorQuotations.rfqId, - vendorId: procurementVendorQuotations.vendorId, - quotationCode: procurementVendorQuotations.quotationCode, - quotationVersion: procurementVendorQuotations.quotationVersion, - totalItemsCount: procurementVendorQuotations.totalItemsCount, - subTotal: procurementVendorQuotations.subTotal, - taxTotal: procurementVendorQuotations.taxTotal, - discountTotal: procurementVendorQuotations.discountTotal, - totalPrice: procurementVendorQuotations.totalPrice, - currency: procurementVendorQuotations.currency, - validUntil: procurementVendorQuotations.validUntil, - estimatedDeliveryDate: procurementVendorQuotations.estimatedDeliveryDate, - paymentTermsCode: procurementVendorQuotations.paymentTermsCode, - incotermsCode: procurementVendorQuotations.incotermsCode, - incotermsDetail: procurementVendorQuotations.incotermsDetail, - status: procurementVendorQuotations.status, - remark: procurementVendorQuotations.remark, - rejectionReason: procurementVendorQuotations.rejectionReason, - submittedAt: procurementVendorQuotations.submittedAt, - acceptedAt: procurementVendorQuotations.acceptedAt, - createdAt: procurementVendorQuotations.createdAt, - updatedAt: procurementVendorQuotations.updatedAt, - - // 벤더 정보 - vendorName: vendors.vendorName, - paymentTermsDescription: paymentTerms.description, - incotermsDescription: incoterms.description, - }) - .from(procurementVendorQuotations) - .leftJoin(vendors, eq(procurementVendorQuotations.vendorId, vendors.id)) - .leftJoin(paymentTerms, eq(procurementVendorQuotations.paymentTermsCode, paymentTerms.code)) - .leftJoin(incoterms, eq(procurementVendorQuotations.incotermsCode, incoterms.code)) - .where( - and( - eq(procurementVendorQuotations.rfqId, rfqId), - // eq(procurementVendorQuotations.status, "Submitted") // <=== Submitted 상태만! - ) - ) - .orderBy(desc(procurementVendorQuotations.updatedAt)) - - - return { success: true, data: quotations } - } catch (error) { - console.error("벤더 견적 조회 오류:", error) - return { success: false, error: "벤더 견적을 조회하는 중 오류가 발생했습니다" } - } -} - -/** - * 견적 ID 목록에 해당하는 모든 견적 아이템 정보를 조회하는 서버 액션 - * @param quotationIds 견적 ID 배열 - * @returns 견적 아이템 정보 목록 - */ -export async function fetchQuotationItems(quotationIds: number[]) { - try { - // 빈 배열이 전달된 경우 빈 결과 반환 - if (!quotationIds.length) { - return { success: true, data: [] } - } - - // 견적 아이템 정보 조회 - const items = await db - .select() - .from(procurementQuotationItems) - .where(inArray(procurementQuotationItems.quotationId, quotationIds)) - .orderBy(procurementQuotationItems.id) - - return { success: true, data: items } - } catch (error) { - console.error("견적 아이템 조회 오류:", error) - return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" } - } -} - diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx deleted file mode 100644 index 79524f58..00000000 --- a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx +++ /dev/null @@ -1,512 +0,0 @@ -"use client" - -import * as React from "react" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { toast } from "sonner" -import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react" - -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { ProcurementRfqsView } from "@/db/schema" -import { addVendorToRfq } from "@/lib/procurement-rfqs/services" -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { ScrollArea } from "@/components/ui/scroll-area" - -// 필수 필드를 위한 커스텀 레이블 컴포넌트 -const RequiredLabel = ({ children }: { children: React.ReactNode }) => ( - <FormLabel> - {children} <span className="text-red-500">*</span> - </FormLabel> -); - -// 폼 유효성 검증 스키마 -const vendorFormSchema = z.object({ - vendorId: z.string().min(1, "벤더를 선택해주세요"), - currency: z.string().min(1, "통화를 선택해주세요"), - paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), - incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), - incotermsDetail: z.string().optional(), - deliveryDate: z.string().optional(), - taxCode: z.string().optional(), - placeOfShipping: z.string().optional(), - placeOfDestination: z.string().optional(), - materialPriceRelatedYn: z.boolean().default(false), -}) - -type VendorFormValues = z.infer<typeof vendorFormSchema> - -interface AddVendorDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: ProcurementRfqsView | null - // 벤더 및 기타 옵션 데이터를 prop으로 받음 - vendors?: { id: number; vendorName: string; vendorCode: string }[] - currencies?: { code: string; name: string }[] - paymentTerms?: { code: string; description: string }[] - incoterms?: { code: string; description: string }[] - onSuccess?: () => void - existingVendorIds?: number[] - -} - -export function AddVendorDialog({ - open, - onOpenChange, - selectedRfq, - vendors = [], - currencies = [], - paymentTerms = [], - incoterms = [], - onSuccess, - existingVendorIds = [], // 기본값 빈 배열 -}: AddVendorDialogProps) { - - - const availableVendors = React.useMemo(() => { - return vendors.filter(vendor => !existingVendorIds.includes(vendor.id)); - }, [vendors, existingVendorIds]); - - - // 파일 업로드 상태 관리 - const [attachments, setAttachments] = useState<File[]>([]) - const [isSubmitting, setIsSubmitting] = useState(false) - - // 벤더 선택을 위한 팝오버 상태 - const [vendorOpen, setVendorOpen] = useState(false) - - const form = useForm<VendorFormValues>({ - resolver: zodResolver(vendorFormSchema), - defaultValues: { - vendorId: "", - currency: "", - paymentTermsCode: "", - incotermsCode: "", - incotermsDetail: "", - deliveryDate: "", - taxCode: "", - placeOfShipping: "", - placeOfDestination: "", - materialPriceRelatedYn: false, - }, - }) - - // 폼 제출 핸들러 - async function onSubmit(values: VendorFormValues) { - if (!selectedRfq) { - toast.error("선택된 RFQ가 없습니다") - return - } - - try { - setIsSubmitting(true) - - // FormData 생성 - const formData = new FormData() - formData.append("rfqId", selectedRfq.id.toString()) - - // 폼 데이터 추가 - Object.entries(values).forEach(([key, value]) => { - formData.append(key, value.toString()) - }) - - // 첨부파일 추가 - attachments.forEach((file, index) => { - formData.append(`attachment-${index}`, file) - }) - - // 서버 액션 호출 - const result = await addVendorToRfq(formData) - - if (result.success) { - toast.success("벤더가 성공적으로 추가되었습니다") - onOpenChange(false) - form.reset() - setAttachments([]) - onSuccess?.() - } else { - toast.error(result.message || "벤더 추가 중 오류가 발생했습니다") - } - } catch (error) { - console.error("벤더 추가 오류:", error) - toast.error("벤더 추가 중 오류가 발생했습니다") - } finally { - setIsSubmitting(false) - } - } - - // 파일 업로드 핸들러 - const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.files && event.target.files.length > 0) { - const newFiles = Array.from(event.target.files) - setAttachments((prev) => [...prev, ...newFiles]) - } - } - - // 파일 삭제 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments((prev) => prev.filter((_, i) => i !== index)) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} - <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}> - {/* 고정 헤더 */} - <div className="p-6 border-b"> - <DialogHeader> - <DialogTitle>벤더 추가</DialogTitle> - <DialogDescription> - {selectedRfq ? ( - <> - <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. - </> - ) : ( - "RFQ에 벤더를 추가합니다." - )} - </DialogDescription> - </DialogHeader> - </div> - - {/* 스크롤 가능한 콘텐츠 영역 */} - <div className="flex-1 overflow-y-auto p-6"> - <Form {...form}> - <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 검색 가능한 벤더 선택 필드 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <RequiredLabel>벤더</RequiredLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - > - {field.value - ? vendors.find((vendor) => String(vendor.id) === field.value) - ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` - : "벤더를 선택하세요" - : "벤더를 선택하세요"} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - <CommandList> - <ScrollArea className="h-60"> - <CommandGroup> - {availableVendors.length > 0 ? ( - availableVendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - String(vendor.id) === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {vendor.vendorName} ({vendor.vendorCode}) - </CommandItem> - )) - ) : ( - <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem> - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <RequiredLabel>통화</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.name} ({currency.code}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <RequiredLabel>지불 조건</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <RequiredLabel>인코텀즈</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((incoterm) => ( - <SelectItem key={incoterm.code} value={incoterm.code}> - {incoterm.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* 나머지 필드들은 동일하게 유지 */} - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 세부사항</FormLabel> - <FormControl> - <Input {...field} placeholder="인코텀즈 세부사항" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="deliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel>납품 예정일</FormLabel> - <FormControl> - <Input {...field} type="date" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="taxCode" - render={({ field }) => ( - <FormItem> - <FormLabel>세금 코드</FormLabel> - <FormControl> - <Input {...field} placeholder="세금 코드" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="placeOfShipping" - render={({ field }) => ( - <FormItem> - <FormLabel>선적지</FormLabel> - <FormControl> - <Input {...field} placeholder="선적지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="placeOfDestination" - render={({ field }) => ( - <FormItem> - <FormLabel>도착지</FormLabel> - <FormControl> - <Input {...field} placeholder="도착지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="materialPriceRelatedYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <input - type="checkbox" - checked={field.value} - onChange={field.onChange} - className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>하도급대금 연동제 여부</FormLabel> - </div> - </FormItem> - )} - /> - - {/* 파일 업로드 섹션 */} - <div className="space-y-2"> - <Label>첨부 파일</Label> - <div className="border rounded-md p-4"> - <div className="flex items-center justify-center w-full"> - <label - htmlFor="file-upload" - className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" - > - <div className="flex flex-col items-center justify-center pt-5 pb-6"> - <Upload className="w-8 h-8 mb-2 text-gray-500" /> - <p className="mb-2 text-sm text-gray-500"> - <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요 - </p> - <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p> - </div> - <input - id="file-upload" - type="file" - className="hidden" - multiple - onChange={handleFileUpload} - /> - </label> - </div> - - {/* 업로드된 파일 목록 */} - {attachments.length > 0 && ( - <div className="mt-4 space-y-2"> - <h4 className="text-sm font-medium">업로드된 파일</h4> - <ul className="space-y-2"> - {attachments.map((file, index) => ( - <li - key={index} - className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md" - > - <div className="flex items-center space-x-2"> - <File className="w-4 h-4 text-gray-500" /> - <span className="truncate max-w-[250px]">{file.name}</span> - <span className="text-gray-500 text-xs"> - ({(file.size / 1024).toFixed(1)} KB) - </span> - </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => handleRemoveFile(index)} - > - <X className="w-4 h-4 text-gray-500" /> - </Button> - </li> - ))} - </ul> - </div> - )} - </div> - </div> - </form> - </Form> - </div> - - {/* 고정 푸터 */} - <div className="p-6 border-t"> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - form="vendor-form" - disabled={isSubmitting} - > - {isSubmitting ? "처리 중..." : "벤더 추가"} - </Button> - </DialogFooter> - </div> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx deleted file mode 100644 index 49d982e1..00000000 --- a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client" - -import * as React from "react" -import { type RfqDetailView } from "./rfq-detail-column" -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 { deleteRfqDetail } from "@/lib/procurement-rfqs/services" - - -interface DeleteRfqDetailDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - detail: RfqDetailView | null - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteRfqDetailDialog({ - detail, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqDetailDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - if (!detail) return - - startDeleteTransition(async () => { - try { - const result = await deleteRfqDetail(detail.detailId) - - if (!result.success) { - toast.error(result.message || "삭제 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 삭제되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 삭제 오류:", error) - toast.error("삭제 중 오류가 발생했습니다") - } - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <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/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx deleted file mode 100644 index bc257202..00000000 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx +++ /dev/null @@ -1,393 +0,0 @@ -"use client" - -import * as React from "react" -import type { ColumnDef, Row } from "@tanstack/react-table"; -import { formatDate, formatDateTime } from "@/lib/utils" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Ellipsis, MessageCircle, ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; - -export interface DataTableRowAction<TData> { - row: Row<TData>; - type: "delete" | "update" | "communicate"; // communicate 타입 추가 -} - -// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요) -export interface RfqDetailView { - detailId: number - rfqId: number - rfqCode: string - vendorId?: number | null // 벤더 ID 필드 추가 - projectCode: string | null - projectName: string | null - vendorCountry: string | null - itemCode: string | null - itemName: string | null - vendorName: string | null - vendorCode: string | null - currency: string | null - paymentTermsCode: string | null - paymentTermsDescription: string | null - incotermsCode: string | null - incotermsDescription: string | null - incotermsDetail: string | null - deliveryDate: Date | null - taxCode: string | null - placeOfShipping: string | null - placeOfDestination: string | null - materialPriceRelatedYn: boolean | null - hasQuotation: boolean | null - updatedByUserName: string | null - quotationStatus: string | null - updatedAt: Date | null - prItemsCount: number - majorItemsCount: number - quotationVersion:number | null - // 커뮤니케이션 관련 필드 추가 - commentCount?: number // 전체 코멘트 수 - unreadCount?: number // 읽지 않은 코멘트 수 - lastCommentDate?: Date // 마지막 코멘트 날짜 -} - -interface GetColumnsProps<TData> { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<TData> | null> - >; - unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수 -} - -export function getRfqDetailColumns({ - setRowAction, - unreadMessages = {}, -}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { - return [ - { - accessorKey: "quotationStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 상태" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>, - meta: { - excelHeader: "견적 상태" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "quotationVersion", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 버전" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>, - meta: { - excelHeader: "견적 버전" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>, - meta: { - excelHeader: "벤더 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더명" /> - ), - cell: ({ row }) => { - const vendorName = row.getValue("vendorName") as string; - const vendorId = row.original.vendorId; - - if (!vendorName || !vendorId) { - return <div>{vendorName}</div>; - } - - const handleVendorClick = () => { - window.open(`/evcp/vendors/${vendorId}/info`, '_blank'); - }; - - return ( - <Button - variant="link" - className="h-auto p-0 text-left justify-start font-normal text-foreground underline-offset-4 hover:underline" - onClick={handleVendorClick} - > - <span className="flex items-center gap-1"> - {vendorName} - {/* <ExternalLink className="h-3 w-3 opacity-50" /> */} - </span> - </Button> - ); - }, - meta: { - excelHeader: "벤더명" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "vendorType", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="내외자" /> - ), - cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>, - meta: { - excelHeader: "내외자" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="통화" /> - ), - cell: ({ row }) => <div>{row.getValue("currency")}</div>, - meta: { - excelHeader: "통화" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "paymentTermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>, - meta: { - excelHeader: "지불 조건 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "paymentTermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="지불 조건" /> - ), - cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>, - meta: { - excelHeader: "지불 조건" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>, - meta: { - excelHeader: "인코텀스 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "incotermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>, - meta: { - excelHeader: "인코텀스" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsDetail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>, - meta: { - excelHeader: "인코텀스 상세" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "deliveryDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="납품일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "납품일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "taxCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="세금 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("taxCode")}</div>, - meta: { - excelHeader: "세금 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "placeOfShipping", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="선적지" /> - ), - cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>, - meta: { - excelHeader: "선적지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "placeOfDestination", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="도착지" /> - ), - cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>, - meta: { - excelHeader: "도착지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "materialPriceRelatedYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" /> - ), - cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>, - meta: { - excelHeader: "하도급대금 연동" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "updatedByUserName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정자" /> - ), - cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>, - meta: { - excelHeader: "수정자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정일시" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDateTime(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "수정일시" - }, - enableResizing: true, - size: 140, - }, - // 커뮤니케이션 컬럼 추가 - { - id: "communication", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" /> - ), - cell: ({ row }) => { - const vendorId = row.original.vendorId || 0; - const unreadCount = unreadMessages[vendorId] || 0; - - return ( - <Button - variant="ghost" - size="sm" - className="relative p-0 h-8 w-8 flex items-center justify-center" - onClick={() => setRowAction({ row, type: "communicate" })} - > - <MessageCircle className="h-4 w-4" /> - {unreadCount > 0 && ( - <Badge - variant="destructive" - className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs" - > - {unreadCount} - </Badge> - )} - </Button> - ); - }, - enableResizing: false, - size: 80, - }, - { - 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-7 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, - } - ] -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx deleted file mode 100644 index ad9a19e7..00000000 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx +++ /dev/null @@ -1,521 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - DataTableRowAction, - getRfqDetailColumns, - RfqDetailView -} from "./rfq-detail-column" -import { toast } from "sonner" - -import { Skeleton } from "@/components/ui/skeleton" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { ProcurementRfqsView } from "@/db/schema" -import { - fetchCurrencies, - fetchIncoterms, - fetchPaymentTerms, - fetchRfqDetails, - fetchVendors, - fetchUnreadMessages -} from "@/lib/procurement-rfqs/services" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { AddVendorDialog } from "./add-vendor-dialog" -import { Button } from "@/components/ui/button" -import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가 -import { DeleteRfqDetailDialog } from "./delete-vendor-dialog" -import { UpdateRfqDetailSheet } from "./update-vendor-sheet" -import { VendorCommunicationDrawer } from "./vendor-communication-drawer" -import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트 - -// 프로퍼티 정의 -interface RfqDetailTablesProps { - selectedRfq: ProcurementRfqsView | null - maxHeight?: string | number -} - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string | null; // Update this to allow null - // 기타 필요한 벤더 속성들 -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) { - - console.log("selectedRfq", selectedRfq) - // 상태 관리 - const [isLoading, setIsLoading] = useState(false) - const [isRefreshing, setIsRefreshing] = useState(false) - const [details, setDetails] = useState<RfqDetailView[]>([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null) - - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [currencies, setCurrencies] = React.useState<Currency[]>([]) - const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([]) - const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) - const [isUnreadLoading, setIsUnreadLoading] = useState(false) - - // 견적 비교 다이얼로그 상태 관리 (추가) - const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) - - const existingVendorIds = React.useMemo(() => { - return details.map(detail => Number(detail.vendorId)).filter(Boolean); - }, [details]); - - const handleAddVendor = async () => { - try { - setIsAdddialogLoading(true) - - // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - fetchVendors(), - fetchCurrencies(), - fetchPaymentTerms(), - fetchIncoterms() - ]) - - setVendors(vendorsData.data || []) - setCurrencies(currenciesData.data || []) - setPaymentTerms(paymentTermsData.data || []) - setIncoterms(incotermsData.data || []) - - setVendorDialogOpen(true) - } catch (error) { - console.error("데이터 로드 오류:", error) - toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsAdddialogLoading(false) - } - } - - // 견적 비교 다이얼로그 열기 핸들러 (추가) - const handleOpenComparisonDialog = () => { - // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 - const hasSubmittedQuotations = details.some(detail => - detail.hasQuotation && detail.quotationStatus === "Submitted" - ); - - if (!hasSubmittedQuotations) { - toast.warning("제출된 견적이 없습니다."); - return; - } - - setComparisonDialogOpen(true); - } - - // 읽지 않은 메시지 로드 - const loadUnreadMessages = async () => { - if (!selectedRfq || !selectedRfq.id) return; - - try { - setIsUnreadLoading(true); - - // 읽지 않은 메시지 수 가져오기 - const unreadData = await fetchUnreadMessages(selectedRfq.id); - setUnreadMessages(unreadData); - } catch (error) { - console.error("읽지 않은 메시지 로드 오류:", error); - // 조용히 실패 - 사용자에게 알림 표시하지 않음 - } finally { - setIsUnreadLoading(false); - } - }; - - // 칼럼 정의 - unreadMessages 상태 전달 - const columns = React.useMemo(() => - getRfqDetailColumns({ - setRowAction, - unreadMessages - }), [unreadMessages]) - - // 필터 필드 정의 (필터 사용 시) - const advancedFilterFields = React.useMemo( - () => [ - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "text", - }, - ], - [] - ) - - // RFQ ID가 변경될 때 데이터 로드 - useEffect(() => { - async function loadRfqDetails() { - if (!selectedRfq || !selectedRfq.id) { - setDetails([]) - return - } - - try { - setIsLoading(true) - const transformRfqDetails = (data: any[]): RfqDetailView[] => { - return data.map(item => ({ - ...item, - // Convert vendorId from string|null to number|undefined - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // Transform any other fields that need type conversion - })); - }; - - // Then in your useEffect: - const result = await fetchRfqDetails(selectedRfq.id); - setDetails(transformRfqDetails(result.data)); - - // 읽지 않은 메시지 개수 로드 - await loadUnreadMessages(); - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - setDetails([]) - toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsLoading(false) - } - } - - loadRfqDetails() - }, [selectedRfq]) - - // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - useEffect(() => { - if (!selectedRfq || !selectedRfq.id) return; - - const intervalId = setInterval(() => { - loadUnreadMessages(); - }, 60000); // 60초마다 갱신 - - return () => clearInterval(intervalId); - }, [selectedRfq]); - - // rowAction 처리 - useEffect(() => { - if (!rowAction) return - - const handleRowAction = async () => { - try { - // 통신 액션인 경우 드로어 열기 - if (rowAction.type === "communicate") { - setSelectedVendor(rowAction.row.original); - setCommunicationDrawerOpen(true); - - // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) - const vendorId = rowAction.row.original.vendorId; - if (vendorId) { - setUnreadMessages(prev => ({ - ...prev, - [vendorId]: 0 - })); - } - - // rowAction 초기화 - setRowAction(null); - return; - } - - // 다른 액션들은 기존과 동일하게 처리 - setIsAdddialogLoading(true); - - // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - fetchVendors(), - fetchCurrencies(), - fetchPaymentTerms(), - fetchIncoterms() - ]); - - setVendors(vendorsData.data || []); - setCurrencies(currenciesData.data || []); - setPaymentTerms(paymentTermsData.data || []); - setIncoterms(incotermsData.data || []); - - // 이제 데이터가 로드되었으므로 필요한 작업 수행 - if (rowAction.type === "update") { - setSelectedDetail(rowAction.row.original); - setUpdateSheetOpen(true); - } else if (rowAction.type === "delete") { - setSelectedDetail(rowAction.row.original); - setDeleteDialogOpen(true); - } - } catch (error) { - console.error("데이터 로드 오류:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다"); - } finally { - // communicate 타입이 아닌 경우에만 로딩 상태 변경 - if (rowAction && rowAction.type !== "communicate") { - setIsAdddialogLoading(false); - } - } - }; - - handleRowAction(); - }, [rowAction]) - - // RFQ가 선택되지 않은 경우 - if (!selectedRfq) { - return ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - RFQ를 선택하세요 - </div> - ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( - <div className="p-4 space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-24 w-full" /> - <Skeleton className="h-48 w-full" /> - </div> - ) - } - - const handleRefreshData = async () => { - if (!selectedRfq || !selectedRfq.id) return - - try { - setIsRefreshing(true) - - const transformRfqDetails = (data: any[]): RfqDetailView[] => { - return data.map(item => ({ - ...item, - // Convert vendorId from string|null to number|undefined - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // Transform any other fields that need type conversion - })); - }; - - // Then in your useEffect: - const result = await fetchRfqDetails(selectedRfq.id); - setDetails(transformRfqDetails(result.data)); - - // 읽지 않은 메시지 개수 업데이트 - await loadUnreadMessages(); - - toast.success("데이터가 새로고침되었습니다") - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - toast.error("데이터 새로고침 중 오류가 발생했습니다") - } finally { - setIsRefreshing(false) - } - } - - // 전체 읽지 않은 메시지 수 계산 - const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0); - - // 견적이 있는 벤더 수 계산 - const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length; - - return ( - <div className="h-full overflow-hidden pt-4"> - - {/* 메시지 및 새로고침 영역 */} - - - {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - - <ClientDataTable - columns={columns} - data={details} - advancedFilterFields={advancedFilterFields} - maxHeight={maxHeight} - > - - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2 mr-2"> - {totalUnreadMessages > 0 && ( - <Badge variant="destructive" className="h-6"> - 읽지 않은 메시지: {totalUnreadMessages}건 - </Badge> - )} - {vendorsWithQuotations > 0 && ( - <Badge variant="outline" className="h-6"> - 견적 제출: {vendorsWithQuotations}개 벤더 - </Badge> - )} - </div> - <div className="flex gap-2"> - {/* 견적 비교 버튼 추가 */} - <Button - variant="outline" - size="sm" - onClick={handleOpenComparisonDialog} - className="gap-2" - disabled={ - !selectedRfq || - details.length === 0 || - (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate)) - } - > - <BarChart2 className="size-4" aria-hidden="true" /> - <span>견적 비교</span> - </Button> - <Button - variant="outline" - size="sm" - onClick={handleRefreshData} - disabled={isRefreshing} - > - {isRefreshing ? ( - <> - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - 새로고침 중... - </> - ) : ( - '새로고침' - )} - </Button> - </div> - </div> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - className="gap-2" - disabled={!selectedRfq || isAdddialogLoading} - > - {isAdddialogLoading ? ( - <> - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - <span>로딩 중...</span> - </> - ) : ( - <> - <UserPlus className="size-4" aria-hidden="true" /> - <span>벤더 추가</span> - </> - )} - </Button> - </ClientDataTable> - - ) : ( - <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4"> - <div className="flex flex-col items-center gap-4"> - <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - className="gap-2" - disabled={!selectedRfq || isAdddialogLoading} - > - {isAdddialogLoading ? ( - <> - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - <span>로딩 중...</span> - </> - ) : ( - <> - <UserPlus className="size-4" aria-hidden="true" /> - <span>협력업체 추가</span> - </> - )} - </Button> - </div> - </div> - )} - - {/* 벤더 추가 다이얼로그 */} - <AddVendorDialog - open={vendorDialogOpen} - onOpenChange={(open) => { - setVendorDialogOpen(open); - if (!open) setIsAdddialogLoading(false); - }} - selectedRfq={selectedRfq} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - existingVendorIds={existingVendorIds} - /> - - {/* 벤더 정보 수정 시트 */} - <UpdateRfqDetailSheet - open={updateSheetOpen} - onOpenChange={setUpdateSheetOpen} - detail={selectedDetail} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - /> - - {/* 벤더 정보 삭제 다이얼로그 */} - <DeleteRfqDetailDialog - open={deleteDialogOpen} - onOpenChange={setDeleteDialogOpen} - detail={selectedDetail} - showTrigger={false} - onSuccess={handleRefreshData} - /> - - {/* 벤더 커뮤니케이션 드로어 */} - <VendorCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={(open) => { - setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 - if (!open) loadUnreadMessages(); - }} - selectedRfq={selectedRfq} - selectedVendor={selectedVendor} - onSuccess={handleRefreshData} - /> - - {/* 견적 비교 다이얼로그 추가 */} - <VendorQuotationComparisonDialog - open={comparisonDialogOpen} - onOpenChange={setComparisonDialogOpen} - selectedRfq={selectedRfq} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx deleted file mode 100644 index edc04788..00000000 --- a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx +++ /dev/null @@ -1,449 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Check, ChevronsUpDown, Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Checkbox } from "@/components/ui/checkbox" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { RfqDetailView } from "./rfq-detail-column" -import { updateRfqDetail } from "@/lib/procurement-rfqs/services" - -// 폼 유효성 검증 스키마 -const updateRfqDetailSchema = z.object({ - vendorId: z.string().min(1, "벤더를 선택해주세요"), - currency: z.string().min(1, "통화를 선택해주세요"), - paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), - incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), - incotermsDetail: z.string().optional(), - deliveryDate: z.string().optional(), - taxCode: z.string().optional(), - placeOfShipping: z.string().optional(), - placeOfDestination: z.string().optional(), - materialPriceRelatedYn: z.boolean().default(false), -}) - -type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema> - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string; -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -interface UpdateRfqDetailSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - detail: RfqDetailView | null; - vendors: Vendor[]; - currencies: Currency[]; - paymentTerms: PaymentTerm[]; - incoterms: Incoterm[]; - onSuccess?: () => void; -} - -export function UpdateRfqDetailSheet({ - detail, - vendors, - currencies, - paymentTerms, - incoterms, - onSuccess, - ...props -}: UpdateRfqDetailSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [vendorOpen, setVendorOpen] = React.useState(false) - - const form = useForm<UpdateRfqDetailFormValues>({ - resolver: zodResolver(updateRfqDetailSchema), - defaultValues: { - vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", - currency: detail?.currency || "", - paymentTermsCode: detail?.paymentTermsCode || "", - incotermsCode: detail?.incotermsCode || "", - incotermsDetail: detail?.incotermsDetail || "", - deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail?.taxCode || "", - placeOfShipping: detail?.placeOfShipping || "", - placeOfDestination: detail?.placeOfDestination || "", - materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, - }, - }) - - // detail이 변경될 때 form 값 업데이트 - React.useEffect(() => { - if (detail) { - const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id - - form.reset({ - vendorId: vendorId ? String(vendorId) : "", - currency: detail.currency || "", - paymentTermsCode: detail.paymentTermsCode || "", - incotermsCode: detail.incotermsCode || "", - incotermsDetail: detail.incotermsDetail || "", - deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail.taxCode || "", - placeOfShipping: detail.placeOfShipping || "", - placeOfDestination: detail.placeOfDestination || "", - materialPriceRelatedYn: detail.materialPriceRelatedYn || false, - }) - } - }, [detail, form, vendors]) - - function onSubmit(values: UpdateRfqDetailFormValues) { - if (!detail) return - - startUpdateTransition(async () => { - try { - const result = await updateRfqDetail(detail.detailId, values) - - if (!result.success) { - toast.error(result.message || "수정 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 수정되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 수정 오류:", error) - toast.error("수정 중 오류가 발생했습니다") - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl"> - <SheetHeader className="text-left"> - <SheetTitle>RFQ 벤더 정보 수정</SheetTitle> - <SheetDescription> - 벤더 정보를 수정하고 저장하세요 - </SheetDescription> - </SheetHeader> - <ScrollArea className="flex-1 pr-4"> - <Form {...form}> - <form - id="update-rfq-detail-form" - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - > - {/* 검색 가능한 벤더 선택 필드 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - > - {field.value - ? vendors.find((vendor) => String(vendor.id) === field.value) - ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` - : "벤더를 선택하세요" - : "벤더를 선택하세요"} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - <ScrollArea className="h-60"> - <CommandGroup> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - String(vendor.id) === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {vendor.vendorName} ({vendor.vendorCode}) - </CommandItem> - ))} - </CommandGroup> - </ScrollArea> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>통화 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.name} ({currency.code}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((incoterm) => ( - <SelectItem key={incoterm.code} value={incoterm.code}> - {incoterm.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 세부사항</FormLabel> - <FormControl> - <Input {...field} placeholder="인코텀즈 세부사항" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="deliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel>납품 예정일</FormLabel> - <FormControl> - <Input {...field} type="date" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="taxCode" - render={({ field }) => ( - <FormItem> - <FormLabel>세금 코드</FormLabel> - <FormControl> - <Input {...field} placeholder="세금 코드" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="placeOfShipping" - render={({ field }) => ( - <FormItem> - <FormLabel>선적지</FormLabel> - <FormControl> - <Input {...field} placeholder="선적지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="placeOfDestination" - render={({ field }) => ( - <FormItem> - <FormLabel>도착지</FormLabel> - <FormControl> - <Input {...field} placeholder="도착지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="materialPriceRelatedYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>하도급 대금 연동 여부</FormLabel> - </div> - </FormItem> - )} - /> - </form> - </Form> - </ScrollArea> - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 취소 - </Button> - </SheetClose> - <Button - type="submit" - form="update-rfq-detail-form" - disabled={isUpdatePending} - > - {isUpdatePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 저장 - </Button> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx deleted file mode 100644 index e43fc676..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx +++ /dev/null @@ -1,518 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { ProcurementRfqsView } from "@/db/schema" -import { RfqDetailView } from "./rfq-detail-column" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Badge } from "@/components/ui/badge" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X -} from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime } from "@/lib/utils" -import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 -import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services" - -// 타입 정의 -interface Comment { - id: number; - rfqId: number; - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string; - isVendorComment: boolean | null; // null 허용으로 변경 - createdAt: Date; - updatedAt: Date; - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[]; - isRead: boolean | null // null 허용으로 변경 -} - -interface Attachment { - id: number; - fileName: string; - fileSize: number; - fileType: string; - filePath: string; - uploadedAt: Date; -} - -// 프롭스 정의 -interface VendorCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedRfq: ProcurementRfqsView | null; - selectedVendor: RfqDetailView | null; - onSuccess?: () => void; -} - -async function sendComment(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<Comment> { - try { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'false'); - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - // API 호출 - const response = await fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API 요청 실패: ${response.status} ${errorText}`); - } - - // 응답 데이터 파싱 - const result = await response.json(); - - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - - return result.data.comment; - } catch (error) { - console.error('코멘트 전송 오류:', error); - throw error; - } -} - -export function VendorCommunicationDrawer({ - open, - onOpenChange, - selectedRfq, - selectedVendor, - onSuccess -}: VendorCommunicationDrawerProps) { - // 상태 관리 - const [comments, setComments] = useState<Comment[]>([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState<File[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - const messagesEndRef = useRef<HTMLDivElement>(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); - - // 드로어가 열릴 때 데이터 로드 - useEffect(() => { - if (open && selectedRfq && selectedVendor) { - loadComments(); - } - }, [open, selectedRfq, selectedVendor]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 코멘트 로드 함수 - const loadComments = async () => { - if (!selectedRfq || !selectedVendor) return; - - try { - setIsLoading(true); - - // Server Action을 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId); - setComments(commentsData); - - // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 - await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId); - } catch (error) { - console.error("코멘트 로드 오류:", error); - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); - } finally { - setIsLoading(false); - } - }; - - // 파일 선택 핸들러 - const handleFileSelect = () => { - fileInputRef.current?.click(); - }; - - // 파일 변경 핸들러 - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files && e.target.files.length > 0) { - const newFiles = Array.from(e.target.files); - setAttachments(prev => [...prev, ...newFiles]); - } - }; - - // 파일 제거 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)); - }; - - console.log(newComment) - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) - console.log(!newComment.trim() && attachments.length === 0) - - if (!newComment.trim() && attachments.length === 0) return; - if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; - - console.log("버튼 클릭") - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendComment({ - rfqId: selectedRfq.id, - vendorId: selectedVendor.vendorId, - content: newComment, - attachments: attachments - }); - - // 상태 업데이트 - setComments(prev => [...prev, newCommentObj]); - setNewComment(""); - setAttachments([]); - - toast.success("메시지가 전송되었습니다"); - - // 데이터 새로고침 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("코멘트 전송 오류:", error); - toast.error("메시지 전송 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - }; - - // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: Attachment) => { - setSelectedAttachment(attachment); - setPreviewDialogOpen(true); - }; - - // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: Attachment) => { - // TODO: 실제 다운로드 구현 - window.open(attachment.filePath, '_blank'); - }; - - // 파일 아이콘 선택 - const getFileIcon = (fileType: string) => { - if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; - if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return <FileText className="h-5 w-5 text-green-500" />; - if (fileType.includes("document") || fileType.includes("word")) - return <FileText className="h-5 w-5 text-blue-500" />; - return <File className="h-5 w-5 text-gray-500" />; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); - - return ( - <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {getFileIcon(selectedAttachment.fileType)} - {selectedAttachment.fileName} - </DialogTitle> - <DialogDescription> - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")} - </DialogDescription> - </DialogHeader> - - <div className="min-h-[300px] flex items-center justify-center p-4"> - {isImage ? ( - <img - src={selectedAttachment.filePath} - alt={selectedAttachment.fileName} - className="max-h-[500px] max-w-full object-contain" - /> - ) : isPdf ? ( - <iframe - src={`${selectedAttachment.filePath}#toolbar=0`} - className="w-full h-[500px]" - title={selectedAttachment.fileName} - /> - ) : ( - <div className="flex flex-col items-center gap-4 p-8"> - {getFileIcon(selectedAttachment.fileType)} - <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> - <Button - variant="outline" - onClick={() => handleAttachmentDownload(selectedAttachment)} - > - <DownloadCloud className="h-4 w-4 mr-2" /> - 다운로드 - </Button> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); - }; - - if (!selectedRfq || !selectedVendor) { - return null; - } - - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[85vh]"> - <DrawerHeader className="border-b"> - <DrawerTitle className="flex items-center gap-2"> - <Avatar className="h-8 w-8"> - <AvatarFallback className="bg-primary/10"> - {selectedVendor.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - <div> - <span>{selectedVendor.vendorName}</span> - <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName} - </DrawerDescription> - </DrawerHeader> - - <div className="p-0 flex flex-col h-[60vh]"> - {/* 메시지 목록 */} - <ScrollArea className="flex-1 p-4"> - {isLoading ? ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground">메시지 로딩 중...</p> - </div> - ) : comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <AlertCircle className="h-6 w-6 text-muted-foreground" /> - <p className="text-muted-foreground">아직 메시지가 없습니다</p> - </div> - </div> - ) : ( - <div className="space-y-4"> - {comments.map(comment => ( - <div - key={comment.id} - className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`} - > - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - {comment.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${ - comment.isVendorComment - ? 'bg-muted' - : 'bg-primary text-primary-foreground' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? comment.vendorName : comment.userName} - </div> - - {comment.content && ( - <div className="text-sm whitespace-pre-wrap break-words"> - {comment.content} - </div> - )} - - {/* 첨부파일 표시 */} - {comment.attachments.length > 0 && ( - <div className={`mt-2 pt-2 ${ - comment.isVendorComment - ? 'border-t border-t-border/30' - : 'border-t border-t-primary-foreground/20' - }`}> - {comment.attachments.map(attachment => ( - <div - key={attachment.id} - className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" - onClick={() => handleAttachmentPreview(attachment)} - > - {getFileIcon(attachment.fileType)} - <span className="flex-1 truncate">{attachment.fileName}</span> - <span className="text-xs opacity-70"> - {formatFileSize(attachment.fileSize)} - </span> - <Button - variant="ghost" - size="icon" - className="h-6 w-6 rounded-full" - onClick={(e) => { - e.stopPropagation(); - handleAttachmentDownload(attachment); - }} - > - <DownloadCloud className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - )} - - <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> - {formatDateTime(comment.createdAt, "KR")} - </div> - </div> - - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - {comment.userName?.[0] || 'U'} - </AvatarFallback> - </Avatar> - )} - </div> - ))} - <div ref={messagesEndRef} /> - </div> - )} - </ScrollArea> - - {/* 선택된 첨부파일 표시 */} - {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2"> - <div className="text-xs font-medium mb-1">첨부파일</div> - <div className="flex flex-wrap gap-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> - {file.type.startsWith("image/") ? ( - <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> - ) : ( - <File className="h-4 w-4 mr-1 text-gray-500" /> - )} - <span className="truncate max-w-[100px]">{file.name}</span> - <Button - variant="ghost" - size="icon" - className="h-4 w-4 ml-1 p-0" - onClick={() => handleRemoveFile(index)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 메시지 입력 영역 */} - <div className="p-4 border-t"> - <div className="flex gap-2 items-end"> - <div className="flex-1"> - <Textarea - placeholder="메시지를 입력하세요..." - className="min-h-[80px] resize-none" - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - </div> - <div className="flex flex-col gap-2"> - <input - type="file" - ref={fileInputRef} - className="hidden" - multiple - onChange={handleFileChange} - /> - <Button - variant="outline" - size="icon" - onClick={handleFileSelect} - title="파일 첨부" - > - <Paperclip className="h-4 w-4" /> - </Button> - <Button - onClick={handleSubmitComment} - disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - </div> - - <DrawerFooter className="border-t"> - <div className="flex justify-between"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> - <DrawerClose asChild> - <Button variant="outline">닫기</Button> - </DrawerClose> - </div> - </DrawerFooter> - </DrawerContent> - - {renderAttachmentPreviewDialog()} - </Drawer> - ); -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx deleted file mode 100644 index 72cf187c..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { toast } from "sonner" - -// Lucide 아이콘 -import { Plus, Minus } from "lucide-react" - -import { ProcurementRfqsView } from "@/db/schema" -import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services" -import { formatCurrency, formatDate } from "@/lib/utils" - -// 견적 정보 타입 -interface VendorQuotation { - id: number - rfqId: number - vendorId: number - vendorName?: string | null - quotationCode: string - quotationVersion: number - totalItemsCount: number - subTotal: string - taxTotal: string - discountTotal: string - totalPrice: string - currency: string - validUntil: string | Date // 수정: string | Date 허용 - estimatedDeliveryDate: string | Date // 수정: string | Date 허용 - paymentTermsCode: string - paymentTermsDescription?: string | null - incotermsCode: string - incotermsDescription?: string | null - incotermsDetail: string - status: string - remark: string - rejectionReason: string - submittedAt: string | Date // 수정: string | Date 허용 - acceptedAt: string | Date // 수정: string | Date 허용 - createdAt: string | Date // 수정: string | Date 허용 - updatedAt: string | Date // 수정: string | Date 허용 -} - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null // Changed from string to string | null - materialDescription: string | null // Changed from string to string | null - quantity: string - uom: string | null // Changed assuming this might be null - unitPrice: string - totalPrice: string - currency: string | null // Changed from string to string | null - vendorMaterialCode: string | null // Changed from string to string | null - vendorMaterialDescription: string | null // Changed from string to string | null - deliveryDate: Date | null // Changed from string to string | null - leadTimeInDays: number | null // Changed from number to number | null - taxRate: string | null // Changed from string to string | null - taxAmount: string | null // Changed from string to string | null - discountRate: string | null // Changed from string to string | null - discountAmount: string | null // Changed from string to string | null - remark: string | null // Changed from string to string | null - isAlternative: boolean | null // Changed from boolean to boolean | null - isRecommended: boolean | null // Changed from boolean to boolean | null -} - -interface VendorQuotationComparisonDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: ProcurementRfqsView | null -} - -export function VendorQuotationComparisonDialog({ - open, - onOpenChange, - selectedRfq, -}: VendorQuotationComparisonDialogProps) { - const [isLoading, setIsLoading] = useState(false) - const [quotations, setQuotations] = useState<VendorQuotation[]>([]) - const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({}) - const [activeTab, setActiveTab] = useState("summary") - - // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘 - const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({}) - - useEffect(() => { - async function loadQuotationData() { - if (!open || !selectedRfq?.id) return - - try { - setIsLoading(true) - // 1) 견적 목록 - const quotationsResult = await fetchVendorQuotations(selectedRfq.id) - const rawQuotationsData = quotationsResult.data || [] - - const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({ - id: rawData.id, - rfqId: rawData.rfqId, - vendorId: rawData.vendorId, - vendorName: rawData.vendorName || null, - quotationCode: rawData.quotationCode || '', - quotationVersion: rawData.quotationVersion || 0, - totalItemsCount: rawData.totalItemsCount || 0, - subTotal: rawData.subTotal || '0', - taxTotal: rawData.taxTotal || '0', - discountTotal: rawData.discountTotal || '0', - totalPrice: rawData.totalPrice || '0', - currency: rawData.currency || 'KRW', - validUntil: rawData.validUntil || '', - estimatedDeliveryDate: rawData.estimatedDeliveryDate || '', - paymentTermsCode: rawData.paymentTermsCode || '', - paymentTermsDescription: rawData.paymentTermsDescription || null, - incotermsCode: rawData.incotermsCode || '', - incotermsDescription: rawData.incotermsDescription || null, - incotermsDetail: rawData.incotermsDetail || '', - status: rawData.status || '', - remark: rawData.remark || '', - rejectionReason: rawData.rejectionReason || '', - submittedAt: rawData.submittedAt || '', - acceptedAt: rawData.acceptedAt || '', - createdAt: rawData.createdAt || '', - updatedAt: rawData.updatedAt || '', - })); - - setQuotations(quotationsData); - - // 벤더별로 접힘 상태 기본값(true) 설정 - const collapsedInit: Record<number, boolean> = {} - quotationsData.forEach((q) => { - collapsedInit[q.id] = true - }) - setCollapsedVendors(collapsedInit) - - // 2) 견적 아이템 - const qIds = quotationsData.map((q) => q.id) - if (qIds.length > 0) { - const itemsResult = await fetchQuotationItems(qIds) - const itemsData = itemsResult.data || [] - - const itemsByQuotation: Record<number, QuotationItem[]> = {} - itemsData.forEach((item) => { - if (!itemsByQuotation[item.quotationId]) { - itemsByQuotation[item.quotationId] = [] - } - itemsByQuotation[item.quotationId].push(item) - }) - setQuotationItems(itemsByQuotation) - } - } catch (error) { - console.error("견적 데이터 로드 오류:", error) - toast.error("견적 데이터를 불러오는 데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadQuotationData() - }, [open, selectedRfq]) - - // 견적 상태 -> 뱃지 색 - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Submitted": - return "default" - case "Accepted": - return "default" - case "Rejected": - return "destructive" - case "Revised": - return "destructive" - default: - return "secondary" - } - } - - // 모든 prItemId 모음 - const allItemIds = React.useMemo(() => { - const itemSet = new Set<number>() - Object.values(quotationItems).forEach((items) => { - items.forEach((it) => itemSet.add(it.prItemId)) - }) - return Array.from(itemSet) - }, [quotationItems]) - - // 아이템 찾는 함수 - const findItemByQuotationId = (prItemId: number, qid: number) => { - const items = quotationItems[qid] || [] - return items.find((i) => i.prItemId === prItemId) - } - - // 접힘 상태 토글 - const toggleVendor = (qid: number) => { - setCollapsedVendors((prev) => ({ - ...prev, - [qid]: !prev[qid], - })) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */} - <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}> - <DialogHeader> - <DialogTitle>벤더 견적 비교</DialogTitle> - <DialogDescription> - {selectedRfq - ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}` - : ""} - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-48 w-full" /> - </div> - ) : quotations.length === 0 ? ( - <div className="py-8 text-center text-muted-foreground"> - 제출된(Submitted) 견적이 없습니다 - </div> - ) : ( - <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="summary">견적 요약 비교</TabsTrigger> - <TabsTrigger value="items">아이템별 비교</TabsTrigger> - </TabsList> - - {/* ======================== 요약 비교 탭 ======================== */} - <TabsContent value="summary" className="mt-4"> - {/* - table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px]) - -> 컨테이너보다 넓으면 수평 스크롤 발생. - */} - <div className="border rounded-md max-h-[60vh] overflow-auto"> - <table className="table-fixed w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - <TableRow> - <TableHead - className="sticky left-0 top-0 z-20 bg-background p-2" - > - 항목 - </TableHead> - {quotations.map((q) => ( - <TableHead key={q.id} className="p-2 text-center whitespace-nowrap"> - {q.vendorName || `벤더 ID: ${q.vendorId}`} - </TableHead> - ))} - </TableRow> - </thead> - <tbody> - {/* 견적 상태 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 상태 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`status-${q.id}`} className="p-2"> - <Badge variant={getStatusBadgeVariant(q.status)}> - {q.status} - </Badge> - </TableCell> - ))} - </TableRow> - - {/* 견적 버전 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 버전 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`version-${q.id}`} className="p-2"> - v{q.quotationVersion} - </TableCell> - ))} - </TableRow> - - {/* 총 금액 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 총 금액 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`total-${q.id}`} className="p-2 font-semibold"> - {formatCurrency(Number(q.totalPrice), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 소계 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 소계 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`subtotal-${q.id}`} className="p-2"> - {formatCurrency(Number(q.subTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 세금 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 세금 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`tax-${q.id}`} className="p-2"> - {formatCurrency(Number(q.taxTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 할인 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 할인 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`discount-${q.id}`} className="p-2"> - {formatCurrency(Number(q.discountTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 통화 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 통화 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`currency-${q.id}`} className="p-2"> - {q.currency} - </TableCell> - ))} - </TableRow> - - {/* 유효기간 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 유효 기간 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`valid-${q.id}`} className="p-2"> - {formatDate(q.validUntil, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 예상 배송일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 예상 배송일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`delivery-${q.id}`} className="p-2"> - {formatDate(q.estimatedDeliveryDate, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 지불 조건 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 지불 조건 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`payment-${q.id}`} className="p-2"> - {q.paymentTermsDescription || q.paymentTermsCode} - </TableCell> - ))} - </TableRow> - - {/* 인코텀즈 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 인코텀즈 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`incoterms-${q.id}`} className="p-2"> - {q.incotermsDescription || q.incotermsCode} - {q.incotermsDetail && ( - <div className="text-xs text-muted-foreground mt-1"> - {q.incotermsDetail} - </div> - )} - </TableCell> - ))} - </TableRow> - - {/* 제출일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 제출일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`submitted-${q.id}`} className="p-2"> - {formatDate(q.submittedAt, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 비고 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 비고 - </TableCell> - {quotations.map((q) => ( - <TableCell - key={`remark-${q.id}`} - className="p-2 whitespace-pre-wrap" - > - {q.remark || "-"} - </TableCell> - ))} - </TableRow> - </tbody> - </table> - </div> - </TabsContent> - - {/* ====================== 아이템별 비교 탭 ====================== */} - <TabsContent value="items" className="mt-4"> - {/* 컨테이너에 테이블 관련 클래스 직접 적용 */} - <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" > - <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}> - <table className="w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - {/* 첫 번째 헤더 행 */} - <tr> - {/* 첫 행: 자재(코드) 컬럼 */} - <th - rowSpan={2} - className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left" - style={{ - width: '250px', - minWidth: '250px', - backgroundColor: 'white', - }} - > - 자재 (코드) - </th> - - {/* 벤더 헤더 (접힘/펼침) */} - {quotations.map((q, index) => { - const collapsed = collapsedVendors[q.id] - // 접힌 상태면 1칸, 펼친 상태면 6칸 - return ( - <th - key={q.id} - className="p-2 text-center whitespace-nowrap border border-gray-200" - colSpan={collapsed ? 1 : 6} - style={{ - borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '', - backgroundColor: 'white', - }} - > - {/* + / - 버튼 */} - <div className="flex items-center gap-2 justify-center"> - <Button - variant="ghost" - size="sm" - className="h-7 w-7 p-1" - onClick={() => toggleVendor(q.id)} - > - {collapsed ? <Plus size={16} /> : <Minus size={16} />} - </Button> - <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> - </div> - </th> - ) - })} - </tr> - - {/* 두 번째 헤더 행 - 하위 컬럼들 */} - <tr className="border-b border-b-gray-200"> - {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */} - {quotations.flatMap((q, qIndex) => { - // 접힌 상태면 추가 헤더 없음 - if (collapsedVendors[q.id]) { - return [ - <th - key={`${q.id}-collapsed`} - className="p-2 text-center whitespace-nowrap border border-gray-200" - style={{ backgroundColor: 'white' }} - > - 총액 - </th> - ]; - } - - // 펼친 상태면 6개 컬럼 표시 - const columns = [ - { key: 'unitprice', label: '단가' }, - { key: 'totalprice', label: '총액' }, - { key: 'tax', label: '세금' }, - { key: 'discount', label: '할인' }, - { key: 'leadtime', label: '리드타임' }, - { key: 'alternative', label: '대체품' }, - ]; - - return columns.map((col, colIndex) => { - const isFirstInGroup = colIndex === 0; - const isLastInGroup = colIndex === columns.length - 1; - - return ( - <th - key={`${q.id}-${col.key}`} - className={`p-2 text-center whitespace-nowrap border border-gray-200 ${ - isFirstInGroup ? 'border-l border-l-gray-200' : '' - } ${ - isLastInGroup ? 'border-r border-r-gray-200' : '' - }`} - style={{ backgroundColor: 'white' }} - > - {col.label} - </th> - ); - }); - })} - </tr> - </thead> - - {/* 테이블 바디 */} - <tbody> - {allItemIds.map((itemId) => { - // 자재 기본 정보는 첫 번째 벤더 아이템 기준 - const firstQid = quotations[0]?.id - const sampleItem = firstQid - ? findItemByQuotationId(itemId, firstQid) - : undefined - - return ( - <tr key={itemId} className="border-b border-gray-100"> - {/* 자재 (코드) 셀 */} - <td - className="sticky left-0 z-10 p-2 align-top border-r border-gray-100" - style={{ - width: '250px', - minWidth: '250px', - backgroundColor: 'white', - }} - > - {sampleItem?.materialDescription || sampleItem?.materialCode || ""} - {sampleItem && ( - <div className="text-xs text-muted-foreground mt-1"> - 코드: {sampleItem.materialCode} | 수량:{" "} - {sampleItem.quantity} {sampleItem.uom} - </div> - )} - </td> - - {/* 벤더별 아이템 데이터 */} - {quotations.flatMap((q, qIndex) => { - const collapsed = collapsedVendors[q.id] - const itemData = findItemByQuotationId(itemId, q.id) - - // 접힌 상태면 총액만 표시 - if (collapsed) { - return [ - <td - key={`${q.id}-collapsed`} - className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100" - > - {itemData - ? formatCurrency(Number(itemData.totalPrice), itemData.currency) - : "N/A"} - </td> - ]; - } - - // 펼친 상태 - 아이템 없음 - if (!itemData) { - return [ - <td - key={`${q.id}-empty`} - colSpan={6} - className="p-2 text-center text-sm border-r border-gray-100" - > - 없음 - </td> - ]; - } - - // 펼친 상태 - 모든 컬럼 표시 - const columns = [ - { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' }, - { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true }, - { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' }, - { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' }, - { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' }, - { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' }, - ]; - - return columns.map((col, colIndex) => { - const isFirstInGroup = colIndex === 0; - const isLastInGroup = colIndex === columns.length - 1; - - return ( - <td - key={`${q.id}-${col.key}`} - className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${ - isFirstInGroup ? 'border-l border-l-gray-100' : '' - } ${ - isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100' - }`} - > - {col.render()} - </td> - ); - }); - })} - </tr> - ); - })} - - {/* 아이템이 전혀 없는 경우 */} - {allItemIds.length === 0 && ( - <tr> - <td - colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버 - className="text-center p-4 border border-gray-100" - > - 아이템 정보가 없습니다 - </td> - </tr> - )} - </tbody> - </table> - </div> - </div> - </TabsContent> - </Tabs> - )} - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 닫기 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/procurement-rfqs/table/pr-item-dialog.tsx b/lib/procurement-rfqs/table/pr-item-dialog.tsx deleted file mode 100644 index aada8438..00000000 --- a/lib/procurement-rfqs/table/pr-item-dialog.tsx +++ /dev/null @@ -1,258 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useState, useEffect } from "react"; -import { formatDate } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Badge } from "@/components/ui/badge"; -import { ProcurementRfqsView } from "@/db/schema"; -import { fetchPrItemsByRfqId } from "../services"; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Input } from "@/components/ui/input"; -import { Search } from "lucide-react"; - -// PR 항목 타입 정의 -interface PrItemView { - id: number; - procurementRfqsId: number; - rfqItem: string | null; - prItem: string | null; - prNo: string | null; - itemId: number | null; - materialCode: string | null; - materialCategory: string | null; - acc: string | null; - materialDescription: string | null; - size: string | null; - deliveryDate: Date | null; - quantity: number | null; - uom: string | null; - grossWeight: number | null; - gwUom: string | null; - specNo: string | null; - specUrl: string | null; - trackingNo: string | null; - majorYn: boolean | null; - projectDef: string | null; - projectSc: string | null; - projectKl: string | null; - projectLc: string | null; - projectDl: string | null; - remark: string | null; - rfqCode: string | null; - itemCode: string | null; - itemName: string | null; -} - -interface PrDetailsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedRfq: ProcurementRfqsView | null; -} - -export function PrDetailsDialog({ - open, - onOpenChange, - selectedRfq, -}: PrDetailsDialogProps) { - const [isLoading, setIsLoading] = useState(false); - const [prItems, setPrItems] = useState<PrItemView[]>([]); - const [searchTerm, setSearchTerm] = useState(""); - - // 검색어로 필터링된 항목들 - const filteredItems = React.useMemo(() => { - if (!searchTerm.trim()) return prItems; - - const term = searchTerm.toLowerCase(); - return prItems.filter(item => - (item.materialDescription || "").toLowerCase().includes(term) || - (item.materialCode || "").toLowerCase().includes(term) || - (item.prNo || "").toLowerCase().includes(term) || - (item.prItem || "").toLowerCase().includes(term) || - (item.rfqItem || "").toLowerCase().includes(term) - ); - }, [prItems, searchTerm]); - - // 선택된 RFQ가 변경되면 PR 항목 데이터를 가져옴 - useEffect(() => { - async function loadPrItems() { - if (!selectedRfq || !open) { - setPrItems([]); - return; - } - - try { - setIsLoading(true); - const result = await fetchPrItemsByRfqId(selectedRfq.id); - const mappedItems: PrItemView[] = result.data.map(item => ({ - ...item, - // procurementRfqsId가 null이면 selectedRfq.id 사용 - procurementRfqsId: item.procurementRfqsId ?? selectedRfq.id, - // 기타 필요한 필드에 대한 기본값 처리 - rfqItem: item.rfqItem ?? null, - prItem: item.prItem ?? null, - prNo: item.prNo ?? null, - // 다른 필드도 필요에 따라 추가 - })); - - setPrItems(mappedItems); - } catch (error) { - console.error("PR 항목 로드 오류:", error); - setPrItems([]); - } finally { - setIsLoading(false); - } - } - - if (open) { - loadPrItems(); - setSearchTerm(""); - } - }, [selectedRfq, open]); - - // 선택된 RFQ가 없는 경우 - if (!selectedRfq) { - return null; - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-screen-sm max-h-[90vh] flex flex-col" style={{ maxWidth: "70vw" }}> - <DialogHeader> - <DialogTitle className="text-xl"> - PR 상세 정보 - {selectedRfq.rfqCode} - </DialogTitle> - <DialogDescription> - 프로젝트: {selectedRfq.projectName} ({selectedRfq.projectCode}) | 건수:{" "} - {selectedRfq.prItemsCount || 0}건 - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="py-4 space-y-3"> - <Skeleton className="h-8 w-full" /> - <Skeleton className="h-24 w-full" /> - <Skeleton className="h-24 w-full" /> - </div> - ) : ( - <div className="flex-1 flex flex-col"> - {/* 검색 필드 */} - <div className="mb-4 relative"> - <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> - <Search className="h-4 w-4 text-muted-foreground" /> - </div> - <Input - placeholder="PR 번호, 자재 코드, 설명 등 검색..." - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - className="pl-8" - /> -</div> - {filteredItems.length === 0 ? ( - <div className="flex items-center justify-center py-8 text-muted-foreground border rounded-md"> - {prItems.length === 0 ? "PR 항목이 없습니다" : "검색 결과가 없습니다"} - </div> - ) : ( - <div className="rounded-md border flex-1 overflow-hidden"> - <div className="overflow-x-auto" style={{ width: "100%" }}> - <Table style={{ minWidth: "2500px" }}> - <TableCaption> - 총 {filteredItems.length}개 항목 (전체 {prItems.length}개 중) - </TableCaption> - <TableHeader className="bg-muted/50 sticky top-0"> - <TableRow> - <TableHead className="w-[100px] whitespace-nowrap">RFQ Item</TableHead> - <TableHead className="w-[120px] whitespace-nowrap">PR 번호</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">PR Item</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">자재그룹</TableHead> - <TableHead className="w-[120px] whitespace-nowrap">자재 코드</TableHead> - <TableHead className="w-[120px] whitespace-nowrap">자재 카테고리</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">ACC</TableHead> - <TableHead className="min-w-[200px] whitespace-nowrap">자재 설명</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">규격</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">납품일</TableHead> - <TableHead className="w-[80px] whitespace-nowrap">수량</TableHead> - <TableHead className="w-[80px] whitespace-nowrap">UOM</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">총중량</TableHead> - <TableHead className="w-[80px] whitespace-nowrap">중량 단위</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">사양 번호</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">사양 URL</TableHead> - <TableHead className="w-[120px] whitespace-nowrap">추적 번호</TableHead> - <TableHead className="w-[80px] whitespace-nowrap">주요 항목</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DEF</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">프로젝트 SC</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">프로젝트 KL</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">프로젝트 LC</TableHead> - <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DL</TableHead> - <TableHead className="w-[150px] whitespace-nowrap">비고</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {filteredItems.map((item) => ( - <TableRow key={item.id}> - <TableCell className="whitespace-nowrap">{item.rfqItem || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.prNo || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.prItem || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.itemCode || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.materialCode || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.materialCategory || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.acc || "-"}</TableCell> - <TableCell>{item.materialDescription || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.size || "-"}</TableCell> - <TableCell className="whitespace-nowrap"> - {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} - </TableCell> - <TableCell className="whitespace-nowrap">{item.quantity || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.uom || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.grossWeight || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.gwUom || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.specNo || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.specUrl || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.trackingNo || "-"}</TableCell> - <TableCell className="whitespace-nowrap"> - {item.majorYn ? ( - <Badge variant="secondary">주요</Badge> - ) : ( - "아니오" - )} - </TableCell> - <TableCell className="whitespace-nowrap">{item.projectDef || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.projectSc || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.projectKl || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.projectLc || "-"}</TableCell> - <TableCell className="whitespace-nowrap">{item.projectDl || "-"}</TableCell> - <TableCell className="text-sm">{item.remark || "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </div> - )} - </div> - )} - - <DialogFooter className="mt-2"> - <Button onClick={() => onOpenChange(false)}>닫기</Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx deleted file mode 100644 index a746603b..00000000 --- a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx +++ /dev/null @@ -1,686 +0,0 @@ -"use client" - -import { useEffect, useTransition, useState, useRef } from "react" -import { useRouter, useParams } from "next/navigation" -import { z } from "zod" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { CalendarIcon, ChevronRight, Search, X } from "lucide-react" -import { customAlphabet } from "nanoid" -import { parseAsStringEnum, useQueryState } from "nuqs" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { useTranslation } from '@/i18n/client' -import { getFiltersStateParser } from "@/lib/parsers" -import { DateRangePicker } from "@/components/date-range-picker" - -// nanoid 생성기 -const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) - -// 필터 스키마 정의 (RFQ 관련 항목 유지) -const filterSchema = z.object({ - picCode: z.string().optional(), - projectCode: z.string().optional(), - rfqCode: z.string().optional(), - itemCode: z.string().optional(), - majorItemMaterialCode: z.string().optional(), - status: z.string().optional(), - dateRange: z.object({ - from: z.date().optional(), - to: z.date().optional(), - }).optional(), -}) - -// 상태 옵션 정의 -const statusOptions = [ - { value: "RFQ Created", label: "RFQ Created" }, - { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, - { value: "RFQ Sent", label: "RFQ Sent" }, - { value: "Quotation Analysis", label: "Quotation Analysis" }, - { value: "PO Transfer", label: "PO Transfer" }, - { value: "PO Create", label: "PO Create" }, -] - -type FilterFormValues = z.infer<typeof filterSchema> - -interface RFQFilterSheetProps { - isOpen: boolean; - onClose: () => void; - onSearch?: () => void; - isLoading?: boolean; -} - -// Updated component for inline use (not a sheet anymore) -export function RFQFilterSheet({ - isOpen, - onClose, - onSearch, - isLoading = false -}: RFQFilterSheetProps) { - const router = useRouter() - const params = useParams(); - const lng = params ? (params.lng as string) : 'ko'; - const { t } = useTranslation(lng); - - const [isPending, startTransition] = useTransition() - - // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 - const [isInitializing, setIsInitializing] = useState(false) - // 마지막으로 적용된 필터를 추적하기 위한 ref - const lastAppliedFilters = useRef<string>("") - - // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경 - const [filters, setFilters] = useQueryState( - "basicFilters", - getFiltersStateParser().withDefault([]) - ) - - // joinOperator 설정 - const [joinOperator, setJoinOperator] = useQueryState( - "basicJoinOperator", - parseAsStringEnum(["and", "or"]).withDefault("and") - ) - - // 현재 URL의 페이지 파라미터도 가져옴 - const [page, setPage] = useQueryState("page", { defaultValue: "1" }) - - // 폼 상태 초기화 - const form = useForm<FilterFormValues>({ - resolver: zodResolver(filterSchema), - defaultValues: { - picCode: "", - projectCode: "", - rfqCode: "", - itemCode: "", - majorItemMaterialCode: "", - status: "", - dateRange: { - from: undefined, - to: undefined, - }, - }, - }) - - // URL 필터에서 초기 폼 상태 설정 - 개선된 버전 - useEffect(() => { - // 현재 필터를 문자열로 직렬화 - const currentFiltersString = JSON.stringify(filters); - - // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 - if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { - setIsInitializing(true); - - const formValues = { ...form.getValues() }; - let formUpdated = false; - - filters.forEach(filter => { - if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { - formValues.dateRange = { - from: filter.value[0] ? new Date(filter.value[0]) : undefined, - to: filter.value[1] ? new Date(filter.value[1]) : undefined, - }; - formUpdated = true; - } else if (filter.id in formValues) { - // @ts-ignore - 동적 필드 접근 - formValues[filter.id] = filter.value; - formUpdated = true; - } - }); - - // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 - if (formUpdated) { - form.reset(formValues); - lastAppliedFilters.current = currentFiltersString; - } - - setIsInitializing(false); - } - }, [filters, isOpen]) // form 의존성 제거 - - // 현재 적용된 필터 카운트 - const getActiveFilterCount = () => { - return filters?.length || 0 - } - - // 폼 제출 핸들러 - PQ 방식으로 수정 (수동 URL 업데이트 버전) - async function onSubmit(data: FilterFormValues) { - // 초기화 중이면 제출 방지 - if (isInitializing) return; - - startTransition(async () => { - try { - // 필터 배열 생성 - const newFilters = [] - - if (data.picCode?.trim()) { - newFilters.push({ - id: "picCode", - value: data.picCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.projectCode?.trim()) { - newFilters.push({ - id: "projectCode", - value: data.projectCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.rfqCode?.trim()) { - newFilters.push({ - id: "rfqCode", - value: data.rfqCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.itemCode?.trim()) { - newFilters.push({ - id: "itemCode", - value: data.itemCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.majorItemMaterialCode?.trim()) { - newFilters.push({ - id: "majorItemMaterialCode", - value: data.majorItemMaterialCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.status?.trim()) { - newFilters.push({ - id: "status", - value: data.status.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // Add date range to params if it exists - if (data.dateRange?.from) { - newFilters.push({ - id: "rfqSendDate", - value: [ - data.dateRange.from.toISOString().split('T')[0], - data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined - ].filter(Boolean), - type: "date", - operator: "isBetween", - rowId: generateId() - }) - } - - console.log("=== RFQ Filter Submit Debug ==="); - console.log("Generated filters:", newFilters); - console.log("Join operator:", joinOperator); - - // 🔑 PQ 방식: 수동으로 URL 업데이트 (nuqs 대신) - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 기존 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.delete('page'); - - // 새로운 필터 추가 - if (newFilters.length > 0) { - params.set('basicFilters', JSON.stringify(newFilters)); - params.set('basicJoinOperator', joinOperator); - } - - // 페이지를 1로 설정 - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("New URL:", newUrl); - - // 🔑 PQ 방식: 페이지 완전 새로고침으로 서버 렌더링 강제 - window.location.href = newUrl; - - // 마지막 적용된 필터 업데이트 - lastAppliedFilters.current = JSON.stringify(newFilters); - - // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) - if (onSearch) { - console.log("Calling onSearch..."); - onSearch(); - } - - console.log("=== RFQ Filter Submit Complete ==="); - } catch (error) { - console.error("RFQ 필터 적용 오류:", error); - } - }) - } - - // 필터 초기화 핸들러 - PQ 방식으로 수정 - async function handleReset() { - try { - setIsInitializing(true); - - form.reset({ - picCode: "", - projectCode: "", - rfqCode: "", - itemCode: "", - majorItemMaterialCode: "", - status: "", - dateRange: { from: undefined, to: undefined }, - }); - - console.log("=== RFQ Filter Reset Debug ==="); - console.log("Current URL before reset:", window.location.href); - - // 🔑 PQ 방식: 수동으로 URL 초기화 - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("Reset URL:", newUrl); - - // 🔑 PQ 방식: 페이지 완전 새로고침 - window.location.href = newUrl; - - // 마지막 적용된 필터 초기화 - lastAppliedFilters.current = ""; - - console.log("RFQ 필터 초기화 완료"); - setIsInitializing(false); - } catch (error) { - console.error("RFQ 필터 초기화 오류:", error); - setIsInitializing(false); - } - } - - // Don't render if not open (for side panel use) - if (!isOpen) { - return null; - } - - return ( - <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> - {/* Filter Panel Header */} - <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> - <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3> - <div className="flex items-center gap-2"> - {getActiveFilterCount() > 0 && ( - <Badge variant="secondary" className="px-2 py-1"> - {getActiveFilterCount()}개 필터 적용됨 - </Badge> - )} - </div> - </div> - - {/* Join Operator Selection */} - <div className="px-6 shrink-0"> - <label className="text-sm font-medium">조건 결합 방식</label> - <Select - value={joinOperator} - onValueChange={(value: "and" | "or") => setJoinOperator(value)} - disabled={isInitializing} - > - <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> - <SelectValue placeholder="조건 결합 방식" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> - <SelectItem value="or">하나라도 충족 (OR)</SelectItem> - </SelectContent> - </Select> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> - {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */} - <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> - <div className="space-y-4 pt-2"> - {/* 발주 담당 */} - <FormField - control={form.control} - name="picCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("발주담당")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("발주담당 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("picCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 프로젝트 코드 */} - <FormField - control={form.control} - name="projectCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("프로젝트 코드")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("프로젝트 코드 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("projectCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ NO. */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("RFQ NO.")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("RFQ 번호 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("rfqCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 자재그룹 */} - <FormField - control={form.control} - name="itemCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("자재그룹")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("자재그룹 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("itemCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 자재코드 */} - <FormField - control={form.control} - name="majorItemMaterialCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("자재코드")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("자재코드 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("majorItemMaterialCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Status */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("Status")}</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isInitializing} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder={t("Select status")} /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("status", ""); - }} - disabled={isInitializing} - > - <X className="size-3" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {statusOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ 전송일 */} - <FormField - control={form.control} - name="dateRange" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("RFQ 전송일")}</FormLabel> - <FormControl> - <div className="relative"> - <DateRangePicker - triggerSize="default" - triggerClassName="w-full bg-white" - align="start" - showClearButton={true} - placeholder={t("RFQ 전송일 범위를 고르세요")} - value={field.value || undefined} - onChange={field.onChange} - disabled={isInitializing} - /> - {(field.value?.from || field.value?.to) && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-10 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("dateRange", { from: undefined, to: undefined }); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* Fixed buttons at bottom */} - <div className="p-4 shrink-0"> - <div className="flex gap-2 justify-end"> - <Button - type="button" - variant="outline" - onClick={handleReset} - disabled={isPending || getActiveFilterCount() === 0 || isInitializing} - className="px-4" - > - {t("초기화")} - </Button> - <Button - type="submit" - variant="samsung" - disabled={isPending || isLoading || isInitializing} - className="px-4" - > - <Search className="size-4 mr-2" /> - {isPending || isLoading ? t("조회 중...") : t("조회")} - </Button> - </div> - </div> - </form> - </Form> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table-column.tsx b/lib/procurement-rfqs/table/rfq-table-column.tsx deleted file mode 100644 index 3cf06315..00000000 --- a/lib/procurement-rfqs/table/rfq-table-column.tsx +++ /dev/null @@ -1,373 +0,0 @@ -"use client" - -import * as React from "react" -import { ColumnDef } from "@tanstack/react-table" -import { formatDate, formatDateTime } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { DataTableRowAction } from "@/types/table" -import { ProcurementRfqsView } from "@/db/schema" -import { Check, Pencil, X } from "lucide-react" -import { Button } from "@/components/ui/button" -import { toast } from "sonner" -import { Input } from "@/components/ui/input" -import { updateRfqRemark } from "../services" - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementRfqsView> | null>>; - // 상태와 상태 설정 함수를 props로 받음 - editingCell: EditingCellState | null; - setEditingCell: (state: EditingCellState | null) => void; - updateRemark: (rfqId: number, remark: string) => Promise<void>; -} - -export interface EditingCellState { - rowId: string | number; - value: string; -} - - -export function getColumns({ - setRowAction, - editingCell, - setEditingCell, - updateRemark, -}: GetColumnsProps): ColumnDef<ProcurementRfqsView>[] { - - - - return [ - { - id: "select", - // Remove the "Select all" checkbox in header since we're doing single-select - header: () => <span className="sr-only">Select</span>, - cell: ({ row, table }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => { - // If selecting this row - if (value) { - // First deselect all rows (to ensure single selection) - table.toggleAllRowsSelected(false) - // Then select just this row - row.toggleSelected(true) - // Trigger the same action that was in the "Select" button - setRowAction({ row, type: "select" }) - } else { - // Just deselect this row - row.toggleSelected(false) - } - }} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - enableSorting: false, - enableHiding: false, - enableResizing: false, - size: 40, - minSize: 40, - maxSize: 40, - }, - - { - accessorKey: "status", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="status" /> - ), - cell: ({ row }) => <div>{row.getValue("status")}</div>, - meta: { - excelHeader: "status" - }, - enableResizing: true, - minSize: 80, - size: 100, - }, - { - accessorKey: "projectCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트" /> - ), - cell: ({ row }) => <div>{row.getValue("projectCode")}</div>, - meta: { - excelHeader: "프로젝트" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "series", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="시리즈" /> - ), - cell: ({ row }) => <div>{row.getValue("series")}</div>, - meta: { - excelHeader: "시리즈" - }, - enableResizing: true, - minSize: 80, - size: 100, - }, - { - accessorKey: "rfqSealedYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 밀봉" /> - ), - cell: ({ row }) => <div>{row.getValue("rfqSealedYn") ? "Y":"N"}</div>, - meta: { - excelHeader: "RFQ 밀봉" - }, - enableResizing: true, - minSize: 80, - size: 100, - }, - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ NO." /> - ), - cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>, - meta: { - excelHeader: "RFQ NO." - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "po_no", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="대표 PR NO." /> - ), - cell: ({ row }) => <div>{row.getValue("po_no")}</div>, - meta: { - excelHeader: "대표 PR NO." - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "itemCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재그룹" /> - ), - cell: ({ row }) => <div>{row.getValue("itemCode")}</div>, - meta: { - excelHeader: "자재그룹" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "majorItemMaterialCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재코드" /> - ), - cell: ({ row }) => <div>{row.getValue("majorItemMaterialCode")}</div>, - meta: { - excelHeader: "자재코드" - }, - enableResizing: true, - minSize: 80, - size: 120, - }, - { - accessorKey: "itemName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재명" /> - ), - cell: ({ row }) => <div>{row.getValue("itemName")}</div>, - meta: { - excelHeader: "자재명" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "prItemsCount", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="PR 건수" /> - ), - cell: ({ row }) => <div>{row.getValue("prItemsCount")}</div>, - meta: { - excelHeader: "PR 건수" - }, - enableResizing: true, - // size: 80, - }, - { - accessorKey: "rfqSendDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "RFQ 전송일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "earliestQuotationSubmittedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첫회신 접수일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "첫회신 접수일" - }, - enableResizing: true, - // size: 140, - }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "RFQ 마감일" - }, - enableResizing: true, - minSize: 80, - size: 120, - }, - { - accessorKey: "sentByUserName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 요청자" /> - ), - cell: ({ row }) => <div>{row.getValue("sentByUserName")}</div>, - meta: { - excelHeader: "RFQ 요청자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"), - meta: { - excelHeader: "updated At" - }, - enableResizing: true, - size: 140, - }, - - { - accessorKey: "remark", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="비고" /> - ), - cell: ({ row }) => { - const rowId = row.id - const value = row.getValue("remark") as string - const isEditing = editingCell && editingCell.rowId === rowId - - const startEditing = () => { - setEditingCell({ - rowId, - value: value || "" - }) - } - - const cancelEditing = () => { - setEditingCell(null) - } - - const saveChanges = async () => { - if (!editingCell) return - - try { - - // 컴포넌트에서 전달받은 업데이트 함수 사용 - await updateRemark(row.original.id, editingCell.value) - row.original.remark = editingCell.value; - - // 편집 모드 종료 - setEditingCell(null) - } catch (error) { - console.error("비고 업데이트 오류:", error) - } - } - - // 키보드 이벤트 처리 - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - saveChanges() - } else if (e.key === "Escape") { - cancelEditing() - } - } - - if (isEditing) { - return ( - <div className="flex items-center space-x-1"> - <Input - value={editingCell.value} - onChange={(e) => setEditingCell({ - ...editingCell, - value: e.target.value - })} - onKeyDown={handleKeyDown} - autoFocus - className="h-8 w-full" - /> - <div className="flex items-center"> - <Button - variant="ghost" - size="icon" - onClick={saveChanges} - className="h-7 w-7" - > - <Check className="h-4 w-4 text-green-500" /> - </Button> - <Button - variant="ghost" - size="icon" - onClick={cancelEditing} - className="h-7 w-7" - > - <X className="h-4 w-4 text-red-500" /> - </Button> - </div> - </div> - ) - } - - return ( - <div - className="flex items-center justify-between group" - onDoubleClick={startEditing} // 더블클릭 이벤트 추가 - > - <div className="truncate">{value || "-"}</div> - <Button - variant="ghost" - size="icon" - onClick={startEditing} - className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity" - > - <Pencil className="h-3.5 w-3.5 text-muted-foreground" /> - </Button> - </div> - ) - }, - meta: { - excelHeader: "비고" - }, - enableResizing: true, - size: 200, - } - ] -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx deleted file mode 100644 index 26725797..00000000 --- a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx +++ /dev/null @@ -1,279 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { ClipboardList, Download, Send, Lock, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { ProcurementRfqsView } from "@/db/schema" -import { PrDetailsDialog } from "./pr-item-dialog" -import { sealRfq, sendRfq, getPORfqs, fetchExternalRfqs } from "../services" - -// total 필드 추가하여 타입 정의 수정 -type PORfqsReturn = Awaited<ReturnType<typeof getPORfqs>> - -interface RFQTableToolbarActionsProps { - table: Table<ProcurementRfqsView>; - // 타입 수정 - localData?: PORfqsReturn; - setLocalData?: React.Dispatch<React.SetStateAction<PORfqsReturn>>; - onSuccess?: () => void; -} - -export function RFQTableToolbarActions({ - table, - localData, - setLocalData, - onSuccess -}: RFQTableToolbarActionsProps) { - // 다이얼로그 열림/닫힘 상태 관리 - const [dialogOpen, setDialogOpen] = React.useState(false) - const [isProcessing, setIsProcessing] = React.useState(false) - - // 선택된 RFQ 가져오기 - const getSelectedRfq = (): ProcurementRfqsView | null => { - const selectedRows = table.getFilteredSelectedRowModel().rows - if (selectedRows.length === 1) { - return selectedRows[0].original - } - return null - } - - // 선택된 RFQ - const selectedRfq = getSelectedRfq() - - // PR 상세보기 버튼 클릭 핸들러 - const handleViewPrDetails = () => { - const rfq = getSelectedRfq() - if (!rfq) { - toast.warning("RFQ를 선택해주세요") - return - } - - if (!rfq.prItemsCount || rfq.prItemsCount <= 0) { - toast.warning("선택한 RFQ에 PR 항목이 없습니다") - return - } - - setDialogOpen(true) - } - - // RFQ 밀봉 버튼 클릭 핸들러 - const handleSealRfq = async () => { - const rfq = getSelectedRfq() - if (!rfq) { - toast.warning("RFQ를 선택해주세요") - return - } - - // 이미 밀봉된 RFQ인 경우 - if (rfq.rfqSealedYn) { - toast.warning("이미 밀봉된 RFQ입니다") - return - } - - try { - setIsProcessing(true) - - // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신) - if (localData?.data && setLocalData) { - // 로컬 데이터에서 해당 행 찾기 - const rowIndex = localData.data.findIndex(row => row.id === rfq.id); - if (rowIndex >= 0) { - // 불변성을 유지하면서 로컬 데이터 업데이트 - 타입 안전하게 복사 - const newData = [...localData.data] as ProcurementRfqsView[]; - newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: "Y" }; - - // 전체 데이터 구조 복사하여 업데이트, total 필드가 있다면 유지 - setLocalData({ - ...localData, - data: newData ?? [], - pageCount: localData.pageCount, - total: localData.total ?? 0 - }); - } - } - - const result = await sealRfq(rfq.id) - - if (result.success) { - toast.success("RFQ가 성공적으로 밀봉되었습니다") - // 데이터 리프레시 - onSuccess?.() - } else { - toast.error(result.message || "RFQ 밀봉 중 오류가 발생했습니다") - - // 서버 요청 실패 시 낙관적 업데이트 되돌리기 - if (localData?.data && setLocalData) { - const rowIndex = localData.data.findIndex(row => row.id === rfq.id); - if (rowIndex >= 0) { - const newData = [...localData.data] as ProcurementRfqsView[]; - newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원 - setLocalData({ - ...localData, - data: newData ?? [], - pageCount: localData.pageCount, - total: localData.total ?? 0 - }); - } - } - } - } catch (error) { - console.error("RFQ 밀봉 오류:", error) - toast.error("RFQ 밀봉 중 오류가 발생했습니다") - - // 에러 발생 시 낙관적 업데이트 되돌리기 - if (localData?.data && setLocalData) { - const rowIndex = localData.data.findIndex(row => row.id === rfq.id); - if (rowIndex >= 0) { - const newData = [...localData.data] as ProcurementRfqsView[]; - newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원 - setLocalData({ - ...localData, - data: newData ?? [], - pageCount: localData.pageCount, - total: localData.total ?? 0 - }); - } - } - } finally { - setIsProcessing(false) - } - } - - // RFQ 전송 버튼 클릭 핸들러 - const handleSendRfq = async () => { - const rfq = getSelectedRfq() - if (!rfq) { - toast.warning("RFQ를 선택해주세요") - return - } - - // 전송 가능한 상태인지 확인 - if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { - toast.warning("벤더가 할당된 RFQ이거나 전송한 적이 있는 RFQ만 전송할 수 있습니다") - return - } - - try { - setIsProcessing(true) - - const result = await sendRfq(rfq.id) - - if (result.success) { - toast.success("RFQ가 성공적으로 전송되었습니다") - // 데이터 리프레시 - onSuccess?.() - } else { - toast.error(result.message || "RFQ 전송 중 오류가 발생했습니다") - } - } catch (error) { - console.error("RFQ 전송 오류:", error) - toast.error("RFQ 전송 중 오류가 발생했습니다") - } finally { - setIsProcessing(false) - } - } - - const handleFetchExternalRfqs = async () => { - try { - setIsProcessing(true); - - const result = await fetchExternalRfqs(); - - if (result.success) { - toast.success(result.message || "외부 RFQ를 성공적으로 가져왔습니다"); - // 데이터 리프레시 - onSuccess?.() - } else { - toast.error(result.message || "외부 RFQ를 가져오는 중 오류가 발생했습니다"); - } - } catch (error) { - console.error("외부 RFQ 가져오기 오류:", error); - toast.error("외부 RFQ를 가져오는 중 오류가 발생했습니다"); - } finally { - setIsProcessing(false); - } - }; - - return ( - <> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "rfq", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - {/* RFQ 가져오기 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleFetchExternalRfqs} - className="gap-2" - disabled={isProcessing} - > - <Upload className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">RFQ 가져오기</span> - </Button> - - {/* PR 상세보기 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleViewPrDetails} - className="gap-2" - disabled={!selectedRfq || !(selectedRfq.prItemsCount && selectedRfq.prItemsCount > 0)} - > - <ClipboardList className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">PR 상세보기</span> - </Button> - - {/* RFQ 밀봉 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleSealRfq} - className="gap-2" - disabled={!selectedRfq || selectedRfq.rfqSealedYn === "Y" || selectedRfq.status !== "RFQ Sent" || isProcessing} - > - <Lock className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">RFQ 밀봉</span> - </Button> - - {/* RFQ 전송 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleSendRfq} - className="gap-2" - disabled={ - !selectedRfq || - (selectedRfq.status !== "RFQ Vendor Assignned" && selectedRfq.status !== "RFQ Sent") || - isProcessing - } - > - <Send className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">RFQ 전송</span> - </Button> - </div> - - {/* PR 상세정보 다이얼로그 */} - <PrDetailsDialog - open={dialogOpen} - onOpenChange={setDialogOpen} - selectedRfq={selectedRfq} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx deleted file mode 100644 index ca976172..00000000 --- a/lib/procurement-rfqs/table/rfq-table.tsx +++ /dev/null @@ -1,412 +0,0 @@ -"use client" - -import * as React from "react" -import { useSearchParams } from "next/navigation" -import { Button } from "@/components/ui/button" -import { PanelLeftClose, PanelLeftOpen } from "lucide-react" -import type { - DataTableAdvancedFilterField, - DataTableRowAction, -} from "@/types/table" -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { getColumns, EditingCellState } from "./rfq-table-column" -import { useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" -import { ProcurementRfqsView } from "@/db/schema" -import { getPORfqs } from "../services" -import { toast } from "sonner" -import { updateRfqRemark } from "@/lib/procurement-rfqs/services" -import { useTablePresets } from "@/components/data-table/use-table-presets" -import { TablePresetManager } from "@/components/data-table/data-table-preset" -import { Loader2 } from "lucide-react" -import { RFQFilterSheet } from "./rfq-filter-sheet" -import { RfqDetailTables } from "./detail-table/rfq-detail-table" -import { cn } from "@/lib/utils" - -interface RFQListTableProps { - promises: Promise<[Awaited<ReturnType<typeof getPORfqs>>]> - className?: string; - calculatedHeight?: string; // 계산된 높이 추가 -} - -export function RFQListTable({ - promises, - className, - calculatedHeight -}: RFQListTableProps) { - const searchParams = useSearchParams() - - // 필터 패널 상태 - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) - - // 선택된 RFQ 상태 - const [selectedRfq, setSelectedRfq] = React.useState<ProcurementRfqsView | null>(null) - - // 패널 collapse 상태 - const [isTopCollapsed, setIsTopCollapsed] = React.useState(false) - const [panelHeight, setPanelHeight] = React.useState<number>(55) - - // refs - const headerRef = React.useRef<HTMLDivElement>(null) - - // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) - const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이 - const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값) - const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border) - const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비 - - // 높이 계산 - // 필터 패널 높이 - Layout Header와 Footer 사이 - const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)` - - console.log(calculatedHeight) - - // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외 - const FIXED_TABLE_HEIGHT = calculatedHeight - ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)` - : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback - - // Suspense 방식으로 데이터 처리 - const [promiseData] = React.use(promises) - const tableData = promiseData - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null) - const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) - - // 초기 설정 정의 - const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams.get('page') || '1'), - perPage: parseInt(searchParams.get('perPage') || '10'), - sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], - filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], - basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams.get('search') || '', - from: searchParams.get('from') || undefined, - to: searchParams.get('to') || undefined, - columnVisibility: {}, - columnOrder: [], - pinnedColumns: { left: [], right: [] }, - groupBy: [], - expandedRows: [] - }), [searchParams]) - - // DB 기반 프리셋 훅 사용 - const { - presets, - activePresetId, - hasUnsavedChanges, - isLoading: presetsLoading, - createPreset, - applyPreset, - updatePreset, - deletePreset, - setDefaultPreset, - renamePreset, - getCurrentSettings, - } = useTablePresets<ProcurementRfqsView>('rfq-list-table', initialSettings) - - // 비고 업데이트 함수 - const updateRemark = async (rfqId: number, remark: string) => { - try { - const result = await updateRfqRemark(rfqId, remark); - - if (result.success) { - toast.success("비고가 업데이트되었습니다"); - } else { - toast.error(result.message || "업데이트 중 오류가 발생했습니다"); - } - } catch (error) { - console.error("비고 업데이트 오류:", error); - toast.error("업데이트 중 오류가 발생했습니다"); - } - } - - // 행 액션 처리 - useEffect(() => { - if (rowAction) { - switch (rowAction.type) { - case "select": - setSelectedRfq(rowAction.row.original) - break; - case "update": - console.log("Update rfq:", rowAction.row.original) - break; - case "delete": - console.log("Delete rfq:", rowAction.row.original) - break; - } - setRowAction(null) - } - }, [rowAction]) - - const columns = React.useMemo( - () => getColumns({ - setRowAction, - editingCell, - setEditingCell, - updateRemark - }), - [setRowAction, editingCell, setEditingCell, updateRemark] - ) - - // 고급 필터 필드 정의 - const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [ - { - id: "rfqCode", - label: "RFQ No.", - type: "text", - }, - { - id: "projectCode", - label: "프로젝트", - type: "text", - }, - { - id: "itemCode", - label: "자재그룹", - type: "text", - }, - { - id: "itemName", - label: "자재명", - type: "text", - }, - { - id: "rfqSealedYn", - label: "RFQ 밀봉여부", - type: "text", - }, - { - id: "majorItemMaterialCode", - label: "자재코드", - type: "text", - }, - { - id: "rfqSendDate", - label: "RFQ 전송일", - type: "date", - }, - { - id: "dueDate", - label: "RFQ 마감일", - type: "date", - }, - { - id: "createdByUserName", - label: "요청자", - type: "text", - }, - ] - - // 현재 설정 가져오기 - const currentSettings = useMemo(() => { - return getCurrentSettings() - }, [getCurrentSettings]) - - // useDataTable 초기 상태 설정 - const initialState = useMemo(() => { - return { - sorting: initialSettings.sort.filter(sortItem => { - const columnExists = columns.some(col => col.accessorKey === sortItem.id) - return columnExists - }) as any, - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [currentSettings, initialSettings.sort, columns]) - - // useDataTable 훅 설정 (PQ와 동일한 설정) - const { table } = useDataTable({ - data: tableData?.data || [], - columns, - pageCount: tableData?.pageCount || 0, - rowCount: tableData?.total || 0, - filterFields: [], // PQ와 동일하게 빈 배열 - enablePinning: true, - enableAdvancedFilter: true, - initialState, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, // PQ와 동일하게 false - clearOnDefault: true, - }) - - // 조회 버튼 클릭 핸들러 - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - // Get active basic filter count (PQ와 동일한 방식) - const getActiveBasicFilterCount = () => { - try { - const basicFilters = searchParams.get('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 - } - } - - console.log(panelHeight) - - return ( - <div - className={cn("flex flex-col relative", className)} - style={{ height: calculatedHeight }} - > - {/* Filter Panel - 계산된 높이 적용 */} - <div - className={cn( - "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", - isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" - )} - style={{ - width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - top: `${LAYOUT_HEADER_HEIGHT*2}px`, - height: FIXED_FILTER_HEIGHT - }} - > - {/* Filter Content */} - <div className="h-full"> - <RFQFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} - isLoading={false} - /> - </div> - </div> - - {/* Main Content */} - <div - className="flex flex-col transition-all duration-300 ease-in-out" - style={{ - width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', - marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - height: '100%' - }} - > - {/* Header Bar - 고정 높이 */} - <div - ref={headerRef} - className="flex items-center justify-between p-4 bg-background border-b" - style={{ - height: `${LOCAL_HEADER_HEIGHT}px`, - flexShrink: 0 - }} - > - <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - type='button' - onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} - className="flex items-center shadow-sm" - > - {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} - {getActiveBasicFilterCount() > 0 && ( - <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} - </span> - )} - </Button> - </div> - - {/* Right side info */} - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || 0}건</span> - )} - </div> - </div> - - {/* Table Content Area - 계산된 높이 사용 */} - <div - className="relative bg-background" - style={{ - height: FIXED_TABLE_HEIGHT, - display: 'grid', - gridTemplateRows: '1fr', - gridTemplateColumns: '1fr' - }} - > - <ResizablePanelGroup - direction="vertical" - className="w-full h-full" - > - <ResizablePanel - defaultSize={60} - minSize={25} - maxSize={75} - collapsible={false} - onResize={(size) => { - setPanelHeight(size) - }} - className="flex flex-col overflow-hidden" - > - {/* 상단 테이블 영역 */} - <div className="flex-1 min-h-0 overflow-hidden"> - <DataTable - table={table} - // className="h-full" - maxHeight={`${panelHeight*0.5}vh`} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <div className="flex items-center gap-2"> - <TablePresetManager<ProcurementRfqsView> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> - - <RFQTableToolbarActions - table={table} - localData={tableData} - setLocalData={() => {}} - onSuccess={() => {}} - /> - </div> - </DataTableAdvancedToolbar> - </DataTable> - </div> - </ResizablePanel> - - <ResizableHandle withHandle /> - - <ResizablePanel - minSize={25} - defaultSize={40} - collapsible={false} - className="flex flex-col overflow-hidden" - > - {/* 하단 상세 테이블 영역 */} - <div className="flex-1 min-h-0 overflow-hidden bg-background"> - <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/> - </div> - </ResizablePanel> - </ResizablePanelGroup> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/validations.ts b/lib/procurement-rfqs/validations.ts deleted file mode 100644 index 5059755f..00000000 --- a/lib/procurement-rfqs/validations.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum,parseAsBoolean -} from "nuqs/server" -import * as z from "zod" - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { ProcurementRfqsView, ProcurementVendorQuotations } from "@/db/schema"; - - -// ======================= -// 1) SearchParams (목록 필터링/정렬) -// ======================= -export const searchParamsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<ProcurementRfqsView>().withDefault([ - { id: "updatedAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 기본 필터 (RFQFilterBox) - 새로운 필드 추가 - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - search: parseAsString.withDefault(""), - from: parseAsString.withDefault(""), - to: parseAsString.withDefault(""), -}); - -export type GetPORfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; - - -export const searchParamsVendorRfqCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<ProcurementVendorQuotations>().withDefault([ - { id: "updatedAt", desc: true }, - ]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 기본 필터 (RFQFilterBox) - 새로운 필드 추가 - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - search: parseAsString.withDefault(""), - from: parseAsString.withDefault(""), - to: parseAsString.withDefault(""), -}); - -export type GetQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>;
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx deleted file mode 100644 index 69ba0363..00000000 --- a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx +++ /dev/null @@ -1,522 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X, - User, - Building -} from "lucide-react" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime, formatFileSize } from "@/lib/utils" -import { useSession } from "next-auth/react" -import { fetchBuyerVendorComments } from "../services" - -// 타입 정의 -interface Comment { - id: number; - rfqId: number; - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string; - isVendorComment: boolean | null; // null 허용으로 변경 - createdAt: Date; - updatedAt: Date; - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[]; - isRead: boolean | null // null 허용으로 변경 -} - -interface Attachment { - id: number; - fileName: string; - fileSize: number; - fileType: string | null; // null 허용으로 변경 - filePath: string; - uploadedAt: Date; -} - -// 프롭스 정의 -interface BuyerCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - quotation: { - id: number; - rfqId: number; - vendorId: number; - quotationCode: string; - rfq?: { - rfqCode: string; - }; - } | null; - onSuccess?: () => void; -} - - - -// 벤더 코멘트 전송 함수 -export function sendVendorCommentClient(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<Comment> { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 (벤더 API 경로) - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - // API 호출 - return fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }) - .then(response => { - if (!response.ok) { - return response.text().then(text => { - throw new Error(`API 요청 실패: ${response.status} ${text}`); - }); - } - return response.json(); - }) - .then(result => { - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - return result.data.comment; - }); -} - - -export function BuyerCommunicationDrawer({ - open, - onOpenChange, - quotation, - onSuccess -}: BuyerCommunicationDrawerProps) { - // 세션 정보 - const { data: session } = useSession(); - - // 상태 관리 - const [comments, setComments] = useState<Comment[]>([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState<File[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - const messagesEndRef = useRef<HTMLDivElement>(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); - - // 드로어가 열릴 때 데이터 로드 - useEffect(() => { - if (open && quotation) { - loadComments(); - } - }, [open, quotation]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 코멘트 로드 함수 - const loadComments = async () => { - if (!quotation) return; - - try { - setIsLoading(true); - - // API를 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정 - } catch (error) { - console.error("코멘트 로드 오류:", error); - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); - } finally { - setIsLoading(false); - } - }; - - // 파일 선택 핸들러 - const handleFileSelect = () => { - fileInputRef.current?.click(); - }; - - // 파일 변경 핸들러 - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files && e.target.files.length > 0) { - const newFiles = Array.from(e.target.files); - setAttachments(prev => [...prev, ...newFiles]); - } - }; - - // 파일 제거 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)); - }; - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - if (!newComment.trim() && attachments.length === 0) return; - if (!quotation) return; - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendVendorCommentClient({ - rfqId: quotation.rfqId, - vendorId: quotation.vendorId, - content: newComment, - attachments: attachments - }); - - // 상태 업데이트 - setComments(prev => [...prev, newCommentObj]); - setNewComment(""); - setAttachments([]); - - toast.success("메시지가 전송되었습니다"); - - // 데이터 새로고침 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("코멘트 전송 오류:", error); - toast.error("메시지 전송 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - }; - - // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: Attachment) => { - setSelectedAttachment(attachment); - setPreviewDialogOpen(true); - }; - - // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: Attachment) => { - // 실제 다운로드 구현 - window.open(attachment.filePath, '_blank'); - }; - - // 파일 아이콘 선택 - const getFileIcon = (fileType: string) => { - if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; - if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return <FileText className="h-5 w-5 text-green-500" />; - if (fileType.includes("document") || fileType.includes("word")) - return <FileText className="h-5 w-5 text-blue-500" />; - return <File className="h-5 w-5 text-gray-500" />; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); - - return ( - <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {getFileIcon(selectedAttachment.fileType)} - {selectedAttachment.fileName} - </DialogTitle> - <DialogDescription> - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} - </DialogDescription> - </DialogHeader> - - <div className="min-h-[300px] flex items-center justify-center p-4"> - {isImage ? ( - <img - src={selectedAttachment.filePath} - alt={selectedAttachment.fileName} - className="max-h-[500px] max-w-full object-contain" - /> - ) : isPdf ? ( - <iframe - src={`${selectedAttachment.filePath}#toolbar=0`} - className="w-full h-[500px]" - title={selectedAttachment.fileName} - /> - ) : ( - <div className="flex flex-col items-center gap-4 p-8"> - {getFileIcon(selectedAttachment.fileType)} - <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> - <Button - variant="outline" - onClick={() => handleAttachmentDownload(selectedAttachment)} - > - <DownloadCloud className="h-4 w-4 mr-2" /> - 다운로드 - </Button> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); - }; - - if (!quotation) { - return null; - } - - // 구매자 정보 (실제로는 API에서 가져와야 함) - const buyerName = "구매 담당자"; - - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[85vh]"> - <DrawerHeader className="border-b"> - <DrawerTitle className="flex items-center gap-2"> - <Avatar className="h-8 w-8"> - <AvatarFallback className="bg-primary/10"> - <User className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - <div> - <span>{buyerName}</span> - <Badge variant="outline" className="ml-2">구매자</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode} - </DrawerDescription> - </DrawerHeader> - - <div className="p-0 flex flex-col h-[60vh]"> - {/* 메시지 목록 */} - <ScrollArea className="flex-1 p-4"> - {isLoading ? ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground">메시지 로딩 중...</p> - </div> - ) : comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <AlertCircle className="h-6 w-6 text-muted-foreground" /> - <p className="text-muted-foreground">아직 메시지가 없습니다</p> - </div> - </div> - ) : ( - <div className="space-y-4"> - {comments.map(comment => ( - <div - key={comment.id} - className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`} - > - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - <User className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment - ? 'bg-primary text-primary-foreground' - : 'bg-muted' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? ( - session?.user?.name || "벤더" - ) : ( - comment.userName || buyerName - )} - </div> - - {comment.content && ( - <div className="text-sm whitespace-pre-wrap break-words"> - {comment.content} - </div> - )} - - {/* 첨부파일 표시 */} - {comment.attachments.length > 0 && ( - <div className={`mt-2 pt-2 ${comment.isVendorComment - ? 'border-t border-t-primary-foreground/20' - : 'border-t border-t-border/30' - }`}> - {comment.attachments.map(attachment => ( - <div - key={attachment.id} - className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" - onClick={() => handleAttachmentPreview(attachment)} - > - {getFileIcon(attachment.fileType)} - <span className="flex-1 truncate">{attachment.fileName}</span> - <span className="text-xs opacity-70"> - {formatFileSize(attachment.fileSize)} - </span> - <Button - variant="ghost" - size="icon" - className="h-6 w-6 rounded-full" - onClick={(e) => { - e.stopPropagation(); - handleAttachmentDownload(attachment); - }} - > - <DownloadCloud className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - )} - - <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> - {formatDateTime(comment.createdAt)} - </div> - </div> - - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - <Building className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - )} - </div> - ))} - <div ref={messagesEndRef} /> - </div> - )} - </ScrollArea> - - {/* 선택된 첨부파일 표시 */} - {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2"> - <div className="text-xs font-medium mb-1">첨부파일</div> - <div className="flex flex-wrap gap-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> - {file.type.startsWith("image/") ? ( - <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> - ) : ( - <File className="h-4 w-4 mr-1 text-gray-500" /> - )} - <span className="truncate max-w-[100px]">{file.name}</span> - <Button - variant="ghost" - size="icon" - className="h-4 w-4 ml-1 p-0" - onClick={() => handleRemoveFile(index)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 메시지 입력 영역 */} - <div className="p-4 border-t"> - <div className="flex gap-2 items-end"> - <div className="flex-1"> - <Textarea - placeholder="메시지를 입력하세요..." - className="min-h-[80px] resize-none" - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - </div> - <div className="flex flex-col gap-2"> - <input - type="file" - ref={fileInputRef} - className="hidden" - multiple - onChange={handleFileChange} - /> - <Button - variant="outline" - size="icon" - onClick={handleFileSelect} - title="파일 첨부" - > - <Paperclip className="h-4 w-4" /> - </Button> - <Button - onClick={handleSubmitComment} - disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - </div> - - <DrawerFooter className="border-t"> - <div className="flex justify-between"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> - <DrawerClose asChild> - <Button variant="outline">닫기</Button> - </DrawerClose> - </div> - </DrawerFooter> - </DrawerContent> - - {renderAttachmentPreviewDialog()} - </Drawer> - ); -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx deleted file mode 100644 index 66bb2613..00000000 --- a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx +++ /dev/null @@ -1,955 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useMemo } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { format } from "date-fns" -import { toast } from "sonner" -import { MessageSquare, Paperclip } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" -import { DatePicker } from "@/components/ui/date-picker" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { Skeleton } from "@/components/ui/skeleton" - -import { formatCurrency, formatDate } from "@/lib/utils" -import { QuotationItemEditor } from "./quotation-item-editor" -import { - submitVendorQuotation, - updateVendorQuotation, - fetchCurrencies, - fetchPaymentTerms, - fetchIncoterms, - fetchBuyerVendorComments, - Comment -} from "../services" -import { BuyerCommunicationDrawer } from "./buyer-communication-drawer" - -// 견적서 폼 스키마 -const quotationFormSchema = z.object({ - quotationVersion: z.number().min(1), - // 필수값 표시됨 - currency: z.string().min(1, "통화를 선택해주세요"), - // 필수값 표시됨 - validUntil: z.date({ - required_error: "견적 유효기간을 선택해주세요", - invalid_type_error: "유효한 날짜를 선택해주세요", - }), - // 필수값 표시됨 - estimatedDeliveryDate: z.date({ - required_error: "예상 납품일을 선택해주세요", - invalid_type_error: "유효한 날짜를 선택해주세요", - }), - // 필수값 표시됨 - paymentTermsCode: z.string({ - required_error: "지불 조건을 선택해주세요", - }).min(1, "지불 조건을 선택해주세요"), - // 필수값 표시됨 - incotermsCode: z.string({ - required_error: "인코텀즈를 선택해주세요", - }).min(1, "인코텀즈를 선택해주세요"), - // 필수값 아님 - incotermsDetail: z.string().optional(), - // 필수값 아님 - remark: z.string().optional(), -}) - -type QuotationFormValues = z.infer<typeof quotationFormSchema> - -// 데이터 타입 정의 -interface Currency { - code: string - name: string -} - -interface PaymentTerm { - code: string - description: string -} - -interface Incoterm { - code: string - description: string -} - -// 이 컴포넌트에 전달되는 견적서 데이터 타입 -interface VendorQuotation { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - quotationVersion: number | null - totalItemsCount: number | null - subTotal: string| null - taxTotal: string| null - discountTotal: string| null - totalPrice: string| null - currency: string| null - validUntil: Date | null - estimatedDeliveryDate: Date | null - paymentTermsCode: string | null - incotermsCode: string | null - incotermsDetail: string | null - status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted" - remark: string | null - rejectionReason: string | null - submittedAt: Date | null - acceptedAt: Date | null - createdAt: Date - updatedAt: Date - rfq: { - id: number - rfqCode: string| null - dueDate: Date | null - status: string| null - // 기타 필요한 정보 - } - vendor: { - id: number - vendorName: string - vendorCode: string| null - // 기타 필요한 정보 - } - items: QuotationItem[] -} - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null - materialDescription: string | null - quantity: number - uom: string | null - unitPrice: number - totalPrice: number - currency: string - vendorMaterialCode: string | null - vendorMaterialDescription: string | null - deliveryDate: Date | null - leadTimeInDays: number | null - taxRate: number | null - taxAmount: number | null - discountRate: number | null - discountAmount: number | null - remark: string | null - isAlternative: boolean - isRecommended: boolean - createdAt: Date - updatedAt: Date - prItem?: { - id: number - materialCode: string | null - materialDescription: string | null - // 기타 필요한 정보 - } -} - -// 견적서 편집 컴포넌트 프롭스 -interface VendorQuotationEditorProps { - quotation: VendorQuotation -} - -export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) { - - - console.log(quotation) - - const [activeTab, setActiveTab] = useState("items") - const [isSubmitting, setIsSubmitting] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [items, setItems] = useState<QuotationItem[]>(quotation.items || []) - - // 서버에서 가져온 데이터 상태 - const [currencies, setCurrencies] = useState<Currency[]>([]) - const [paymentTerms, setPaymentTerms] = useState<PaymentTerm[]>([]) - const [incoterms, setIncoterms] = useState<Incoterm[]>([]) - - // 데이터 로딩 상태 - const [loadingCurrencies, setLoadingCurrencies] = useState(true) - const [loadingPaymentTerms, setLoadingPaymentTerms] = useState(true) - const [loadingIncoterms, setLoadingIncoterms] = useState(true) - - // 커뮤니케이션 드로어 상태 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - - const [comments, setComments] = useState<Comment[]>([]); - const [unreadCount, setUnreadCount] = useState(0); - const [loadingComments, setLoadingComments] = useState(false); - - // 컴포넌트 마운트 시 메시지 미리 로드 - useEffect(() => { - if (quotation) { - loadCommunicationData(); - } - }, [quotation]); - - // 메시지 데이터 로드 함수 - const loadCommunicationData = async () => { - try { - setLoadingComments(true); - const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽지 않은 메시지 수 계산 - const unread = commentsData.filter( - comment => !comment.isVendorComment && !comment.isRead - ).length; - setUnreadCount(unread); - } catch (error) { - console.error("메시지 데이터 로드 오류:", error); - } finally { - setLoadingComments(false); - } - }; - - // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 - const handleCommunicationDrawerChange = (open: boolean) => { - setCommunicationDrawerOpen(open); - if (!open) { - loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 - } - }; - - // 버튼 비활성화 - const isBeforeDueDate = () => { - if (!quotation.rfq.dueDate) { - // dueDate가 null인 경우 기본적으로 수정 불가능하도록 설정 (false 반환) - return false; - } - - const now = new Date(); - const dueDate = new Date(quotation.rfq.dueDate); - return now < dueDate; - }; - // 수정된 isDisabled 조건 - const isDisabled = (quotation.status === "Accepted") || - ((quotation.status === "Submitted" || quotation.status === "Revised") && - !isBeforeDueDate()); - - - // 견적서 총합 계산 - const totals = useMemo(() => { - const subTotal = items.reduce((sum, item) => sum + Number(item.totalPrice), 0) - const taxTotal = items.reduce((sum, item) => sum + (Number(item.taxAmount) || 0), 0) - const discountTotal = items.reduce((sum, item) => sum + (Number(item.discountAmount) || 0), 0) - const totalPrice = subTotal + taxTotal - discountTotal - - return { - subTotal, - taxTotal, - discountTotal, - totalPrice - } - }, [items]) - - // 폼 설정 - const form = useForm<QuotationFormValues>({ - resolver: zodResolver(quotationFormSchema), - defaultValues: { - quotationVersion: quotation.quotationVersion || 0, - currency: quotation.currency || "KRW", - validUntil: quotation.validUntil || undefined, - estimatedDeliveryDate: quotation.estimatedDeliveryDate || undefined, - paymentTermsCode: quotation.paymentTermsCode || "", - incotermsCode: quotation.incotermsCode || "", - incotermsDetail: quotation.incotermsDetail || "", - remark: quotation.remark || "", - }, - mode: "onChange", // 실시간 검증 활성화 - }) - - // 마운트 시 데이터 로드 - useEffect(() => { - // 통화 데이터 로드 - const loadCurrencies = async () => { - try { - setLoadingCurrencies(true) - const result = await fetchCurrencies() - if (result.success) { - setCurrencies(result.data) - } else { - toast.error(result.message || "통화 데이터 로드 실패") - } - } catch (error) { - console.error("통화 데이터 로드 오류:", error) - toast.error("통화 데이터를 불러오는 중 오류가 발생했습니다") - } finally { - setLoadingCurrencies(false) - } - } - - // 지불 조건 데이터 로드 - const loadPaymentTerms = async () => { - try { - setLoadingPaymentTerms(true) - const result = await fetchPaymentTerms() - if (result.success) { - setPaymentTerms(result.data) - } else { - toast.error(result.message || "지불 조건 데이터 로드 실패") - } - } catch (error) { - console.error("지불 조건 데이터 로드 오류:", error) - toast.error("지불 조건 데이터를 불러오는 중 오류가 발생했습니다") - } finally { - setLoadingPaymentTerms(false) - } - } - - // 인코텀즈 데이터 로드 - const loadIncoterms = async () => { - try { - setLoadingIncoterms(true) - const result = await fetchIncoterms() - if (result.success) { - setIncoterms(result.data) - } else { - toast.error(result.message || "인코텀즈 데이터 로드 실패") - } - } catch (error) { - console.error("인코텀즈 데이터 로드 오류:", error) - toast.error("인코텀즈 데이터를 불러오는 중 오류가 발생했습니다") - } finally { - setLoadingIncoterms(false) - } - } - - // 함수 호출 - loadCurrencies() - loadPaymentTerms() - loadIncoterms() - }, []) - - // 견적서 저장 - const handleSave = async () => { - try { - setIsSaving(true) - - // 기본 검증 (통화는 필수) - const validationResult = await form.trigger(['currency']); - if (!validationResult) { - toast.warning("통화는 필수 항목입니다"); - return; - } - - const values = form.getValues() - - const result = await updateVendorQuotation({ - id: quotation.id, - ...values, - subTotal: totals.subTotal.toString(), - taxTotal: totals.taxTotal.toString(), - discountTotal: totals.discountTotal.toString(), - totalPrice: totals.totalPrice.toString(), - totalItemsCount: items.length, - }) - - if (result.success) { - toast.success("견적서가 저장되었습니다") - - // 견적서 제출 준비 상태 점검 - const formValid = await form.trigger(); - const itemsValid = !items.some(item => item.unitPrice <= 0 || !item.deliveryDate); - const alternativeItemsValid = !items.some(item => - item.isAlternative && (!item.vendorMaterialDescription || !item.remark) - ); - - if (formValid && itemsValid && alternativeItemsValid) { - toast.info("모든 필수 정보가 입력되었습니다. '견적서 제출' 버튼을 클릭하여 제출하세요."); - } else { - const missingFields = []; - if (!formValid) missingFields.push("견적서 기본 정보"); - if (!itemsValid) missingFields.push("견적 항목의 단가/납품일"); - if (!alternativeItemsValid) missingFields.push("대체품 정보"); - - toast.info(`제출하기 전에 다음 정보를 입력해주세요: ${missingFields.join(', ')}`); - } - } else { - toast.error(result.message || "견적서 저장 중 오류가 발생했습니다") - } - } catch (error) { - console.error("견적서 저장 오류:", error) - toast.error("견적서 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // 견적서 제출 - const handleSubmit = async () => { - try { - setIsSubmitting(true) - - // 1. 폼 스키마 검증 (기본 정보) - const formValid = await form.trigger(); - if (!formValid) { - const formState = form.getFieldState("validUntil"); - const estimatedDeliveryState = form.getFieldState("estimatedDeliveryDate"); - const paymentTermsState = form.getFieldState("paymentTermsCode"); - const incotermsState = form.getFieldState("incotermsCode"); - - // 주요 필드별 오류 메시지 표시 - if (!form.getValues("validUntil")) { - toast.error("견적 유효기간을 선택해주세요"); - } else if (!form.getValues("estimatedDeliveryDate")) { - toast.error("예상 납품일을 선택해주세요"); - } else if (!form.getValues("paymentTermsCode")) { - toast.error("지불 조건을 선택해주세요"); - } else if (!form.getValues("incotermsCode")) { - toast.error("인코텀즈를 선택해주세요"); - } else { - toast.error("견적서 기본 정보를 모두 입력해주세요"); - } - - // 견적 정보 탭으로 이동 - setActiveTab("details"); - return; - } - - // 2. 견적 항목 검증 - const emptyItems = items.filter(item => - item.unitPrice <= 0 || !item.deliveryDate - ); - - if (emptyItems.length > 0) { - toast.error(`${emptyItems.length}개 항목의 단가와 납품일을 입력해주세요`); - setActiveTab("items"); - return; - } - - // 3. 대체품 정보 검증 - const invalidAlternativeItems = items.filter(item => - item.isAlternative && (!item.vendorMaterialDescription || !item.remark) - ); - - if (invalidAlternativeItems.length > 0) { - toast.error(`${invalidAlternativeItems.length}개의 대체품 항목에 정보를 모두 입력해주세요`); - setActiveTab("items"); - return; - } - - // 모든 검증 통과 - 제출 진행 - const values = form.getValues(); - - const result = await submitVendorQuotation({ - id: quotation.id, - ...values, - subTotal: totals.subTotal.toString(), - taxTotal: totals.taxTotal.toString(), - discountTotal: totals.discountTotal.toString(), - totalPrice: totals.totalPrice.toString(), - totalItemsCount: items.length, - }); - - if (result.success && isBeforeDueDate()) { - toast.success("견적서가 제출되었습니다. 마감일 전까지 수정 가능합니다."); - - // 페이지 새로고침 - window.location.reload(); - } else { - toast.error(result.message || "견적서 제출 중 오류가 발생했습니다"); - } - } catch (error) { - console.error("견적서 제출 오류:", error); - toast.error("견적서 제출 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - } - - const isSubmitReady = () => { - // 폼 유효성 - const formValid = !Object.keys(form.formState.errors).length; - - // 항목 유효성 - const itemsValid = !items.some(item => - item.unitPrice <= 0 || !item.deliveryDate - ); - - // 대체품 유효성 - const alternativeItemsValid = !items.some(item => - item.isAlternative && (!item.vendorMaterialDescription || !item.remark) - ); - - // 유효하지 않은 항목 또는 대체품이 있으면 제출 불가 - return formValid && itemsValid && alternativeItemsValid; - } - - // 아이템 업데이트 핸들러 - const handleItemsUpdate = (updatedItems: QuotationItem[]) => { - setItems(updatedItems) - } - - // 상태에 따른 배지 색상 - const getStatusBadge = (status: string) => { - switch (status) { - case "Draft": - return <Badge variant="outline">초안</Badge> - case "Submitted": - return <Badge variant="default">제출됨</Badge> - case "Revised": - return <Badge variant="secondary">수정됨</Badge> - case "Rejected": - return <Badge variant="destructive">반려됨</Badge> - case "Accepted": - return <Badge variant="default">승인됨</Badge> - default: - return <Badge>{status}</Badge> - } - } - - // 셀렉트 로딩 상태 표시 컴포넌트 - const SelectSkeleton = () => ( - <div className="flex flex-col gap-2"> - <Skeleton className="h-4 w-[40%]" /> - <Skeleton className="h-10 w-full" /> - </div> - ) - - return ( - <div className="space-y-6"> - <div className="flex justify-between items-start"> - <div> - <h1 className="text-2xl font-bold tracking-tight">견적서 작성</h1> - <p className="text-muted-foreground"> - RFQ 번호: {quotation.rfq.rfqCode} | 견적서 번호: {quotation.quotationCode} - </p> - {quotation.rfq.dueDate ? ( - <p className={`text-sm ${isBeforeDueDate() ? 'text-green-600' : 'text-red-600'}`}> - 마감일: {formatDate(new Date(quotation.rfq.dueDate))} - {isBeforeDueDate() - ? ' (마감 전: 수정 가능)' - : ' (마감 됨: 수정 불가)'} - </p> - ) : ( - <p className="text-sm text-amber-600"> - 마감일이 설정되지 않았습니다 - </p> - )} - </div> - <div className="flex items-center gap-2"> - {getStatusBadge(quotation.status)} - {quotation.status === "Rejected" && ( - <div className="text-sm text-destructive"> - <span className="font-medium">반려 사유:</span> {quotation.rejectionReason || "사유 없음"} - </div> - )} - </div> - </div> - - <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> - <TabsList> - <TabsTrigger value="items">견적 항목</TabsTrigger> - <TabsTrigger value="details">견적 정보</TabsTrigger> - <TabsTrigger value="communication">커뮤니케이션</TabsTrigger> - </TabsList> - - {/* 견적 항목 탭 */} - <TabsContent value="items" className="p-0 pt-4"> - <Card> - <CardHeader> - <CardTitle>견적 항목 정보</CardTitle> - <CardDescription> - 각 항목에 대한 가격, 납품일 등을 입력해주세요 - </CardDescription> - </CardHeader> - <CardContent> - <QuotationItemEditor - items={items} - onItemsChange={handleItemsUpdate} - disabled={isDisabled} - currency={form.watch("currency")} - /> - </CardContent> - <CardFooter className="flex justify-between border-t p-4"> - <div className="space-y-1"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium">소계:</span> {formatCurrency(totals.subTotal, quotation.currency)} - </div> - <div className="text-sm text-muted-foreground"> - <span className="font-medium">세액:</span> {formatCurrency(totals.taxTotal, quotation.currency)} - </div> - <div className="text-sm text-muted-foreground"> - <span className="font-medium">할인액:</span> {formatCurrency(totals.discountTotal, quotation.currency)} - </div> - <div className="text-base font-bold"> - <span>총액:</span> {formatCurrency(totals.totalPrice, quotation.currency)} - </div> - </div> - <div className="flex space-x-2"> - <Button - variant="outline" - onClick={handleSave} - disabled={isDisabled || isSaving} - > - {isSaving ? "저장 중..." : "저장"} - </Button> - <Button - onClick={handleSubmit} - disabled={isDisabled || isSubmitting || !isSubmitReady()} - > - {isSubmitting ? "제출 중..." : "견적서 제출"} - </Button> - </div> - </CardFooter> - </Card> - </TabsContent> - - {/* 견적 정보 탭 */} - <TabsContent value="details" className="p-0 pt-4"> - <Form {...form}> - <form className="space-y-6"> - <Card> - <CardHeader> - <CardTitle>견적서 기본 정보</CardTitle> - <CardDescription> - 견적서의 일반 정보를 입력해주세요 - </CardDescription> - </CardHeader> - <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6"> - {/* 통화 필드 */} - {loadingCurrencies ? ( - <SelectSkeleton /> - ) : ( - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 통화 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isDisabled} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.code} ({currency.name}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - <FormField - control={form.control} - name="validUntil" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 견적 유효기간 - <span className="text-destructive ml-1">*</span> {/* 필수값 표시 */} - </FormLabel> - <FormControl> - <DatePicker - date={field.value} - onSelect={field.onChange} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="estimatedDeliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 예상 납품일 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <FormControl> - <DatePicker - date={field.value} - onSelect={field.onChange} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 지불 조건 필드 */} - {loadingPaymentTerms ? ( - <SelectSkeleton /> - ) : ( - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 지불 조건 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <Select - value={field.value || ""} - onValueChange={field.onChange} - disabled={isDisabled} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* 인코텀즈 필드 */} - {loadingIncoterms ? ( - <SelectSkeleton /> - ) : ( - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 인코텀즈 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <Select - value={field.value || ""} - onValueChange={field.onChange} - disabled={isDisabled} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.code} ({term.description}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 인코텀즈 상세 - <span className="text-destructive ml-1"></span> - </FormLabel> - <FormControl> - <Input - placeholder="인코텀즈 상세 정보 입력" - {...field} - value={field.value || ""} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="remark" - render={({ field }) => ( - <FormItem className="col-span-2"> - <FormLabel className="flex items-center"> - 비고 - <span className="text-destructive ml-1"></span> - </FormLabel> - <FormControl> - <Textarea - placeholder="추가 정보나 특이사항을 입력해주세요" - className="resize-none min-h-[100px]" - {...field} - value={field.value || ""} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - <CardFooter className="flex justify-end"> - <Button - variant="outline" - onClick={handleSave} - disabled={isDisabled || isSaving} - > - {isSaving ? "저장 중..." : "저장"} - </Button> - </CardFooter> - </Card> - </form> - </Form> - </TabsContent> - - {/* 커뮤니케이션 탭 */} - <TabsContent value="communication" className="p-0 pt-4"> - <Card> - <CardHeader className="flex flex-row items-center justify-between"> - <div> - <CardTitle className="flex items-center gap-2"> - 커뮤니케이션 - {unreadCount > 0 && ( - <Badge variant="destructive" className="ml-2"> - 새 메시지 {unreadCount} - </Badge> - )} - </CardTitle> - <CardDescription> - 구매자와의 메시지 및 첨부파일 - </CardDescription> - </div> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - variant="outline" - size="sm" - > - <MessageSquare className="h-4 w-4 mr-2" /> - {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"} - </Button> - </CardHeader> - <CardContent> - {loadingComments ? ( - <div className="flex items-center justify-center p-8"> - <div className="text-center"> - <Skeleton className="h-4 w-32 mx-auto mb-2" /> - <Skeleton className="h-4 w-48 mx-auto" /> - </div> - </div> - ) : comments.length === 0 ? ( - <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8"> - <div className="max-w-md"> - <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4"> - <MessageSquare className="h-6 w-6 text-primary" /> - </div> - <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3> - <p className="text-muted-foreground mb-4"> - 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요. - </p> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="mx-auto" - > - 메시지 보내기 - </Button> - </div> - </div> - ) : ( - <div className="space-y-4"> - {/* 최근 메시지 3개 미리보기 */} - <div className="space-y-2"> - <h3 className="text-sm font-medium">최근 메시지</h3> - <ScrollArea className="h-[250px] rounded-md border p-4"> - {comments.slice(-3).map(comment => ( - <div - key={comment.id} - className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead - ? 'bg-primary/10 border-l-4 border-primary' - : 'bg-muted/50' - }`} - > - <div className="flex justify-between items-center mb-1"> - <span className="text-sm font-medium"> - {comment.isVendorComment - ? '나' - : comment.userName || '구매 담당자'} - </span> - <span className="text-xs text-muted-foreground"> - {new Date(comment.createdAt).toLocaleDateString()} - </span> - </div> - <p className="text-sm line-clamp-2">{comment.content}</p> - {comment.attachments.length > 0 && ( - <div className="mt-1 text-xs text-muted-foreground"> - <Paperclip className="h-3 w-3 inline mr-1" /> - 첨부파일 {comment.attachments.length}개 - </div> - )} - </div> - ))} - </ScrollArea> - </div> - - <div className="flex justify-center"> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="w-full" - > - 전체 메시지 보기 ({comments.length}개) - </Button> - </div> - </div> - )} - </CardContent> - </Card> - - {/* 커뮤니케이션 드로어 */} - <BuyerCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={handleCommunicationDrawerChange} - quotation={quotation} - onSuccess={loadCommunicationData} - /> - </TabsContent> - </Tabs> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx deleted file mode 100644 index e11864dc..00000000 --- a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx +++ /dev/null @@ -1,664 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { format } from "date-fns" - -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Checkbox } from "@/components/ui/checkbox" -import { DatePicker } from "@/components/ui/date-picker" -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@/components/ui/tooltip" -import { - Info, - Clock, - CalendarIcon, - ClipboardCheck, - AlertTriangle, - CheckCircle2, - RefreshCw, - Save, - FileText, - Sparkles -} from "lucide-react" - -import { formatCurrency } from "@/lib/utils" -import { updateQuotationItem } from "../services" -import { Textarea } from "@/components/ui/textarea" - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null - materialDescription: string | null - quantity: number - uom: string | null - unitPrice: number - totalPrice: number - currency: string - vendorMaterialCode: string | null - vendorMaterialDescription: string | null - deliveryDate: Date | null - leadTimeInDays: number | null - taxRate: number | null - taxAmount: number | null - discountRate: number | null - discountAmount: number | null - remark: string | null - isAlternative: boolean - isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음 - createdAt: Date - updatedAt: Date - prItem?: { - id: number - materialCode: string | null - materialDescription: string | null - // 기타 필요한 정보 - } -} - -// debounce 함수 구현 -function debounce<T extends (...args: any[]) => any>( - func: T, - wait: number -): (...args: Parameters<T>) => void { - let timeout: NodeJS.Timeout | null = null; - - return function (...args: Parameters<T>) { - if (timeout) clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; -} - -interface QuotationItemEditorProps { - items: QuotationItem[] - onItemsChange: (items: QuotationItem[]) => void - disabled?: boolean - currency: string -} - -export function QuotationItemEditor({ - items, - onItemsChange, - disabled = false, - currency -}: QuotationItemEditorProps) { - const [editingItem, setEditingItem] = useState<number | null>(null) - const [isSaving, setIsSaving] = useState(false) - - // 저장이 필요한 항목들을 추적 - const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set()) - - // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음 - const updateLocalItem = <K extends keyof QuotationItem>( - index: number, - field: K, - value: QuotationItem[K] - ) => { - // 로컬 상태 업데이트 - const updatedItems = [...items] - const item = { ...updatedItems[index] } - - // 필드 업데이트 - item[field] = value - - // 대체품 체크 해제 시 관련 필드 초기화 - if (field === 'isAlternative' && value === false) { - item.vendorMaterialCode = null; - item.vendorMaterialDescription = null; - item.remark = null; - } - - // 단가나 수량이 변경되면 총액 계산 - if (field === 'unitPrice' || field === 'quantity') { - item.totalPrice = Number(item.unitPrice) * Number(item.quantity) - - // 세금이 있으면 세액 계산 - if (item.taxRate) { - item.taxAmount = item.totalPrice * (item.taxRate / 100) - } - - // 할인이 있으면 할인액 계산 - if (item.discountRate) { - item.discountAmount = item.totalPrice * (item.discountRate / 100) - } - } - - // 세율이 변경되면 세액 계산 - if (field === 'taxRate') { - item.taxAmount = item.totalPrice * (value as number / 100) - } - - // 할인율이 변경되면 할인액 계산 - if (field === 'discountRate') { - item.discountAmount = item.totalPrice * (value as number / 100) - } - - // 변경된 아이템으로 교체 - updatedItems[index] = item - - // 미저장 항목으로 표시 - setPendingChanges(prev => new Set(prev).add(item.id)) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - // 저장 필요함을 표시 - return item - } - - // 서버에 저장하는 함수 - const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => { - if (disabled) return - - try { - setIsSaving(true) - - const result = await updateQuotationItem({ - id: item.id, - [field]: value, - totalPrice: item.totalPrice, - taxAmount: item.taxAmount ?? 0, - discountAmount: item.discountAmount ?? 0 - }) - - // 저장 완료 후 pendingChanges에서 제거 - setPendingChanges(prev => { - const newSet = new Set(prev) - newSet.delete(item.id) - return newSet - }) - - if (!result.success) { - toast.error(result.message || "항목 저장 중 오류가 발생했습니다") - } - } catch (error) { - console.error("항목 저장 오류:", error) - toast.error("항목 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // debounce된 저장 함수 - const debouncedSave = useRef(debounce( - (item: QuotationItem, field: keyof QuotationItem, value: any) => { - saveItemToServer(item, field, value) - }, - 800 // 800ms 지연 - )).current - - // 견적 항목 업데이트 함수 - const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => { - const updatedItem = updateLocalItem(index, field, value) - - // debounce를 통해 서버 저장 지연 - if (!disabled) { - debouncedSave(updatedItem, field, value) - } - } - - // 모든 변경 사항 저장 - const saveAllChanges = async () => { - if (disabled || pendingChanges.size === 0) return - - setIsSaving(true) - toast.info(`${pendingChanges.size}개 항목 저장 중...`) - - try { - // 변경된 모든 항목 저장 - for (const itemId of pendingChanges) { - const index = items.findIndex(item => item.id === itemId) - if (index !== -1) { - const item = items[index] - await updateQuotationItem({ - id: item.id, - unitPrice: item.unitPrice, - totalPrice: item.totalPrice, - taxRate: item.taxRate ?? 0, - taxAmount: item.taxAmount ?? 0, - discountRate: item.discountRate ?? 0, - discountAmount: item.discountAmount ?? 0, - deliveryDate: item.deliveryDate, - leadTimeInDays: item.leadTimeInDays ?? 0, - vendorMaterialCode: item.vendorMaterialCode ?? "", - vendorMaterialDescription: item.vendorMaterialDescription ?? "", - isAlternative: item.isAlternative, - isRecommended: false, // 항상 false로 설정 (사용하지 않음) - remark: item.remark ?? "" - }) - } - } - - // 모든 변경 사항 저장 완료 - setPendingChanges(new Set()) - toast.success("모든 변경 사항이 저장되었습니다") - } catch (error) { - console.error("변경 사항 저장 오류:", error) - toast.error("변경 사항 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후) - const handleBlur = (index: number, field: keyof QuotationItem, value: any) => { - const itemId = items[index].id - - // 해당 항목이 pendingChanges에 있다면 즉시 저장 - if (pendingChanges.has(itemId)) { - const item = items[index] - saveItemToServer(item, field, value) - } - } - - // 전체 단가 업데이트 (일괄 반영) - const handleBulkUnitPriceUpdate = () => { - if (items.length === 0) return - - // 첫 번째 아이템의 단가 가져오기 - const firstUnitPrice = items[0].unitPrice - - if (!firstUnitPrice) { - toast.error("첫 번째 항목의 단가를 먼저 입력해주세요") - return - } - - // 모든 아이템에 동일한 단가 적용 - const updatedItems = items.map(item => ({ - ...item, - unitPrice: firstUnitPrice, - totalPrice: firstUnitPrice * item.quantity, - taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount, - discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount - })) - - // 모든 아이템을 변경 필요 항목으로 표시 - setPendingChanges(new Set(updatedItems.map(item => item.id))) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.") - } - - // 입력 핸들러 - const handleNumberInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement> - ) => { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - handleItemUpdate(index, field, value) - } - - const handleTextInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - handleItemUpdate(index, field, e.target.value) - } - - const handleDateChange = ( - index: number, - field: keyof QuotationItem, - date: Date | undefined - ) => { - handleItemUpdate(index, field, date || null) - } - - const handleCheckboxChange = ( - index: number, - field: keyof QuotationItem, - checked: boolean - ) => { - handleItemUpdate(index, field, checked) - } - - // 날짜 형식 지정 - const formatDeliveryDate = (date: Date | null) => { - if (!date) return "-" - return format(date, "yyyy-MM-dd") - } - - // 입력 폼 필드 렌더링 - const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => { - if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') { - return ( - <Input - type="number" - min={0} - step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1} - value={item[field] as number || 0} - onChange={(e) => handleNumberInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)} - disabled={disabled || isSaving} - className="w-full" - /> - ) - } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') { - return ( - <Input - type="text" - value={item[field] as string || ''} - onChange={(e) => handleTextInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, e.target.value)} - disabled={disabled || isSaving || !item.isAlternative} - className="w-full" - placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"} - /> - ) - } else if (field === 'deliveryDate') { - return ( - <DatePicker - date={item.deliveryDate ? new Date(item.deliveryDate) : undefined} - onSelect={(date) => { - handleDateChange(index, field, date); - // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거 - if (date) handleBlur(index, field, date); - }} - disabled={disabled || isSaving} - /> - ) - } else if (field === 'isAlternative') { - return ( - <div className="flex items-center gap-1"> - <Checkbox - checked={item.isAlternative} - onCheckedChange={(checked) => { - handleCheckboxChange(index, field, checked as boolean); - handleBlur(index, field, checked as boolean); - }} - disabled={disabled || isSaving} - /> - <span className="text-xs">대체품</span> - </div> - ) - } - - return null - } - - // 대체품 필드 렌더링 - const renderAlternativeFields = (item: QuotationItem, index: number) => { - if (!item.isAlternative) return null; - - return ( - <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm"> - {/* <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재코드</label> - <Input - value={item.vendorMaterialCode || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재코드 입력" - /> - </div> */} - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재명</label> - <Input - value={item.vendorMaterialDescription || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재명 입력" - /> - </div> - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">대체품 설명</label> - <Textarea - value={item.remark || ""} - onChange={(e) => handleTextInputChange(index, 'remark', e)} - onBlur={(e) => handleBlur(index, 'remark', e.target.value)} - disabled={disabled || isSaving} - className="min-h-[60px] text-sm" - placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요" - /> - </div> - </div> - ); - }; - - // 항목의 저장 상태 아이콘 표시 - const renderSaveStatus = (itemId: number) => { - if (pendingChanges.has(itemId)) { - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" /> - </TooltipTrigger> - <TooltipContent> - <p>저장되지 않은 변경 사항이 있습니다</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - } - - return null - } - - return ( - <div className="space-y-4"> - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2"> - <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3> - {pendingChanges.size > 0 && ( - <Badge variant="outline" className="bg-yellow-50"> - 변경 {pendingChanges.size}개 - </Badge> - )} - </div> - - <div className="flex items-center gap-2"> - {pendingChanges.size > 0 && !disabled && ( - <Button - variant="default" - size="sm" - onClick={saveAllChanges} - disabled={isSaving} - > - {isSaving ? ( - <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> - ) : ( - <Save className="h-4 w-4 mr-2" /> - )} - 변경사항 저장 ({pendingChanges.size}개) - </Button> - )} - - {!disabled && ( - <Button - variant="outline" - size="sm" - onClick={handleBulkUnitPriceUpdate} - disabled={items.length === 0 || isSaving} - > - 첫 항목 단가로 일괄 적용 - </Button> - )} - </div> - </div> - - <ScrollArea className="h-[500px] rounded-md border"> - <Table> - <TableHeader className="sticky top-0 bg-background"> - <TableRow> - <TableHead className="w-[50px]">번호</TableHead> - <TableHead>자재코드</TableHead> - <TableHead>자재명</TableHead> - <TableHead>수량</TableHead> - <TableHead>단위</TableHead> - <TableHead>단가</TableHead> - <TableHead>금액</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 세율(%) - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 납품일 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>납품 가능한 날짜를 선택해주세요.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead>리드타임(일)</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 대체품 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p> - <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead className="w-[50px]">상태</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {items.length === 0 ? ( - <TableRow> - <TableCell colSpan={12} className="text-center py-10"> - 견적 항목이 없습니다 - </TableCell> - </TableRow> - ) : ( - items.map((item, index) => ( - <React.Fragment key={item.id}> - <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}> - <TableCell> - {index + 1} - </TableCell> - <TableCell> - {item.materialCode || "-"} - </TableCell> - <TableCell> - <div className="font-medium max-w-xs truncate"> - {item.materialDescription || "-"} - </div> - </TableCell> - <TableCell> - {item.quantity} - </TableCell> - <TableCell> - {item.uom || "-"} - </TableCell> - <TableCell> - {renderInputField(item, index, 'unitPrice')} - </TableCell> - <TableCell> - {formatCurrency(item.totalPrice, currency)} - </TableCell> - <TableCell> - {renderInputField(item, index, 'taxRate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'deliveryDate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'leadTimeInDays')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'isAlternative')} - </TableCell> - <TableCell> - {renderSaveStatus(item.id)} - </TableCell> - </TableRow> - - {/* 대체품으로 선택된 경우 추가 정보 행 표시 */} - {item.isAlternative && ( - <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}> - <TableCell colSpan={1}></TableCell> - <TableCell colSpan={10}> - {renderAlternativeFields(item, index)} - </TableCell> - <TableCell colSpan={1}></TableCell> - </TableRow> - )} - </React.Fragment> - )) - )} - </TableBody> - </Table> - </ScrollArea> - - {isSaving && ( - <div className="flex items-center justify-center text-sm text-muted-foreground"> - <Clock className="h-4 w-4 animate-spin mr-2" /> - 변경 사항을 저장 중입니다... - </div> - )} - - <div className="bg-muted p-4 rounded-md"> - <h4 className="text-sm font-medium mb-2">안내 사항</h4> - <ul className="text-sm space-y-1 text-muted-foreground"> - <li className="flex items-start gap-2"> - <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>단가와 납품일은 필수로 입력해야 합니다.</span> - </li> - <li className="flex items-start gap-2"> - <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span> - </li> - <li className="flex items-start gap-2"> - <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span> - </li> - </ul> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx deleted file mode 100644 index 1fb225d8..00000000 --- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx +++ /dev/null @@ -1,333 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, FileText, Pencil, Edit, Trash2 } from "lucide-react" -import { formatCurrency, formatDate, formatDateTime } 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, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import Link from "next/link" -import { ProcurementVendorQuotations } from "@/db/schema" -import { useRouter } from "next/navigation" - -// 상태에 따른 배지 컴포넌트 -function StatusBadge({ status }: { status: string }) { - switch (status) { - case "Draft": - return <Badge variant="outline">초안</Badge> - case "Submitted": - return <Badge variant="default">제출됨</Badge> - case "Revised": - return <Badge variant="secondary">수정됨</Badge> - case "Rejected": - return <Badge variant="destructive">반려됨</Badge> - case "Accepted": - return <Badge variant="default">승인됨</Badge> - default: - return <Badge>{status}</Badge> - } -} - -interface QuotationWithRfqCode extends ProcurementVendorQuotations { - rfqCode?: string; - rfq?: { - id?: number; - rfqCode?: string; - status?: string; - dueDate?: Date | string | null; - rfqSendDate?: Date | string | null; - item?: { - id?: number; - itemCode?: string; - itemName?: string; - } | null; - } | null; - vendor?: { - id?: number; - vendorName?: string; - vendorCode?: string; - } | null; -} - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<QuotationWithRfqCode> | null> - > - router: NextRouter -} - -/** - * tanstack table 컬럼 정의 (RfqsTable 스타일) - */ -export function getColumns({ - setRowAction, - router, -}: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<QuotationWithRfqCode> = { - 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 컬럼 - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<QuotationWithRfqCode> = { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const id = row.original.id - const code = row.getValue("quotationCode") as string - const tooltipText = `${code} 작성하기` - - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={() => router.push(`/partners/rfq-ship/${id}`)} - className="h-8 w-8" - > - <Edit className="h-4 w-4" /> - <span className="sr-only">견적서 작성</span> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - }, - size: 50, - } - - // ---------------------------------------------------------------- - // 3) 컬럼 정의 배열 - // ---------------------------------------------------------------- - const columnDefinitions = [ - { - id: "quotationCode", - label: "RFQ 번호", - group: null, - size: 150, - minSize: 100, - maxSize: 200, - }, - { - id: "quotationVersion", - label: "RFQ 버전", - group: null, - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "itemCode", - label: "자재 그룹 코드", - group: "RFQ 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "itemName", - label: "자재 이름", - group: "RFQ 정보", - // size를 제거하여 유연한 크기 조정 허용 - minSize: 150, - maxSize: 300, - }, - { - id: "rfqSendDate", - label: "RFQ 송부일", - group: "날짜 정보", - size: 150, - minSize: 120, - maxSize: 180, - }, - { - id: "dueDate", - label: "RFQ 마감일", - group: "날짜 정보", - size: 150, - minSize: 120, - maxSize: 180, - }, - { - id: "status", - label: "상태", - group: null, - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "totalPrice", - label: "총액", - group: null, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "submittedAt", - label: "제출일", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "validUntil", - label: "유효기간", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - ]; - - // ---------------------------------------------------------------- - // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성) - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<QuotationWithRfqCode>[]> = {} - - columnDefinitions.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // 개별 컬럼 정의 - const columnDef: ColumnDef<QuotationWithRfqCode> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - cell: ({ row, cell }) => { - // 각 컬럼별 특별한 렌더링 처리 - switch (cfg.id) { - case "quotationCode": - return row.original.quotationCode || "-" - - case "quotationVersion": - return row.original.quotationVersion || "-" - - case "itemCode": - const itemCode = row.original.rfq?.item?.itemCode; - return itemCode ? itemCode : "-"; - - case "itemName": - const itemName = row.original.rfq?.item?.itemName; - return itemName ? itemName : "-"; - - case "rfqSendDate": - const sendDate = row.original.rfq?.rfqSendDate; - return sendDate ? formatDateTime(new Date(sendDate)) : "-"; - - case "dueDate": - const dueDate = row.original.rfq?.dueDate; - return dueDate ? formatDateTime(new Date(dueDate)) : "-"; - - case "status": - return <StatusBadge status={row.getValue("status") as string} /> - - case "totalPrice": - const price = parseFloat(row.getValue("totalPrice") as string || "0") - const currency = row.original.currency - return formatCurrency(price, currency) - - case "submittedAt": - const submitDate = row.getValue("submittedAt") as string | null - return submitDate ? formatDate(new Date(submitDate)) : "-" - - case "validUntil": - const validDate = row.getValue("validUntil") as string | null - return validDate ? formatDate(new Date(validDate)) : "-" - - default: - return row.getValue(cfg.id) ?? "" - } - }, - size: cfg.size, - minSize: cfg.minSize, - maxSize: cfg.maxSize, - } - - groupMap[groupName].push(columnDef) - }) - - // ---------------------------------------------------------------- - // 5) 그룹별 중첩 컬럼 생성 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<QuotationWithRfqCode>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // 그룹이 없는 컬럼들은 직접 추가 - nestedColumns.push(...colDefs) - } else { - // 그룹이 있는 컬럼들은 중첩 구조로 추가 - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - actionsColumn, - ] -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx deleted file mode 100644 index 7ea0c69e..00000000 --- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// lib/vendor-quotations/vendor-quotations-table.tsx -"use client" - -import * as React from "react" -import { type DataTableAdvancedFilterField, type DataTableFilterField, type 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 { Button } from "@/components/ui/button" -import { ProcurementVendorQuotations } from "@/db/schema" -import { useRouter } from "next/navigation" -import { getColumns } from "./vendor-quotations-table-columns" - -interface QuotationWithRfqCode extends ProcurementVendorQuotations { - rfqCode?: string; - rfq?: { - rfqCode?: string; - } | null; -} - -interface VendorQuotationsTableProps { - promises: Promise<[{ data: ProcurementVendorQuotations[], pageCount: number }]>; -} - -export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) { - const [{ data, pageCount }] = React.use(promises); - const router = useRouter(); - - console.log(data ,"data") - - // 선택된 행 액션 상태 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<QuotationWithRfqCode> | null>(null); - - // 테이블 컬럼 정의 - const columns = React.useMemo(() => getColumns({ - setRowAction, - router, - }), [setRowAction, router]); - - // 상태별 견적서 수 계산 - const statusCounts = React.useMemo(() => { - return { - Draft: data.filter(q => q.status === "Draft").length, - Submitted: data.filter(q => q.status === "Submitted").length, - Revised: data.filter(q => q.status === "Revised").length, - Rejected: data.filter(q => q.status === "Rejected").length, - Accepted: data.filter(q => q.status === "Accepted").length, - }; - }, [data]); - - // 필터 필드 - const filterFields: DataTableFilterField<QuotationWithRfqCode>[] = [ - { - id: "status", - label: "상태", - options: [ - { label: "초안", value: "Draft", count: statusCounts.Draft }, - { label: "제출됨", value: "Submitted", count: statusCounts.Submitted }, - { label: "수정됨", value: "Revised", count: statusCounts.Revised }, - { label: "반려됨", value: "Rejected", count: statusCounts.Rejected }, - { label: "승인됨", value: "Accepted", count: statusCounts.Accepted }, - ] - }, - { - id: "quotationCode", - label: "견적서 번호", - placeholder: "견적서 번호 검색...", - }, - { - id: "rfqCode", - label: "RFQ 번호", - placeholder: "RFQ 번호 검색...", - } - ]; - - // 고급 필터 필드 - const advancedFilterFields: DataTableAdvancedFilterField<QuotationWithRfqCode>[] = [ - { - id: "quotationCode", - label: "견적서 번호", - type: "text", - }, - { - id: "rfqCode", - label: "RFQ 번호", - type: "text", - }, - { - id: "status", - label: "상태", - type: "multi-select", - options: [ - { label: "초안", value: "Draft" }, - { label: "제출됨", value: "Submitted" }, - { label: "수정됨", value: "Revised" }, - { label: "반려됨", value: "Rejected" }, - { label: "승인됨", value: "Accepted" }, - ], - }, - { - id: "validUntil", - label: "유효기간", - type: "date", - }, - { - id: "submittedAt", - label: "제출일", - type: "date", - }, - ]; - - // useDataTable 훅 사용 (RfqsTable 스타일로 개선) - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - enableColumnResizing: true, // 컬럼 크기 조정 허용 - columnResizeMode: 'onChange', // 실시간 크기 조정 - initialState: { - sorting: [{ id: "updatedAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - defaultColumn: { - minSize: 50, - maxSize: 500, - }, - }); - - return ( - <div className="w-full"> - <div className="overflow-x-auto"> - <DataTable - table={table} - className="min-w-full" - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - </DataTableAdvancedToolbar> - </DataTable> - </div> - </div> - ); -}
\ No newline at end of file diff --git a/lib/projects/service.ts b/lib/projects/service.ts index aad1856e..ba6e730a 100644 --- a/lib/projects/service.ts +++ b/lib/projects/service.ts @@ -1,13 +1,11 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { revalidateTag, unstable_noStore } from "next/cache"; -import db from "@/db/db"; -import { unstable_cache } from "@/lib/unstable-cache"; +import db from "@/db/db"; +import { projects, type Project } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; -import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { and, asc, desc, eq, ilike, or } from "drizzle-orm"; import { countProjectLists, selectProjectLists } from "./repository"; -import { projects } from "@/db/schema"; import { GetProjectListsSchema } from "./validation"; export async function getProjectLists(input: GetProjectListsSchema) { @@ -132,4 +130,29 @@ export async function getProjectCode(projectId: number): Promise<string | null> console.error("Error fetching project code:", error) return null } -}
\ No newline at end of file +} + +export async function getProjects(): Promise<Project[]> { + try { + // 트랜잭션을 사용하여 프로젝트 데이터 조회 + const projectList = await db.transaction(async (tx) => { + // 모든 프로젝트 조회 + const results = await tx + .select({ + id: projects.id, + projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 + projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 + type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정 + }) + .from(projects) + .orderBy(projects.code); + + return results; + }); + + return projectList; + } catch (error) { + console.error("프로젝트 목록 가져오기 실패:", error); + return []; // 오류 발생 시 빈 배열 반환 + } +} diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx deleted file mode 100644 index aa244c75..00000000 --- a/lib/rfqs/cbe-table/cbe-table-columns.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } 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, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - - -import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (responseId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithCbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "responseStatus") { - const statusVal = row.original.responseStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) CBE Updated (날짜) - if (cfg.id === "respondedAt" ) { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal, "KR") - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - -// ---------------------------------------------------------------- -// 3) Comments 컬럼 -// ---------------------------------------------------------------- -const commentsColumn: ColumnDef<VendorWithCbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.responseId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize:80 -} - - - - -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - commentsColumn, - // actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx deleted file mode 100644 index fbcf9af9..00000000 --- a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { InviteVendorsDialog } from "./invite-vendors-dialog" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithCbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.commercialResponseStatus === null); - }, [table.getFilteredSelectedRowModel().rows]); - - return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && - ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) - } - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - 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/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx deleted file mode 100644 index 37fbc3f4..00000000 --- a/lib/rfqs/cbe-table/cbe-table.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"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 { fetchRfqAttachmentsbyCommentId, getCBE } from "../service" -import { getColumns } from "./cbe-table-columns" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { CommentSheet, CbeComment } from "./comments-sheet" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 -import { VendorContactsDialog } from "./vendor-contact-dialog" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getCBE>>, - ] - > - rfqId: number -} - - -export function CbeTable({ promises, rfqId }: VendorsTableProps) { - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const { data: session } = useSession() // 세션 정보 가져오기 - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - const currentUser = session?.user - - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - - // **router** 획득 - const router = useRouter() - - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) - // console.log("selectedVendorId", selectedVendorId) - // console.log("selectedCbeId", selectedCbeId) - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - async function openCommentSheet(responseId: number) { - setInitialComments([]) - setIsLoadingComments(true) - const comments = rowAction?.row.original.comments - // const rfqId = rowAction?.row.original.rfqId - const vendorId = rowAction?.row.original.vendorId - - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 - setInitialComments(commentWithAttachments) - } - - // if(rfqId){ setSelectedRfqIdForComments(rfqId)} - if(vendorId){ setSelectedVendorId(vendorId)} - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - setIsLoadingComments(false) - } - - const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) - } - - // getColumns() 호출 시, router를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), - [setRowAction, router] - ) - - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [ - ] - - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "respondedAt", label: "Updated at", type: "date" }, - ] - - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["comments"] }, - }, - getRowId: (originalRow) => String(originalRow.responseId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - - <CommentSheet - currentUserId={currentUserId} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={rfqId} - cbeId={selectedCbeId ?? 0} - vendorId={selectedVendorId ?? 0} - isLoading={isLoadingComments} - initialComments={initialComments} - /> - - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={rfqId} - open={rowAction?.type === "invite"} - showTrigger={false} - currentUser={currentUser} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - - </> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/comments-sheet.tsx b/lib/rfqs/cbe-table/comments-sheet.tsx deleted file mode 100644 index b040d734..00000000 --- a/lib/rfqs/cbe-table/comments-sheet.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { createRfqCommentWithAttachments } from "../service" -import { formatDate } from "@/lib/utils" - - -export interface CbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: CbeComment[] - currentUserId: number - rfqId: number - // tbeId?: number - cbeId?: number - vendorId: number - onCommentsUpdated?: (comments: CbeComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - // tbeId, - cbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - - const [comments, setComments] = React.useState<CbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt, "KR") : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - // console.log("rfqId", rfqId) - // console.log("vendorId", vendorId) - // console.log("cbeId", cbeId) - // console.log("currentUserId", currentUserId) - - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: cbeId, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: CbeComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx deleted file mode 100644 index 8d69e765..00000000 --- a/lib/rfqs/cbe-table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,423 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Send, User } from "lucide-react" -import { toast } from "sonner" -import { z } from "zod" - -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 { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { type Row } from "@tanstack/react-table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { createCbeEvaluation } from "../service" - -// 컴포넌트 내부에서 사용할 폼 스키마 정의 -const formSchema = z.object({ - paymentTerms: z.string().min(1, "지급 조건을 입력하세요"), - incoterms: z.string().min(1, "Incoterms를 입력하세요"), - deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), - notes: z.string().optional(), -}) - -type FormValues = z.infer<typeof formSchema> - -interface InviteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - vendors: Row<VendorWithCbeFields>["original"][] - currentUserId?: number - currentUser?: { - id: string - name?: string | null - email?: string | null - image?: string | null - companyId?: number | null - domain?: string | null - } - showTrigger?: boolean - onSuccess?: () => void -} - -export function InviteVendorsDialog({ - rfqId, - vendors, - currentUserId, - currentUser, - showTrigger = true, - onSuccess, - ...props -}: InviteVendorsDialogProps) { - const [files, setFiles] = React.useState<FileList | null>(null) - const isDesktop = useMediaQuery("(min-width: 640px)") - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // 로컬 스키마와 폼 값을 사용하도록 수정 - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - paymentTerms: "", - incoterms: "", - deliverySchedule: "", - notes: "", - }, - mode: "onChange", - }) - - // 폼 상태 감시 - const { formState } = form - const isValid = formState.isValid && - !!form.getValues("paymentTerms") && - !!form.getValues("incoterms") && - !!form.getValues("deliverySchedule") - - // 디버깅용 상태 트래킹 - React.useEffect(() => { - const subscription = form.watch((value) => { - // 폼 값이 변경될 때마다 실행되는 콜백 - console.log("Form values changed:", value); - }); - - return () => subscription.unsubscribe(); - }, [form]); - - async function onSubmit(data: FormValues) { - try { - setIsSubmitting(true) - - // 기본 FormData 생성 - const formData = new FormData() - - // rfqId 추가 - formData.append("rfqId", String(rfqId)) - - // 폼 데이터 추가 - Object.entries(data).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - formData.append(key, String(value)) - } - }) - - // 현재 사용자 ID 추가 - if (currentUserId) { - formData.append("evaluatedBy", String(currentUserId)) - } - - // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회) - vendors.forEach((vendor) => { - formData.append("vendorIds[]", String(vendor.vendorId)) - }) - - // 파일 추가 (있는 경우에만) - if (files && files.length > 0) { - for (let i = 0; i < files.length; i++) { - formData.append("files", files[i]) - } - } - - // 서버 액션 호출 - const response = await createCbeEvaluation(formData) - - if (response.error) { - toast.error(response.error) - return - } - - // 성공 처리 - toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`) - form.reset() - setFiles(null) - props.onOpenChange?.(false) - onSuccess?.() - } catch (error) { - console.error(error) - toast.error("CBE 평가 생성 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset() - setFiles(null) - } - props.onOpenChange?.(nextOpen) - } - - // 필수 필드 라벨에 추가할 요소 - const RequiredLabel = ( - <span className="text-destructive ml-1 font-medium">*</span> - ) - - const formContent = ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 선택된 협력업체 정보 표시 */} - <div className="space-y-2"> - <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> - <ScrollArea className="h-20 border rounded-md p-2"> - <div className="flex flex-wrap gap-2"> - {vendors.map((vendor, index) => ( - <Badge key={index} variant="secondary" className="py-1"> - {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} - </Badge> - ))} - </div> - </ScrollArea> - <FormDescription> - 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다. - </FormDescription> - </div> - - {/* 작성자 정보 (읽기 전용) */} - {currentUser && ( - <div className="border rounded-md p-3 space-y-2"> - <FormLabel>작성자</FormLabel> - <div className="flex items-center gap-3"> - {currentUser.image ? ( - <Avatar className="h-8 w-8"> - <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> - <AvatarFallback> - {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} - </AvatarFallback> - </Avatar> - ) : ( - <Avatar className="h-8 w-8"> - <AvatarFallback> - {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} - </AvatarFallback> - </Avatar> - )} - <div> - <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> - <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> - </div> - </div> - </div> - )} - - {/* 지급 조건 - 필수 필드 */} - <FormField - control={form.control} - name="paymentTerms" - render={({ field }) => ( - <FormItem> - <FormLabel> - 지급 조건{RequiredLabel} - </FormLabel> - <FormControl> - <Input {...field} placeholder="예: Net 30" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Incoterms - 필수 필드 */} - <FormField - control={form.control} - name="incoterms" - render={({ field }) => ( - <FormItem> - <FormLabel> - Incoterms{RequiredLabel} - </FormLabel> - <FormControl> - <Input {...field} placeholder="예: FOB, CIF" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 배송 일정 - 필수 필드 */} - <FormField - control={form.control} - name="deliverySchedule" - render={({ field }) => ( - <FormItem> - <FormLabel> - 배송 일정{RequiredLabel} - </FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="배송 일정 세부사항을 입력하세요" - rows={3} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 비고 - 선택적 필드 */} - <FormField - control={form.control} - name="notes" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="추가 비고 사항을 입력하세요" - rows={3} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 첨부 (옵션) */} - <div className="space-y-2"> - <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel> - <Input - id="files" - type="file" - multiple - onChange={(e) => setFiles(e.target.files)} - /> - {files && files.length > 0 && ( - <p className="text-sm text-muted-foreground"> - {files.length}개 파일이 첨부되었습니다 - </p> - )} - </div> - - {/* 필수 입력 항목 안내 */} - <div className="text-sm text-muted-foreground"> - <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다. - </div> - - {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */} - {isDesktop && ( - <DialogFooter className="gap-2 pt-4"> - <DialogClose asChild> - <Button - type="button" - variant="outline" - > - 취소 - </Button> - </DialogClose> - <Button - type="submit" - disabled={isSubmitting || !isValid} - > - {isSubmitting && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} - </Button> - </DialogFooter> - )} - </form> - </Form> - ) - - // Desktop Dialog - if (isDesktop) { - return ( - <Dialog {...props} onOpenChange={handleDialogOpenChange}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - CBE 평가 전송 ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>CBE 평가 생성 및 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. - </DialogDescription> - </DialogHeader> - - {formContent} - </DialogContent> - </Dialog> - ) - } - - // Mobile Drawer - return ( - <Drawer {...props} onOpenChange={handleDialogOpenChange}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - CBE 평가 전송 ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle> - <DrawerDescription> - 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. - </DrawerDescription> - </DrawerHeader> - - <div className="px-4"> - {formContent} - </div> - - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - onClick={form.handleSubmit(onSubmit)} - disabled={isSubmitting || !isValid} - > - {isSubmitting && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx deleted file mode 100644 index 180db392..00000000 --- a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table" - -interface VendorContactsDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - vendorId: number | null - vendor: VendorWithCbeFields | null -} - -export function VendorContactsDialog({ - isOpen, - onOpenChange, - vendorId, - vendor, -}: VendorContactsDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>협력업체 연락처</DialogTitle> - {vendor && ( - <div className="flex flex-col space-y-1 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{vendor.vendorName}</span> - {vendor.vendorCode && ( - <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> - )} - </div> - <div className="flex items-center"> - {vendor.vendorStatus && ( - <Badge variant="outline" className="mr-2"> - {vendor.vendorStatus} - </Badge> - )} - {vendor.commercialResponseStatus && ( - <Badge - variant={ - vendor.commercialResponseStatus === "INVITED" ? "default" : - vendor.commercialResponseStatus === "DECLINED" ? "destructive" : - vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline" - } - > - {vendor.commercialResponseStatus} - </Badge> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {vendorId && ( - <div className="py-4"> - <VendorContactsTable vendorId={vendorId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/repository.ts b/lib/rfqs/repository.ts deleted file mode 100644 index 24d09ec3..00000000 --- a/lib/rfqs/repository.ts +++ /dev/null @@ -1,232 +0,0 @@ -// src/lib/tasks/repository.ts -import db from "@/db/db"; -import { items } from "@/db/schema/items"; -import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq"; -import { users } from "@/db/schema/users"; -import { - eq, - inArray, - not, - asc, - desc, - and, - ilike, - gte, - lte, - count, - gt, sql -} from "drizzle-orm"; -import { PgTransaction } from "drizzle-orm/pg-core"; -import { RfqType } from "./validations"; -export type NewRfq = typeof rfqs.$inferInsert -export type NewRfqItem = typeof rfqItems.$inferInsert - -/** - * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 - * - 트랜잭션(tx)을 받아서 사용하도록 구현 - */ -export async function selectRfqs( - tx: PgTransaction<any, any, any>, - params: { - where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; - offset?: number; - limit?: number; - } -) { - const { where, orderBy, offset = 0, limit = 10 } = params; - - return tx - .select({ - rfqId: rfqsView.id, - id: rfqsView.id, - rfqCode: rfqsView.rfqCode, - description: rfqsView.description, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - status: rfqsView.status, - // createdBy → user 이메일 - createdBy: rfqsView.createdBy, // still the numeric user ID - createdByEmail: rfqsView.userEmail, // string - - createdAt: rfqsView.createdAt, - updatedAt: rfqsView.updatedAt, - // ==================== - // 1) itemCount via subselect - // ==================== - itemCount:rfqsView.itemCount, - attachCount: rfqsView.attachmentCount, - - // user info - // userId: users.id, - userEmail: rfqsView.userEmail, - userName: rfqsView.userName, - }) - .from(rfqsView) - .where(where ?? undefined) - .orderBy(...(orderBy ?? [])) - .offset(offset) - .limit(limit); -} -/** 총 개수 count */ -export async function countRfqs( - tx: PgTransaction<any, any, any>, - where?: any -) { - const res = await tx.select({ count: count() }).from(rfqsView).where(where); - return res[0]?.count ?? 0; -} - -/** 단건 Insert 예시 */ -export async function insertRfq( - tx: PgTransaction<any, any, any>, - data: NewRfq // DB와 동일한 insert 가능한 타입 -) { - // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 - return tx - .insert(rfqs) - .values(data) - .returning({ id: rfqs.id, createdAt: rfqs.createdAt }); -} - -/** 복수 Insert 예시 */ -export async function insertRfqs( - tx: PgTransaction<any, any, any>, - data: Rfq[] -) { - return tx.insert(rfqs).values(data).onConflictDoNothing(); -} - -/** 단건 삭제 */ -export async function deleteRfqById( - tx: PgTransaction<any, any, any>, - rfqId: number -) { - return tx.delete(rfqs).where(eq(rfqs.id, rfqId)); -} - -/** 복수 삭제 */ -export async function deleteRfqsByIds( - tx: PgTransaction<any, any, any>, - ids: number[] -) { - return tx.delete(rfqs).where(inArray(rfqs.id, ids)); -} - -/** 전체 삭제 */ -export async function deleteAllRfqs( - tx: PgTransaction<any, any, any>, -) { - return tx.delete(rfqs); -} - -/** 단건 업데이트 */ -export async function updateRfq( - tx: PgTransaction<any, any, any>, - rfqId: number, - data: Partial<Rfq> -) { - return tx - .update(rfqs) - .set(data) - .where(eq(rfqs.id, rfqId)) - .returning({ status: rfqs.status }); -} - -// /** 복수 업데이트 */ -export async function updateRfqs( - tx: PgTransaction<any, any, any>, - ids: number[], - data: Partial<Rfq> -) { - return tx - .update(rfqs) - .set(data) - .where(inArray(rfqs.id, ids)) - .returning({ status: rfqs.status, dueDate: rfqs.dueDate }); -} - - -// 모든 task 조회 -export const getAllRfqs = async (): Promise<Rfq[]> => { - const datas = await db.select().from(rfqs).execute(); - return datas -}; - - -export async function groupByStatus( - tx: PgTransaction<any, any, any>, - rfqType: RfqType = RfqType.PURCHASE -) { - return tx - .select({ - status: rfqs.status, - count: count(), - }) - .from(rfqs) - .where(eq(rfqs.rfqType, rfqType)) // rfqType으로 필터링 추가 - .groupBy(rfqs.status) - .having(gt(count(), 0)); -} - -export async function insertRfqItem( - tx: PgTransaction<any, any, any>, - data: NewRfqItem -) { - return tx.insert(rfqItems).values(data).returning(); -} - -export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => { - // 1) RFQ 단건 조회 - const rfqsRes = await db - .select() - .from(rfqsView) - .where(eq(rfqsView.id, id)) - .limit(1); - - if (rfqsRes.length === 0) return null; - const rfqRow = rfqsRes[0]; - - // 2) 해당 RFQ 아이템 목록 조회 - const itemsRes = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, id)); - - // itemsRes: RfqItem[] - - // 3) RfqWithItems 형태로 반환 - const result: RfqViewWithItems = { - ...rfqRow, - lines: itemsRes, - }; - - return result; -}; - -/** 단건 업데이트 */ -export async function updateRfqVendor( - tx: PgTransaction<any, any, any>, - rfqVendorId: number, - data: Partial<VendorResponse> -) { - return tx - .update(vendorResponses) - .set(data) - .where(eq(vendorResponses.id, rfqVendorId)) - .returning({ status: vendorResponses.responseStatus }); -} - -/** 복수 업데이트 */ -export async function updateRfqVendors( - tx: PgTransaction<any, any, any>, - ids: number[], - data: Partial<VendorResponse> -) { - return tx - .update(vendorResponses) - .set(data) - .where(inArray(vendorResponses.id, ids)) - .returning({ status: vendorResponses.responseStatus }); -} diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts deleted file mode 100644 index 651c8eda..00000000 --- a/lib/rfqs/service.ts +++ /dev/null @@ -1,3951 +0,0 @@ -// src/lib/tasks/service.ts -"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) - -import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; -import db from "@/db/db"; - -import { filterColumns } from "@/lib/filter-columns"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { getErrorMessage } from "@/lib/handle-error"; - -import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; -import path from "path"; -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' - -import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; -import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; -import logger from '@/lib/logger'; -import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors"; -import { sendEmail } from "../mail/sendEmail"; -import { biddingProjects, projects } from "@/db/schema/projects"; -import { items } from "@/db/schema/items"; -import * as z from "zod" -import { users } from "@/db/schema/users"; -import { headers } from 'next/headers'; - -// DRM 복호화 관련 유틸 import -import { decryptWithServerAction } from "@/components/drm/drmUtils"; -import { deleteFile, saveDRMFile, saveFile } from "../file-stroage"; - -interface InviteVendorsInput { - rfqId: number - vendorIds: number[] - rfqType: RfqType -} - -/* ----------------------------------------------------- - 1) 조회 관련 ------------------------------------------------------ */ - -/** - * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고, - * 총 개수에 따라 pageCount를 계산해서 리턴. - * Next.js의 unstable_cache를 사용해 일정 시간 캐시. - */ -export async function getRfqs(input: GetRfqsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - // const advancedTable = input.flags.includes("advancedTable"); - const advancedTable = true; - - // advancedTable 모드면 filterColumns()로 where 절 구성 - const advancedWhere = filterColumns({ - table: rfqsView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s) - , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s) - ) - // 필요시 여러 칼럼 OR조건 (status, priority, etc) - } - - let rfqTypeWhere; - if (input.rfqType) { - rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType); - } - - let whereConditions = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - if (rfqTypeWhere) whereConditions.push(rfqTypeWhere); - - // 조건이 있을 때만 and() 사용 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id]) - ) - : [asc(rfqsView.createdAt)]; - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectRfqs(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countRfqs(tx, finalWhere); - return { data, total }; - }); - - - const pageCount = Math.ceil(total / input.perPage); - - - return { data, pageCount }; - } catch (err) { - console.error("getRfqs 에러:", err); // 자세한 에러 로깅 - - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: [`rfqs-${input.rfqType}`], - } - )(); -} - -/** Status별 개수 */ -export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) { - return unstable_cache( - async () => { - try { - const initial: Record<Rfq["status"], number> = { - DRAFT: 0, - PUBLISHED: 0, - EVALUATION: 0, - AWARDED: 0, - }; - - const result = await db.transaction(async (tx) => { - // rfqType을 기준으로 필터링 추가 - const rows = await groupByStatus(tx, rfqType); - return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { - acc[status] = count; - return acc; - }, initial); - }); - - return result; - } catch (err) { - return {} as Record<Rfq["status"], number>; - } - }, - [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가 - { - revalidate: 3600, - } - )(); -} - - - -/* ----------------------------------------------------- - 2) 생성(Create) ------------------------------------------------------ */ - -/** - * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로 - * 전체 Rfq 개수를 고정 - */ -export async function createRfq(input: CreateRfqSchema) { - - console.log(input.createdBy, "input.createdBy") - - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - try { - await db.transaction(async (tx) => { - // 새 Rfq 생성 - const [newTask] = await insertRfq(tx, { - rfqCode: input.rfqCode, - projectId: input.projectId || null, - bidProjectId: input.bidProjectId || null, - description: input.description || null, - dueDate: input.dueDate, - status: input.status, - rfqType: input.rfqType, // rfqType 추가 - createdBy: input.createdBy, - }); - return newTask; - }); - - // 캐시 무효화 - revalidateTag(`rfqs-${input.rfqType}`); - revalidateTag(`rfq-status-counts-${input.rfqType}`); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 3) 업데이트 ------------------------------------------------------ */ - -/** 단건 업데이트 */ -export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfq(tx, input.id, { - rfqCode: input.rfqCode, - projectId: input.projectId || null, - dueDate: input.dueDate, - rfqType: input.rfqType, - status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", - createdBy: input.createdBy, - }); - return res; - }); - - revalidateTag("rfqs"); - if (data.status === input.status) { - revalidateTag("rfqs-status-counts"); - } - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function modifyRfqs(input: { - ids: number[]; - status?: Rfq["status"]; - dueDate?: Date -}) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfqs(tx, input.ids, { - status: input.status, - dueDate: input.dueDate, - }); - return res; - }); - - revalidateTag("rfqs"); - if (data.status === input.status) { - revalidateTag("rfq-status-counts"); - } - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - - -/* ----------------------------------------------------- - 4) 삭제 ------------------------------------------------------ */ - -/** 단건 삭제 */ -export async function removeRfq(input: { id: number }) { - unstable_noStore(); - try { - await db.transaction(async (tx) => { - // 삭제 - await deleteRfqById(tx, input.id); - // 바로 새 Rfq 생성 - }); - - revalidateTag("rfqs"); - revalidateTag("rfq-status-counts"); - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/** 복수 삭제 */ -export async function removeRfqs(input: { ids: number[] }) { - unstable_noStore(); - try { - await db.transaction(async (tx) => { - // 삭제 - await deleteRfqsByIds(tx, input.ids); - }); - - revalidateTag("rfqs"); - revalidateTag("rfq-status-counts"); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -// 삭제를 위한 입력 스키마 -const deleteRfqItemSchema = z.object({ - id: z.number().int(), - rfqId: z.number().int(), - rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE), -}); - -type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>; - -/** - * RFQ 아이템 삭제 함수 - */ -export async function deleteRfqItem(input: DeleteRfqItemSchema) { - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - - try { - // 삭제 작업 수행 - await db - .delete(rfqItems) - .where( - and( - eq(rfqItems.id, input.id), - eq(rfqItems.rfqId, input.rfqId) - ) - ); - - console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`); - - // 캐시 무효화 - revalidateTag("rfq-items"); - revalidateTag(`rfqs-${input.rfqType}`); - revalidateTag(`rfq-${input.rfqId}`); - - return { data: null, error: null }; - } catch (err) { - console.error("Error in deleteRfqItem:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -// createRfqItem 함수 수정 (id 파라미터 추가) -export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) { - unstable_noStore(); - - try { - // DB 트랜잭션 - await db.transaction(async (tx) => { - // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행 - if (input.id) { - // 기존 아이템 업데이트 - await tx - .update(rfqItems) - .set({ - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - updatedAt: new Date(), - }) - .where(eq(rfqItems.id, input.id)); - - console.log(`Updated RFQ item with id: ${input.id}`); - } else { - // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성 - const existingItems = await tx - .select() - .from(rfqItems) - .where( - and( - eq(rfqItems.rfqId, input.rfqId), - eq(rfqItems.itemCode, input.itemCode) - ) - ); - - if (existingItems.length > 0) { - // 이미 존재하는 경우 업데이트 - const existingItem = existingItems[0]; - await tx - .update(rfqItems) - .set({ - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - updatedAt: new Date(), - }) - .where(eq(rfqItems.id, existingItem.id)); - - console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`); - } else { - // 존재하지 않는 경우 새로 생성 - const [newItem] = await insertRfqItem(tx, { - rfqId: input.rfqId, - itemCode: input.itemCode, - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - }); - - console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`); - } - } - }); - - // 캐시 무효화 - revalidateTag("rfq-items"); - revalidateTag(`rfqs-${input.rfqType}`); - revalidateTag(`rfq-${input.rfqId}`); - - return { data: null, error: null }; - } catch (err) { - console.error("Error in createRfqItem:", err); - return { data: null, error: getErrorMessage(err) }; - } -} -/** - * 서버 액션: 파일 첨부/삭제 처리 - * @param rfqId RFQ ID - * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열 - * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서 - * @param vendorId (optional) 업로더가 vendor인지 구분 - */ -export async function processRfqAttachments(args: { - rfqId: number; - removedExistingIds?: number[]; - newFiles?: File[]; - vendorId?: number | null; - rfqType?: RfqType | null; -}) { - const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args; - - try { - // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거 - if (removedExistingIds.length > 0) { - // 1-1) DB에서 filePath 조회 - const rows = await db - .select({ - id: rfqAttachments.id, - filePath: rfqAttachments.filePath - }) - .from(rfqAttachments) - .where(inArray(rfqAttachments.id, removedExistingIds)); - - // 1-2) DB 삭제 - await db - .delete(rfqAttachments) - .where(inArray(rfqAttachments.id, removedExistingIds)); - - // 1-3) 파일 삭제 - for (const row of rows) { - await deleteFile(row.filePath!); - } - } - - // 2) 새 파일 업로드 - if (newFiles.length > 0) { - for (const file of newFiles) { - - const saveResult = await saveDRMFile(file, decryptWithServerAction,'rfq' ) - - // 2-4) DB Insert - await db.insert(rfqAttachments).values({ - rfqId, - vendorId, - fileName: file.name, - filePath: saveResult.publicPath!, - // (Windows 경로 대비) - }); - } - } - - const [countRow] = await db - .select({ cnt: sql<number>`count(*)`.as("cnt") }) - .from(rfqAttachments) - .where(eq(rfqAttachments.rfqId, rfqId)); - - const newCount = countRow?.cnt ?? 0; - - // 3) revalidateTag 등 캐시 무효화 - revalidateTag("rfq-attachments"); - revalidateTag(`rfqs-${args.rfqType}`) - - return { ok: true, updatedItemCount: newCount }; - } catch (error) { - console.error("processRfqAttachments error:", error); - return { ok: false, error: String(error) }; - } -} - - - -export async function fetchRfqAttachments(rfqId: number) { - // DB select - const rows = await db - .select() - .from(rfqAttachments) - .where(eq(rfqAttachments.rfqId, rfqId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - id: row.id, - fileName: row.fileName, - filePath: row.filePath, - createdAt: row.createdAt, // or string - vendorId: row.vendorId, - size: undefined, // size를 DB에 저장하지 않았다면 - })) -} - -export async function fetchRfqItems(rfqId: number) { - // DB select - const rows = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - // id: row.id, - itemCode: row.itemCode, - description: row.description, - quantity: row.quantity, - uom: row.uom, - })) -} - -export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => { - try { - logger.info({ id }, 'Fetching user by ID'); - const rfq = await getRfqById(id); - if (!rfq) { - logger.warn({ id }, 'User not found'); - } else { - logger.debug({ rfq }, 'User fetched successfully'); - } - return rfq; - } catch (error) { - logger.error({ error }, 'Error fetching user by ID'); - throw new Error('Failed to fetch user'); - } -}; - -export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) { - return unstable_cache( - async () => { - // ───────────────────────────────────────────────────── - // 1) rfq_items에서 distinct itemCode - // ───────────────────────────────────────────────────── - const itemRows = await db - .select({ code: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - .groupBy(rfqItems.itemCode) - - const itemCodes = itemRows.map((r) => r.code) - const itemCount = itemCodes.length - if (itemCount === 0) { - return { data: [], pageCount: 0 } - } - - // ───────────────────────────────────────────────────── - // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor - // ───────────────────────────────────────────────────── - const inList = itemCodes.map((c) => `'${c}'`).join(",") - const sqlVendorIds = await db.execute( - sql` - SELECT vpi.vendor_id AS "vendorId" - FROM ${vendorPossibleItems} vpi - WHERE vpi.item_code IN (${sql.raw(inList)}) - GROUP BY vpi.vendor_id - HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount} - ` - ) - const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId) - if (vendorIdList.length === 0) { - return { data: [], pageCount: 0 } - } - - // ───────────────────────────────────────────────────── - // 3) 필터/검색/정렬 - // ───────────────────────────────────────────────────── - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // (가) 커스텀 필터 - // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다. - const advancedWhere = filterColumns({ - // 테이블이 아니라 "뷰"를 넘길 수도 있고, - // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다. - table: vendorRfqView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // (나) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorRfqView.vendorName} ILIKE ${s}`, - sql`${vendorRfqView.vendorCode} ILIKE ${s}`, - sql`${vendorRfqView.email} ILIKE ${s}` - ) - } - - // (다) 최종 where - // vendorId가 vendorIdList 내에 있어야 하고, - // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. - const finalWhere = and( - inArray(vendorRfqView.vendorId, vendorIdList), - // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만 - // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다 - // eq(vendorRfqView.rfqId, rfqId), - advancedWhere, - globalWhere - ) - - // (라) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // "column id" -> vendorRfqView.* 중 하나 - const col = (vendorRfqView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorRfqView.vendorId)] - - // ───────────────────────────────────────────────────── - // 4) View에서 데이터 SELECT - // ───────────────────────────────────────────────────── - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - id: vendorRfqView.vendorId, - vendorID: vendorRfqView.vendorId, - vendorName: vendorRfqView.vendorName, - vendorCode: vendorRfqView.vendorCode, - address: vendorRfqView.address, - country: vendorRfqView.country, - email: vendorRfqView.email, - website: vendorRfqView.website, - vendorStatus: vendorRfqView.vendorStatus, - // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정 - rfqVendorStatus: vendorRfqView.rfqVendorStatus, - rfqVendorUpdated: vendorRfqView.rfqVendorUpdated, - }) - .from(vendorRfqView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - // 중복 제거된 데이터 생성 - const distinctData = Array.from( - new Map(data.map(row => [row.id, row])).values() - ) - - // 중복 제거된 총 개수 계산 - const [{ count }] = await tx - .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) - .from(vendorRfqView) - .where(finalWhere) - - return [distinctData, Number(count)] - }) - - - // ───────────────────────────────────────────────────── - // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 - // ───────────────────────────────────────────────────── - const distinctVendorIds = [...new Set(rows.map((r) => r.id))] - - // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회 - const vendorStatuses = await db - .select({ - vendorId: vendorResponses.vendorId, - status: vendorResponses.responseStatus, - updatedAt: vendorResponses.updatedAt - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.vendorId, distinctVendorIds), - eq(vendorResponses.rfqId, rfqId) - ) - ) - - // vendorId별 상태정보 맵 생성 - const statusMap = new Map<number, { status: string, updatedAt: Date }>() - for (const vs of vendorStatuses) { - statusMap.set(vs.vendorId, { - status: vs.status, - updatedAt: vs.updatedAt - }) - } - - // 정확한 상태 정보로 업데이트된 rows 생성 - const updatedRows = rows.map(row => ({ - ...row, - rfqVendorStatus: statusMap.get(row.id)?.status || null, - rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null - })) - - // ───────────────────────────────────────────────────── - // 5) 코멘트 조회: 기존과 동일 - // ───────────────────────────────────────────────────── - console.log("distinctVendorIds", distinctVendorIds) - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.vendorId, distinctVendorIds), - eq(rfqComments.rfqId, rfqId), - isNull(rfqComments.evaluationId), - isNull(rfqComments.cbeId) - ) - ) - - const commByVendorId = new Map<number, any[]>() - // 먼저 모든 사용자 ID를 수집 - const userIds = new Set(commAll.map(c => c.commentedBy)); - const userIdsArray = Array.from(userIds); - - // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴 - const usersData = await db - .select({ - id: users.id, - email: users.email, - }) - .from(users) - .where(inArray(users.id, userIdsArray)); - - // 사용자 ID를 키로 하는 맵 생성 - const userMap = new Map(); - for (const user of usersData) { - userMap.set(user.id, user); - } - - // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - - // 사용자 정보 가져오기 - const user = userMap.get(c.commentedBy); - const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정 - - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - commentedByEmail: userEmail, // 이메일 추가 - }) - } - // ───────────────────────────────────────────────────── - // 6) rows에 comments 병합 - // ───────────────────────────────────────────────────── - const final = updatedRows.map((row) => ({ - ...row, - comments: commByVendorId.get(row.id) ?? [], - })) - - // ───────────────────────────────────────────────────── - // 7) 반환 - // ───────────────────────────────────────────────────── - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify({ input, rfqId })], - { revalidate: 3600, tags: ["rfq-vendors"] } - )() -} - -export async function inviteVendors(input: InviteVendorsInput) { - unstable_noStore() // 서버 액션 캐싱 방지 - try { - const { rfqId, vendorIds } = input - if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) { - throw new Error("Invalid input") - } - - const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; - - // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 - const rfqData = await db.transaction(async (tx) => { - // 2-A) RFQ 기본 정보 조회 - const [rfqRow] = await tx - .select({ - rfqCode: rfqsView.rfqCode, - description: rfqsView.description, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - createdBy: rfqsView.createdBy, - }) - .from(rfqsView) - .where(eq(rfqsView.id, rfqId)) - - if (!rfqRow) { - throw new Error(`RFQ #${rfqId} not found`) - } - - // 2-B) 아이템 목록 조회 - const items = await tx - .select({ - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // 2-C) 첨부파일 목록 조회 - const attachRows = await tx - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - }) - .from(rfqAttachments) - .where( - and( - eq(rfqAttachments.rfqId, rfqId), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.evaluationId) - ) - ) - - const vendorRows = await tx - .select({ id: vendors.id, email: vendors.email }) - .from(vendors) - .where(inArray(vendors.id, vendorIds)) - - // NodeMailer attachments 형식 맞추기 - const attachments = [] - for (const att of attachRows) { - const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, "")) - attachments.push({ - path: absolutePath, - filename: att.fileName, - }) - } - - return { rfqRow, items, vendorRows, attachments } - }) - - const { rfqRow, items, vendorRows, attachments } = rfqData - const loginUrl = `http://${host}/en/partners/rfq` - - // 이메일 전송 오류를 기록할 배열 - const emailErrors = [] - - // 각 벤더에 대해 처리 - for (const v of vendorRows) { - if (!v.email) { - continue // 이메일 없는 협력업체 무시 - } - - try { - // DB 업데이트: 각 협력업체 상태 별도 트랜잭션 - await db.transaction(async (tx) => { - // rfq_vendors upsert - const existing = await tx - .select() - .from(vendorResponses) - .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id))) - - if (existing.length > 0) { - await tx - .update(vendorResponses) - .set({ - responseStatus: "INVITED", - updatedAt: new Date(), - }) - .where(eq(vendorResponses.id, existing[0].id)) - } else { - await tx.insert(vendorResponses).values({ - rfqId, - vendorId: v.id, - responseStatus: "INVITED", - }) - } - }) - - // 이메일 발송 (트랜잭션 외부) - await sendEmail({ - to: v.email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: v.id, - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl - }, - attachments, - }) - } catch (err) { - // 개별 협력업체 처리 실패 로깅 - console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) - emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) - // 계속 진행 (다른 협력업체 처리) - } - } - - // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션) - try { - await db.transaction(async (tx) => { - await tx - .update(rfqs) - .set({ - status: "PUBLISHED", - updatedAt: new Date(), - }) - .where(eq(rfqs.id, rfqId)) - - console.log(`Updated RFQ #${rfqId} status to PUBLISHED`) - }) - - // 캐시 무효화 - revalidateTag("rfq-vendors") - revalidateTag("cbe-vendors") - revalidateTag("rfqs") - revalidateTag(`rfqs-${input.rfqType}`) - revalidateTag(`rfq-${rfqId}`) - - // 이메일 오류가 있었는지 확인 - if (emailErrors.length > 0) { - return { - error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`, - emailErrors - } - } - - return { error: null } - } catch (err) { - return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` } - } - } catch (err) { - return { error: getErrorMessage(err) } - } -} - - -/** - * TBE용 평가 데이터 목록 조회 - */ -export async function getTBE(input: GetTBESchema, rfqId: number) { - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) finalWhere - const finalWhere = and( - eq(vendorTbeView.rfqId, rfqId), - // notRejected, - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - - technicalResponseId:vendorTbeView.technicalResponseId, - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - technicalSummary:vendorTbeView.technicalSummary, - technicalNotes:vendorTbeView.technicalNotes, - technicalUpdated:vendorTbeView.technicalUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - .where( - and( - isNotNull(rfqComments.evaluationId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ) - - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 9) TBE 파일 조회 - vendorResponseAttachments로 대체 - // Step 1: Get vendorResponses for the rfqId and vendorIds - const responsesAll = await db - .select({ - id: vendorResponses.id, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); - - // Group responses by vendorId for later lookup - const responsesByVendorId = new Map<number, number[]>(); - for (const resp of responsesAll) { - if (!responsesByVendorId.has(resp.vendorId)) { - responsesByVendorId.set(resp.vendorId, []); - } - responsesByVendorId.get(resp.vendorId)!.push(resp.id); - } - - // Step 2: Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); - - // Step 3: Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); - } - - // Step 4: Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - - // Step 5: Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Step 6: Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - // Skip if technicalResponseId is null (should never happen due to our filter above) - if (file.technicalResponseId === null) continue; - - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } - - // Step 7: Create the final filesByVendorId map - const filesByVendorId = new Map<number, any[]>(); - for (const [vendorId, responseIds] of responsesByVendorId.entries()) { - filesByVendorId.set(vendorId, []); - - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByVendorId.get(vendorId)!.push(...files); - } - } - } - - // 10) 최종 합치기 - const final = rows.map((row) => ({ - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - files: filesByVendorId.get(row.vendorId) ?? [], - })) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify({ input, rfqId })], - { - revalidate: 3600, - tags: ["tbe-vendors"], - } - )() -} - -export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { - - if (isNaN(vendorId) || vendorId === null || vendorId === undefined) { - throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다"); - } - - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) finalWhere - const finalWhere = and( - isNotNull(vendorTbeView.tbeId), - eq(vendorTbeView.vendorId, vendorId), - // notRejected, - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - rfqType:vendorTbeView.rfqType, - rfqStatus:vendorTbeView.rfqStatus, - rfqDescription: vendorTbeView.description, - rfqDueDate: vendorTbeView.dueDate, - - - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - vendorResponseId: vendorTbeView.vendorResponseId, - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - // - evaluationId != null && evalType = "TBE" - // - => leftJoin(rfqEvaluations) or innerJoin - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))] - - // (A) 조인 방식 - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, // (optional) - }) - .from(rfqComments) - // evalType = 'TBE' - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") // ★ TBE만 - ) - ) - .where( - and( - isNotNull(rfqComments.evaluationId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ) - - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 9) TBE 템플릿 파일 수 조회 - const templateFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - fileCount: sql<number>`count(*)`.as("file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.commentId) - ) - ) - .groupBy(rfqAttachments.evaluationId) - - // tbeId -> fileCount 매핑 - null 체크 추가 - const templateFileCountMap = new Map<number, number>() - for (const tf of templateFiles) { - if (tf.tbeId !== null) { - templateFileCountMap.set(tf.tbeId, Number(tf.fileCount)) - } - } - - // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해) - const tbeResponseFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - vendorId: rfqAttachments.vendorId, - responseFileCount: sql<number>`count(*)`.as("response_file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - inArray(rfqAttachments.vendorId, distinctVendorIds), - isNull(rfqAttachments.commentId) - ) - ) - .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) - - // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가 - const tbeResponseMap = new Map<string, number>() - for (const rf of tbeResponseFiles) { - if (rf.tbeId !== null && rf.vendorId !== null) { - const key = `${rf.tbeId}_${rf.vendorId}` - tbeResponseMap.set(key, Number(rf.responseFileCount)) - } - } - - // 11) 최종 합치기 - const final = rows.map((row) => { - const tbeId = row.tbeId - const vendorId = row.vendorId - - // 템플릿 파일 수 - const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0 - - // 응답 파일 여부 - const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : "" - const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0 - - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - templateFileCount, // 추가: 템플릿 파일 수 - hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부 - } - }) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가 - { - revalidate: 3600, - tags: [`tbe-vendors-${vendorId}`], - } - )() -} - -export async function inviteTbeVendorsAction(formData: FormData) { - // 캐싱 방지 - unstable_noStore() - - try { - // 1) FormData에서 기본 필드 추출 - const rfqId = Number(formData.get("rfqId")) - const vendorIdsRaw = formData.getAll("vendorIds[]") - const vendorIds = vendorIdsRaw.map((id) => Number(id)) - - // 2) FormData에서 파일들 추출 (multiple) - const tbeFiles = formData.getAll("tbeFiles") as File[] - if (!rfqId || !vendorIds.length || !tbeFiles.length) { - throw new Error("Invalid input or no files attached.") - } - - // DB 트랜잭션 - await db.transaction(async (tx) => { - // (A) RFQ 기본 정보 조회 - const [rfqRow] = await tx - .select({ - rfqCode: vendorResponsesView.rfqCode, - description: vendorResponsesView.rfqDescription, - projectCode: vendorResponsesView.projectCode, - projectName: vendorResponsesView.projectName, - dueDate: vendorResponsesView.rfqDueDate, - createdBy: vendorResponsesView.rfqCreatedBy, - }) - .from(vendorResponsesView) - .where(eq(vendorResponsesView.rfqId, rfqId)) - - if (!rfqRow) { - throw new Error(`RFQ #${rfqId} not found`) - } - - // (B) RFQ 아이템 목록 - const items = await tx - .select({ - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // (C) 대상 벤더들 (이메일 정보 확장) - const vendorRows = await tx - .select({ - id: vendors.id, - name: vendors.vendorName, - email: vendors.email, - representativeEmail: vendors.representativeEmail // 대표자 이메일 추가 - }) - .from(vendors) - .where(sql`${vendors.id} in (${vendorIds})`) - - // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리 - // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. - // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. - const savedFiles = [] - for (const file of tbeFiles) { - - const saveResult = await saveFile({file, directory:'rfb'}); - // 저장 경로 & 파일명 기록 - savedFiles.push({ - fileName: file.name, // 원본 파일명으로 첨부 - filePath: saveResult.publicPath, // public 이하 경로 - absolutePath: saveResult.publicPath, - }) - } - - // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 - for (const vendor of vendorRows) { - // 1) 협력업체 연락처 조회 - 추가 이메일 수집 - const contacts = await tx - .select({ - contactName: vendorContacts.contactName, - contactEmail: vendorContacts.contactEmail, - isPrimary: vendorContacts.isPrimary, - }) - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendor.id)) - - // 2) 모든 이메일 주소 수집 및 중복 제거 - const allEmails = new Set<string>() - - // 협력업체 이메일 추가 (있는 경우에만) - if (vendor.email) { - allEmails.add(vendor.email.trim().toLowerCase()) - } - - // 협력업체 대표자 이메일 추가 (있는 경우에만) - if (vendor.representativeEmail) { - allEmails.add(vendor.representativeEmail.trim().toLowerCase()) - } - - // 연락처 이메일 추가 - contacts.forEach(contact => { - if (contact.contactEmail) { - allEmails.add(contact.contactEmail.trim().toLowerCase()) - } - }) - - // 중복이 제거된 이메일 주소 배열로 변환 - const uniqueEmails = Array.from(allEmails) - - if (uniqueEmails.length === 0) { - console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`) - continue - } - - // 3) TBE 평가 레코드 생성 - const [evalRow] = await tx - .insert(rfqEvaluations) - .values({ - rfqId, - vendorId: vendor.id, - evalType: "TBE", - }) - .returning({ id: rfqEvaluations.id }) - - // 4) rfqAttachments에 저장한 파일들을 기록 - for (const sf of savedFiles) { - await tx.insert(rfqAttachments).values({ - rfqId, - vendorId: vendor.id, - evaluationId: evalRow.id, - fileName: sf.fileName, - filePath: sf.filePath, - }) - } - - // 5) 각 고유 이메일 주소로 초대 메일 발송 - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - const loginUrl = `${baseUrl}/ko/partners/rfq` - - console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) - - for (const email of uniqueEmails) { - try { - // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) - const contact = contacts.find(c => - c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() - ) - const contactName = contact?.contactName || `${vendor.name} 담당자` - - await sendEmail({ - to: email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: vendor.id, - contactName, // 연락처 이름 추가 - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl, - }, - attachments: savedFiles.map((sf) => ({ - path: sf.absolutePath, - filename: sf.fileName, - })), - }) - console.log(`이메일 전송 성공: ${email} (${contactName})`) - } catch (emailErr) { - console.error(`이메일 전송 실패 (${email}):`, emailErr) - } - } - } - - // 6) 캐시 무효화 - revalidateTag("tbe-vendors") - }) - - // 성공 - return { error: null } - } catch (err) { - console.error("[inviteTbeVendorsAction] Error:", err) - return { error: getErrorMessage(err) } - } -} -////partners - - -export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { - unstable_noStore(); - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfqVendor(tx, input.id, { - responseStatus: input.status, - }); - return res; - }); - - revalidateTag("rfqs-vendor"); - revalidateTag("rfq-vendors"); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function createRfqCommentWithAttachments(params: { - rfqId: number - vendorId?: number | null - commentText: string - commentedBy: number - evaluationId?: number | null - cbeId?: number | null - files?: File[] -}) { - const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params - console.log("cbeId", cbeId) - console.log("evaluationId", evaluationId) - // 1) 새로운 코멘트 생성 - const [insertedComment] = await db - .insert(rfqComments) - .values({ - rfqId, - vendorId: vendorId || null, - commentText, - commentedBy, - evaluationId: evaluationId || null, - cbeId: cbeId || null, - }) - .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록 - - if (!insertedComment) { - throw new Error("Failed to create comment") - } - - // 2) 첨부파일 처리 - if (files && files.length > 0) { - - for (const file of files) { - - const saveResult = await saveFile({file, directory:'rfq'}) - - // DB에 첨부파일 row 생성 - await db.insert(rfqAttachments).values({ - rfqId, - vendorId: vendorId || null, - evaluationId: evaluationId || null, - cbeId: cbeId || null, - commentId: insertedComment.id, // 새 코멘트와 연결 - fileName: file.name, - filePath:saveResult.publicPath!, - }) - } - } - - revalidateTag("rfq-vendors"); - - return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt } -} - -export async function fetchRfqAttachmentsbyCommentId(commentId: number) { - // DB select - const rows = await db - .select() - .from(rfqAttachments) - .where(eq(rfqAttachments.commentId, commentId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - id: row.id, - fileName: row.fileName, - filePath: row.filePath, - createdAt: row.createdAt, // or string - vendorId: row.vendorId, - evaluationId: row.evaluationId, - size: undefined, // size를 DB에 저장하지 않았다면 - })) -} - -export async function updateRfqComment(params: { - commentId: number - commentText: string -}) { - const { commentId, commentText } = params - - // 예: 간단한 길이 체크 등 유효성 검사 - if (!commentText || commentText.trim().length === 0) { - throw new Error("Comment text must not be empty.") - } - - // DB 업데이트 - const updatedRows = await db - .update(rfqComments) - .set({ commentText }) // 필요한 컬럼만 set - .where(eq(rfqComments.id, commentId)) - .returning({ id: rfqComments.id }) - - // 혹은 returning 전체(row)를 받아서 확인할 수도 있음 - if (updatedRows.length === 0) { - // 해당 id가 없으면 예외 - throw new Error("Comment not found or already deleted.") - } - revalidateTag("rfq-vendors"); - return { ok: true } -} - -export type Project = { - id: number; - projectCode: string; - projectName: string; - type: string; -} - -export async function getProjects(): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 모든 프로젝트 조회 - const results = await tx - .select({ - id: projects.id, - projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 - projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 - type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정 - }) - .from(projects) - .orderBy(projects.code); - - return results; - }); - - return projectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } -} - - -export async function getBidProjects(): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 모든 프로젝트 조회 - const results = await tx - .select({ - id: biddingProjects.id, - projectCode: biddingProjects.pspid, - projectName: biddingProjects.projNm, - }) - .from(biddingProjects) - .orderBy(biddingProjects.id); - - return results; - }); - - // Handle null projectName values - const validProjectList = projectList.map(project => ({ - ...project, - projectName: project.projectName || '' // Replace null with empty string - })); - - return validProjectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } -} - - -// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 -export interface BudgetaryRfq { - id: number; - rfqCode: string | null; // null 허용으로 변경 - description: string | null; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -type GetBudgetaryRfqsResponse = - | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never } - | { error: string; rfqs?: never; totalCount: number } -/** - * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션 - * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함 - * 페이징 및 필터링 기능 포함 - */ -export interface GetBudgetaryRfqsParams { - search?: string; - projectId?: number; - rfqId?: number; // 특정 ID로 단일 RFQ 검색 - rfqTypes?: RfqType[]; // 특정 RFQ 타입들로 필터링 - limit?: number; - offset?: number; -} - -export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> { - const { search, projectId, rfqId, rfqTypes, limit = 50, offset = 0 } = params; - const cacheKey = `rfqs-query-${JSON.stringify(params)}`; - - return unstable_cache( - async () => { - try { - // 기본 검색 조건 구성 - let baseCondition; - - // 특정 RFQ 타입들로 필터링 (rfqTypes 배열이 주어진 경우) - if (rfqTypes && rfqTypes.length > 0) { - // 여러 타입으로 필터링 (OR 조건) - baseCondition = inArray(rfqs.rfqType, rfqTypes); - } else { - // 기본적으로 BUDGETARY 타입만 검색 (이전 동작 유지) - baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY); - } - - // 특정 ID로 검색하는 경우 - if (rfqId) { - baseCondition = and(baseCondition, eq(rfqs.id, rfqId)); - } - - let where1; - // 검색어 조건 추가 (있을 경우) - if (search && search.trim()) { - const searchTerm = `%${search.trim()}%`; - const searchCondition = or( - ilike(rfqs.rfqCode, searchTerm), - ilike(rfqs.description, searchTerm), - ilike(projects.code, searchTerm), - ilike(projects.name, searchTerm) - ); - where1 = searchCondition; - } - - let where2; - // 프로젝트 ID 조건 추가 (있을 경우) - if (projectId) { - where2 = eq(rfqs.projectId, projectId); - } - - const finalWhere = and(baseCondition, where1, where2); - - // 총 개수 조회 - const [countResult] = await db - .select({ count: count() }) - .from(rfqs) - .leftJoin(projects, eq(rfqs.projectId, projects.id)) - .where(finalWhere); - - // 실제 데이터 조회 - const resultRfqs = await db - .select({ - id: rfqs.id, - rfqCode: rfqs.rfqCode, - description: rfqs.description, - rfqType: rfqs.rfqType, // RFQ 타입 필드 추가 - projectId: rfqs.projectId, - projectCode: projects.code, - projectName: projects.name, - }) - .from(rfqs) - .leftJoin(projects, eq(rfqs.projectId, projects.id)) - .where(finalWhere) - .orderBy(desc(rfqs.createdAt)) - .limit(limit) - .offset(offset); - - return { - rfqs: resultRfqs, - totalCount: Number(countResult?.count) || 0 - }; - } catch (error) { - console.error("Error fetching RFQs:", error); - return { - error: "Failed to fetch RFQs", - totalCount: 0 - }; - } - }, - [cacheKey], - { - revalidate: 60, // 1분 캐시 - tags: ["rfqs-query"], - } - )(); -} -export async function getAllVendors() { - // Adjust the query as needed (add WHERE, ORDER, etc.) - const allVendors = await db.select().from(vendors) - return allVendors -} - - -export async function getVendorContactsByVendorId(vendorId: number) { - try { - const contacts = await db.query.vendorContacts.findMany({ - where: eq(vendorContacts.vendorId, vendorId), - }); - - return { success: true, data: contacts }; - } catch (error) { - console.error("Error fetching vendor contacts:", error); - return { success: false, error: "Failed to fetch vendor contacts" }; - } -} -/** - * Server action to associate items from an RFQ with a vendor - * - * @param rfqId - The ID of the RFQ containing items to associate - * @param vendorId - The ID of the vendor to associate items with - * @returns Object indicating success or failure - */ -export async function addItemToVendors(rfqId: number, vendorIds: number[]) { - try { - // Input validation - if (!vendorIds.length) { - return { - success: false, - error: "No vendors selected" - }; - } - - // 1. Find all itemCodes associated with the given rfqId using select - const rfqItemResults = await db - .select({ itemCode: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)); - - // Extract itemCodes - const itemCodes = rfqItemResults.map(item => item.itemCode); - - if (itemCodes.length === 0) { - return { - success: false, - error: "No items found for this RFQ" - }; - } - - // 2. Find existing vendor-item combinations to avoid duplicates - const existingCombinations = await db - .select({ - vendorId: vendorPossibleItems.vendorId, - itemCode: vendorPossibleItems.itemCode - }) - .from(vendorPossibleItems) - .where( - and( - inArray(vendorPossibleItems.vendorId, vendorIds), - inArray(vendorPossibleItems.itemCode, itemCodes) - ) - ); - - // Create a Set of existing combinations for easy lookups - const existingSet = new Set(); - existingCombinations.forEach(combo => { - existingSet.add(`${combo.vendorId}-${combo.itemCode}`); - }); - - // 3. Prepare records to insert (only non-existing combinations) - const recordsToInsert = []; - - for (const vendorId of vendorIds) { - for (const itemCode of itemCodes) { - const key = `${vendorId}-${itemCode}`; - if (!existingSet.has(key)) { - recordsToInsert.push({ - vendorId, - itemCode, - // createdAt and updatedAt will be set by defaultNow() - }); - } - } - } - - // 4. Bulk insert if there are records to insert - let insertedCount = 0; - if (recordsToInsert.length > 0) { - const result = await db.insert(vendorPossibleItems).values(recordsToInsert); - insertedCount = recordsToInsert.length; - } - - // 5. Revalidate to refresh data - revalidateTag("rfq-vendors"); - - // 6. Return success with counts - return { - success: true, - insertedCount, - totalPossibleItems: vendorIds.length * itemCodes.length, - vendorCount: vendorIds.length, - itemCount: itemCodes.length - }; - } catch (error) { - console.error("Error adding items to vendors:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error" - }; - } -} - -/** - * 특정 평가에 대한 TBE 템플릿 파일 목록 조회 - * evaluationId가 일치하고 vendorId가 null인 파일 목록 - */ -export async function fetchTbeTemplateFiles(evaluationId: number) { - try { - const files = await db - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - createdAt: rfqAttachments.createdAt, - }) - .from(rfqAttachments) - .where( - and( - isNull(rfqAttachments.commentId), - isNull(rfqAttachments.vendorId), - eq(rfqAttachments.evaluationId, evaluationId), - // eq(rfqAttachments.vendorId, vendorId), - - ) - ) - - return { files, error: null } - } catch (error) { - console.error("Error fetching TBE template files:", error) - return { - files: [], - error: "템플릿 파일을 가져오는 중 오류가 발생했습니다." - } - } -} - -export async function getFileFromRfqAttachmentsbyid(fileId: number) { - try { - const file = await db - .select({ - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - }) - .from(rfqAttachments) - .where(eq(rfqAttachments.id, fileId)) - .limit(1) - - if (!file.length) { - return { file: null, error: "파일을 찾을 수 없습니다." } - } - - return { file: file[0], error: null } - } catch (error) { - console.error("Error getting TBE template file info:", error) - return { - file: null, - error: "파일 정보를 가져오는 중 오류가 발생했습니다." - } - } -} - -/** - * TBE 응답 파일 업로드 처리 - */ -export async function uploadTbeResponseFile(formData: FormData) { - try { - const file = formData.get("file") as File - const rfqId = parseInt(formData.get("rfqId") as string) - const vendorId = parseInt(formData.get("vendorId") as string) - const evaluationId = parseInt(formData.get("evaluationId") as string) - const vendorResponseId = parseInt(formData.get("vendorResponseId") as string) - - if (!file || !rfqId || !vendorId || !evaluationId) { - return { - success: false, - error: "필수 필드가 누락되었습니다." - } - } - - // 타임스탬프 기반 고유 파일명 생성 - const timestamp = Date.now() - const originalName = file.name - const fileExtension = originalName.split(".").pop() - const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}` - - // 업로드 디렉토리 및 경로 정의 - const uploadDir = join(process.cwd(), "rfq", "tbe-responses") - - // 디렉토리가 없으면 생성 - try { - await mkdir(uploadDir, { recursive: true }) - } catch (error) { - // 이미 존재하면 무시 - } - - const filePath = join(uploadDir, fileName) - - // 파일을 버퍼로 변환 - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) - - // 파일을 서버에 저장 - await writeFile(filePath, buffer) - - // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성 - const technicalResponse = await db.insert(vendorTechnicalResponses) - .values({ - responseId: vendorResponseId, - summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 - notes: `파일명: ${originalName}`, - responseStatus:"SUBMITTED" - }) - .returning({ id: vendorTechnicalResponses.id }); - - // 생성된 기술 응답 ID 가져오기 - const technicalResponseId = technicalResponse[0].id; - - // 파일 정보를 데이터베이스에 저장 - const dbFilePath = `/rfq/tbe-responses/${fileName}` - - // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 - await db.insert(vendorResponseAttachments) - .values({ - // 오류 메시지를 기반으로 올바른 필드 이름 사용 - // 테이블 스키마에 정의된 필드만 포함해야 함 - responseId: vendorResponseId, - technicalResponseId: technicalResponseId, - // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거 - // vendorId: vendorId, - // evaluationId: evaluationId, - fileName: originalName, - filePath: dbFilePath, - uploadedAt: new Date(), - }); - - // 경로 재검증 (캐시된 데이터 새로고침) - revalidatePath(`/rfq/${rfqId}/tbe`) - revalidateTag(`tbe-vendors-${vendorId}`) - - return { - success: true, - message: "파일이 성공적으로 업로드되었습니다." - } - } catch (error) { - console.error("Error uploading file:", error) - return { - success: false, - error: "파일 업로드에 실패했습니다." - } - } -} - -export async function getTbeSubmittedFiles(responseId: number) { - try { - // First, get the technical response IDs where vendorResponseId matches responseId - const technicalResponses = await db - .select({ - id: vendorTechnicalResponses.id, - }) - .from(vendorTechnicalResponses) - .where( - eq(vendorTechnicalResponses.responseId, responseId) - ) - - if (technicalResponses.length === 0) { - return { files: [], error: null } - } - - // Extract the IDs from the result - const technicalResponseIds = technicalResponses.map(tr => tr.id) - - // Then get attachments where technicalResponseId matches any of the IDs we found - const files = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - uploadedAt: vendorResponseAttachments.uploadedAt, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - }) - .from(vendorResponseAttachments) - .where( - inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) - ) - .orderBy(vendorResponseAttachments.uploadedAt) - - return { files, error: null } - } catch (error) { - return { files: [], error: 'Failed to fetch TBE submitted files' } - } -} - - - -export async function getTbeFilesForVendor(rfqId: number, vendorId: number) { - try { - // Step 1: Get responseId from vendor_responses table - const response = await db - .select({ - id: vendorResponses.id, - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - eq(vendorResponses.vendorId, vendorId) - ) - ) - .limit(1); - - if (!response || response.length === 0) { - return { files: [], error: 'No vendor response found' }; - } - - const responseId = response[0].id; - - // Step 2: Get the technical response IDs - const technicalResponses = await db - .select({ - id: vendorTechnicalResponses.id, - }) - .from(vendorTechnicalResponses) - .where( - eq(vendorTechnicalResponses.responseId, responseId) - ); - - if (technicalResponses.length === 0) { - return { files: [], error: null }; - } - - // Extract the IDs from the result - const technicalResponseIds = technicalResponses.map(tr => tr.id); - - // Step 3: Get attachments where technicalResponseId matches any of the IDs - const files = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - uploadedAt: vendorResponseAttachments.uploadedAt, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - }) - .from(vendorResponseAttachments) - .where( - inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) - ) - .orderBy(vendorResponseAttachments.uploadedAt); - - return { files, error: null }; - } catch (error) { - return { files: [], error: 'Failed to fetch vendor files' }; - } -} - -export async function getAllTBE(input: GetTBESchema) { - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}`, - sql`${vendorTbeView.rfqCode} ILIKE ${s}`, - sql`${vendorTbeView.projectCode} ILIKE ${s}`, - sql`${vendorTbeView.projectName} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) rfqType 필터 추가 - const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined - - // 6) finalWhere - rfqType 필터 추가 - const finalWhere = and( - notRejected, - advancedWhere, - globalWhere, - rfqTypeFilter // 새로 추가된 rfqType 필터 - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - tbeResult: vendorTbeView.tbeResult, - - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Get distinct rfqIds and vendorIds - filter out nulls - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; - - // 9) Comments 조회 - const commentsConditions = [isNotNull(rfqComments.evaluationId)]; - - // 배열이 비어있지 않을 때만 조건 추가 - if (distinctRfqIds.length > 0) { - commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); - } - - if (distinctVendorIds.length > 0) { - commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); - } - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - rfqId: rfqComments.rfqId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - .where(and(...commentsConditions)); - - // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping - const commByCompositeKey = new Map<string, any[]>() - for (const c of commAll) { - if (!c.rfqId || !c.vendorId) continue; - - const compositeKey = `${c.rfqId}-${c.vendorId}`; - if (!commByCompositeKey.has(compositeKey)) { - commByCompositeKey.set(compositeKey, []) - } - commByCompositeKey.get(compositeKey)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 10) Responses 조회 - const responsesAll = await db - .select({ - id: vendorResponses.id, - rfqId: vendorResponses.rfqId, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.rfqId, distinctRfqIds), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); - - // Group responses by rfqId-vendorId composite key - const responsesByCompositeKey = new Map<string, number[]>(); - for (const resp of responsesAll) { - const compositeKey = `${resp.rfqId}-${resp.vendorId}`; - if (!responsesByCompositeKey.has(compositeKey)) { - responsesByCompositeKey.set(compositeKey, []); - } - responsesByCompositeKey.get(compositeKey)!.push(resp.id); - } - - // Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); - - // 11) Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); - } - - // Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - - // 12) Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - if (file.technicalResponseId === null) continue; - - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } - - // 13) Create the final filesByCompositeKey map - const filesByCompositeKey = new Map<string, any[]>(); - - for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) { - filesByCompositeKey.set(compositeKey, []); - - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - } - - // 14) 최종 합치기 - const final = rows.map((row) => { - const compositeKey = `${row.rfqId}-${row.vendorId}`; - - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByCompositeKey.get(compositeKey) ?? [], - files: filesByCompositeKey.get(compositeKey) ?? [], - }; - }) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: ["all-tbe-vendors"], - } - )() -} - - -export async function getCBE(input: GetCBESchema, rfqId: number) { - return unstable_cache( - async () => { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, - sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) - const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] 최종 where 조건 - const finalWhere = and( - eq(vendorResponseCBEView.rfqId, rfqId), - notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined - ); - - // [6] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명 - - // [7] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - rfqType: vendorResponseCBEView.rfqType, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [8] 협력업체 ID 목록 추출 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [9] CBE 평가 관련 코멘트 조회 - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where( - and( - isNotNull(rfqComments.cbeId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ); - - // vendorId별 코멘트 그룹화 - const commentsByVendorId = new Map<number, any[]>(); - for (const comment of commentsAll) { - const vendorId = comment.vendorId!; - if (!commentsByVendorId.has(vendorId)) { - commentsByVendorId.set(vendorId, []); - } - commentsByVendorId.get(vendorId)!.push({ - id: comment.id, - commentText: comment.commentText, - vendorId: comment.vendorId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [10] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [11] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [12] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [13] 최종 데이터 병합 - const final = rows.map((row) => { - // 해당 응답의 모든 첨부파일 가져오기 - const responseFiles = filesByResponseId.get(row.responseId) || []; - const commercialFiles = row.commercialResponseId - ? filesByCommercialResponseId.get(row.commercialResponseId) || [] - : []; - - // 모든 첨부파일 병합 - const allFiles = [...responseFiles, ...commercialFiles]; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByVendorId.get(row.vendorId) || [], - files: allFiles, - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; - }, - // 캐싱 키 & 옵션 - [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`cbe-vendors-${rfqId}`], - } - )(); -} - -export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> { - try { - if (!rfqType) { - return { code: "", error: 'RFQ 타입이 필요합니다' }; - } - - // 현재 연도 가져오기 - const currentYear = new Date().getFullYear(); - - // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기 - const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode }) - .from(rfqs) - .where(and( - sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`, - eq(rfqs.rfqType, rfqType) - )) - .orderBy(desc(rfqs.rfqCode)) - .limit(1); - - let sequenceNumber = 1; - - if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) { - // null 체크 추가 - TypeScript 오류 해결 - const latestCode = latestRfqs[0].rfqCode; - const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/); - - if (matches && matches[1]) { - sequenceNumber = parseInt(matches[1], 10) + 1; - } - } - - // 새로운 RFQ 코드 포맷팅 - const typePrefix = rfqType === RfqType.BUDGETARY ? 'BUD' : - rfqType === RfqType.PURCHASE_BUDGETARY ? 'PBU' : 'RFQ'; - - const newCode = `${typePrefix}-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`; - - return { code: newCode }; - } catch (error) { - console.error('Error generating next RFQ code:', error); - return { code: "", error: '코드 생성에 실패했습니다' }; - } -} - -interface SaveTbeResultParams { - id: number // id from the rfq_evaluations table - vendorId: number // vendorId from the rfq_evaluations table - result: string // The selected evaluation result - notes: string // The evaluation notes -} - -export async function saveTbeResult({ - id, - vendorId, - result, - notes, -}: SaveTbeResultParams) { - try { - // Check if we have all required data - if (!id || !vendorId || !result) { - return { - success: false, - message: "Missing required data for evaluation update", - } - } - - // Update the record in the database - await db - .update(rfqEvaluations) - .set({ - result: result, - notes: notes, - updatedAt: new Date(), - }) - .where( - and( - eq(rfqEvaluations.id, id), - eq(rfqEvaluations.vendorId, vendorId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - - // Revalidate the tbe-vendors tag to refresh the data - revalidateTag("tbe-vendors") - revalidateTag("all-tbe-vendors") - - return { - success: true, - message: "TBE evaluation updated successfully", - } - } catch (error) { - console.error("Failed to update TBE evaluation:", error) - - return { - success: false, - message: error instanceof Error ? error.message : "An unknown error occurred", - } - } -} - - -export async function createCbeEvaluation(formData: FormData) { - try { - // 폼 데이터 추출 - const rfqId = Number(formData.get("rfqId")) - const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id)) - const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null - - - const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; - - // 기본 CBE 데이터 추출 - const rawData = { - rfqId, - paymentTerms: formData.get("paymentTerms") as string, - incoterms: formData.get("incoterms") as string, - deliverySchedule: formData.get("deliverySchedule") as string, - notes: formData.get("notes") as string, - // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음) - // vendorId: vendorIds[0] || 0, - } - - // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리) - const validationResult = createCbeEvaluationSchema.safeParse(rawData) - if (!validationResult.success) { - const errors = validationResult.error.format() - console.error("Validation errors:", errors) - return { error: "입력 데이터가 유효하지 않습니다." } - } - - const validData = validationResult.data - - // RFQ 정보 조회 - const [rfqInfo] = await db - .select({ - rfqCode: rfqsView.rfqCode, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - description: rfqsView.description, - }) - .from(rfqsView) - .where(eq(rfqsView.id, rfqId)) - - if (!rfqInfo) { - return { error: "RFQ 정보를 찾을 수 없습니다." } - } - - // 파일 처리 준비 - const files = formData.getAll("files") as File[] - const hasFiles = files && files.length > 0 && files[0].size > 0 - - - // 첨부 파일 정보를 저장할 배열 - const attachments: { filename: string; path: string }[] = [] - - // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비 - if (hasFiles) { - for (const file of files) { - if (file.size > 0) { - const originalFilename = file.name - const fileExtension = path.extname(originalFilename) - const timestamp = new Date().getTime() - const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` - const filePath = path.join("rfq", String(rfqId), safeFilename) - const fullPath = path.join(process.cwd(), "public", filePath) - - const saveResult = await saveFile({file, directory:'rfq'}) - - } - } - } - - // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송 - const createdCbeIds: number[] = [] - const failedVendors: { id: number, reason: string }[] = [] - - for (const vendorId of vendorIds) { - try { - // 협력업체 정보 조회 (이메일 포함) - const [vendorInfo] = await db - .select({ - id: vendors.id, - name: vendors.vendorName, - vendorCode: vendors.vendorCode, - email: vendors.email, // 협력업체 자체 이메일 추가 - representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가 - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - - if (!vendorInfo) { - failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." }) - continue - } - - // 기존 협력업체 응답 레코드 찾기 - const existingResponse = await db - .select({ id: vendorResponses.id }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - eq(vendorResponses.vendorId, vendorId) - ) - ) - .limit(1) - - if (existingResponse.length === 0) { - console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`) - failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" }) - continue // 다음 벤더로 넘어감 - } - - // 1. CBE 평가 레코드 생성 - const [newCbeEvaluation] = await db - .insert(cbeEvaluations) - .values({ - rfqId, - vendorId, - evaluatedBy, - result: "PENDING", // 초기 상태는 PENDING으로 설정 - totalCost: 0, // 초기값은 0으로 설정 - currency: "USD", // 기본 통화 설정 - paymentTerms: validData.paymentTerms || null, - incoterms: validData.incoterms || null, - deliverySchedule: validData.deliverySchedule || null, - notes: validData.notes || null, - }) - .returning({ id: cbeEvaluations.id }) - - if (!newCbeEvaluation?.id) { - failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" }) - continue - } - - // 2. 상업 응답 레코드 생성 - const [newCbeResponse] = await db - .insert(vendorCommercialResponses) - .values({ - responseId: existingResponse[0].id, - responseStatus: "PENDING", - currency: "USD", - paymentTerms: validData.paymentTerms || null, - incoterms: validData.incoterms || null, - deliveryPeriod: validData.deliverySchedule || null, - }) - .returning({ id: vendorCommercialResponses.id }) - - if (!newCbeResponse?.id) { - failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" }) - continue - } - - createdCbeIds.push(newCbeEvaluation.id) - - // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성 - if (hasFiles) { - for (let i = 0; i < attachments.length; i++) { - const attachment = attachments[i] - - await db.insert(rfqAttachments).values({ - rfqId, - vendorId, - fileName: attachment.filename, - filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장 - cbeId: newCbeEvaluation.id, - }) - } - } - - // 4. 협력업체 연락처 조회 - const contacts = await db - .select({ - contactName: vendorContacts.contactName, - contactEmail: vendorContacts.contactEmail, - isPrimary: vendorContacts.isPrimary, - }) - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendorId)) - - // 5. 모든 이메일 주소 수집 및 중복 제거 - const allEmails = new Set<string>() - - // 연락처 이메일 추가 - contacts.forEach(contact => { - if (contact.contactEmail) { - allEmails.add(contact.contactEmail.trim().toLowerCase()) - } - }) - - // 협력업체 자체 이메일 추가 (있는 경우에만) - if (vendorInfo.email) { - allEmails.add(vendorInfo.email.trim().toLowerCase()) - } - - // 협력업체 대표자 이메일 추가 (있는 경우에만) - if (vendorInfo.representativeEmail) { - allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase()) - } - - // 중복이 제거된 이메일 주소 배열로 변환 - const uniqueEmails = Array.from(allEmails) - - if (uniqueEmails.length === 0) { - console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`) - } else { - console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`) - - // 이메일 발송에 필요한 공통 데이터 준비 - const emailData = { - rfqId, - cbeId: newCbeEvaluation.id, - vendorId, - rfqCode: rfqInfo.rfqCode, - projectCode: rfqInfo.projectCode, - projectName: rfqInfo.projectName, - dueDate: rfqInfo.dueDate, - description: rfqInfo.description, - vendorName: vendorInfo.name, - vendorCode: vendorInfo.vendorCode, - paymentTerms: validData.paymentTerms, - incoterms: validData.incoterms, - deliverySchedule: validData.deliverySchedule, - notes: validData.notes, - loginUrl: `http://${host}/en/partners/cbe` - } - - // 각 고유 이메일 주소로 이메일 발송 - for (const email of uniqueEmails) { - try { - // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) - const contact = contacts.find(c => - c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() - ) - const contactName = contact?.contactName || `${vendorInfo.name} 담당자` - - await sendEmail({ - to: email, - subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`, - template: "cbe-invitation", - context: { - language: "ko", // 또는 다국어 처리를 위한 설정 - contactName, - ...emailData, - }, - attachments: attachments, - }) - console.log(`이메일 전송 성공: ${email}`) - } catch (emailErr) { - console.error(`이메일 전송 실패 (${email}):`, emailErr) - } - } - } - - } catch (err) { - console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err) - failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) - } - } - - // UI 업데이트를 위한 경로 재검증 - revalidatePath(`/rfq/${rfqId}`) - revalidateTag(`cbe-vendors-${rfqId}`) - - // 결과 반환 - if (createdCbeIds.length === 0) { - return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." } - } - - return { - success: true, - cbeIds: createdCbeIds, - totalCreated: createdCbeIds.length, - totalFailed: failedVendors.length, - failedVendors: failedVendors.length > 0 ? failedVendors : undefined - } - - } catch (error) { - console.error("CBE 평가 생성 중 오류 발생:", error) - return { error: "예상치 못한 오류가 발생했습니다." } - } -} - -export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { - return unstable_cache( - async () => { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음) - // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] 최종 where 조건 - const finalWhere = and( - eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링 - isNotNull(vendorResponseCBEView.commercialCreatedAt), - // notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined - ); - - // [6] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순 - - // [7] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - rfqType: vendorResponseCBEView.rfqType, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [8] RFQ ID 목록 추출 - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [9] CBE 평가 관련 코멘트 조회 - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - rfqId: rfqComments.rfqId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where( - and( - isNotNull(rfqComments.cbeId), - eq(rfqComments.vendorId, vendorId), - inArray(rfqComments.rfqId, distinctRfqIds) - ) - ); - - // rfqId별 코멘트 그룹화 - const commentsByRfqId = new Map<number, any[]>(); - for (const comment of commentsAll) { - const rfqId = comment.rfqId!; - if (!commentsByRfqId.has(rfqId)) { - commentsByRfqId.set(rfqId, []); - } - commentsByRfqId.get(rfqId)!.push({ - id: comment.id, - commentText: comment.commentText, - rfqId: comment.rfqId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [10] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [11] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [12] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [13] 최종 데이터 병합 - const final = rows.map((row) => { - // 해당 응답의 모든 첨부파일 가져오기 - const responseFiles = filesByResponseId.get(row.responseId) || []; - const commercialFiles = row.commercialResponseId - ? filesByCommercialResponseId.get(row.commercialResponseId) || [] - : []; - - // 모든 첨부파일 병합 - const allFiles = [...responseFiles, ...commercialFiles]; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByRfqId.get(row.rfqId) || [], - files: allFiles, - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; - }, - // 캐싱 키 & 옵션 - [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`cbe-vendor-${vendorId}`], - } - )(); -} - -export async function fetchCbeFiles(vendorId: number, rfqId: number) { - try { - // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다. - const cbeEval = await db - .select({ id: cbeEvaluations.id }) - .from(cbeEvaluations) - .where( - and( - eq(cbeEvaluations.rfqId, rfqId), - eq(cbeEvaluations.vendorId, vendorId) - ) - ) - .limit(1) - - if (!cbeEval.length) { - return { - files: [], - error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다." - } - } - - const cbeId = cbeEval[0].id - - // 2. 관련 첨부 파일을 조회합니다. - // - commentId와 evaluationId는 null이어야 함 - // - rfqId와 vendorId가 일치해야 함 - // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함 - const files = await db - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - createdAt: rfqAttachments.createdAt - }) - .from(rfqAttachments) - .where( - and( - eq(rfqAttachments.rfqId, rfqId), - eq(rfqAttachments.vendorId, vendorId), - eq(rfqAttachments.cbeId, cbeId), - isNull(rfqAttachments.commentId), - isNull(rfqAttachments.evaluationId) - ) - ) - .orderBy(rfqAttachments.createdAt) - - return { - files, - cbeId - } - } catch (error) { - console.error("CBE 파일 조회 중 오류 발생:", error) - return { - files: [], - error: "CBE 파일을 가져오는 중 오류가 발생했습니다." - } - } -} - -export async function getAllCBE(input: GetCBESchema) { - return unstable_cache( - async () => { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, - sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) - const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] rfqType 필터 추가 - const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined; - - // [6] 최종 where 조건 - const finalWhere = and( - notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined, - rfqTypeFilter // 새로 추가된 rfqType 필터 - ); - - // [7] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명 - - // [8] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - rfqType: vendorResponseCBEView.rfqType, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [10] CBE 평가 관련 코멘트 조회 - const commentsConditions = [isNotNull(rfqComments.cbeId)]; - - // 배열이 비어있지 않을 때만 조건 추가 - if (distinctRfqIds.length > 0) { - commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); - } - - if (distinctVendorIds.length > 0) { - commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); - } - - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - rfqId: rfqComments.rfqId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where(and(...commentsConditions)); - - // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화 - const commentsByCompositeKey = new Map<string, any[]>(); - for (const comment of commentsAll) { - if (!comment.rfqId || !comment.vendorId) continue; - - const compositeKey = `${comment.rfqId}-${comment.vendorId}`; - if (!commentsByCompositeKey.has(compositeKey)) { - commentsByCompositeKey.set(compositeKey, []); - } - commentsByCompositeKey.get(compositeKey)!.push({ - id: comment.id, - commentText: comment.commentText, - vendorId: comment.vendorId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [12] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [13] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [14] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성 - const filesByCompositeKey = new Map<string, any[]>(); - - // responseId -> rfqId-vendorId 매핑 생성 - const responseIdToCompositeKey = new Map<number, string>(); - for (const row of rows) { - if (row.responseId) { - responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`); - } - if (row.commercialResponseId) { - responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`); - } - } - - // responseId별 첨부파일을 복합 키별로 그룹화 - for (const [responseId, files] of filesByResponseId.entries()) { - const compositeKey = responseIdToCompositeKey.get(responseId); - if (compositeKey) { - if (!filesByCompositeKey.has(compositeKey)) { - filesByCompositeKey.set(compositeKey, []); - } - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - - // commercialResponseId별 첨부파일을 복합 키별로 그룹화 - for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) { - const compositeKey = responseIdToCompositeKey.get(commercialResponseId); - if (compositeKey) { - if (!filesByCompositeKey.has(compositeKey)) { - filesByCompositeKey.set(compositeKey, []); - } - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - - // [16] 최종 데이터 병합 - const final = rows.map((row) => { - const compositeKey = `${row.rfqId}-${row.vendorId}`; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByCompositeKey.get(compositeKey) || [], - files: filesByCompositeKey.get(compositeKey) || [], - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; - }, - // 캐싱 키 & 옵션 - [`all-cbe-vendors-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: ["all-cbe-vendors"], - } - )(); -}
\ No newline at end of file diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx deleted file mode 100644 index 3d822499..00000000 --- a/lib/rfqs/table/ItemsDialog.tsx +++ /dev/null @@ -1,752 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray, useWatch } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" - -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { - Command, - CommandInput, - CommandList, - CommandItem, - CommandGroup, - CommandEmpty -} from "@/components/ui/command" -import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react" -import { toast } from "sonner" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Badge } from "@/components/ui/badge" - -import { createRfqItem, deleteRfqItem } from "../service" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { RfqType } from "../validations" - -// Zod 스키마 - 수량은 string으로 받아서 나중에 변환 -const itemSchema = z.object({ - id: z.number().optional(), - itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }), - description: z.string().optional(), - quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1), - uom: z.string().default("each"), -}); - -const itemsFormSchema = z.object({ - rfqId: z.number().int(), - items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }), -}); - -type ItemsFormSchema = z.infer<typeof itemsFormSchema>; - -interface RfqsItemsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - rfq: RfqWithItemCount | null; - defaultItems?: { - id?: number; - itemCode: string; - quantity?: number | null; - description?: string | null; - uom?: string | null; - }[]; - itemsList: { code: string | null; name: string }[]; - rfqType?: RfqType; -} - -export function RfqsItemsDialog({ - open, - onOpenChange, - rfq, - defaultItems = [], - itemsList, - 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), - defaultValues: { - rfqId, - items: defaultItems.length > 0 ? defaultItems.map((it) => ({ - id: it.id, - quantity: it.quantity ?? 1, - uom: it.uom ?? "each", - itemCode: it.itemCode ?? "", - description: it.description ?? "", - })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }], - }, - mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사 - }); - - // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장 - React.useEffect(() => { - if (open) { - 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 ?? "", - })) - : [{ itemCode: "", description: "", quantity: 1, uom: "each" }]; - - form.reset({ - rfqId, - items: initialItems, - }); - - // 초기 아이템 ID 목록 저장 - setInitialItemIds(defaultItems.map(item => item.id)); - - // 삭제된 아이템 목록 초기화 - setDeletedItemIds([]); - setHasUnsavedChanges(false); - } - }, [open, defaultItems, rfqId, form]); - - // 새로운 요소에 대한 ref 배열 - const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); - const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false); - - // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지 - React.useEffect(() => { - if (!isEditable) return; - - const subscription = form.watch(() => { - setHasUnsavedChanges(true); - }); - return () => subscription.unsubscribe(); - }, [form, isEditable]); - - // 2) field array - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "items", - }); - - // 3) watch items array - const watchItems = form.watch("items"); - - // 4) Add item row with auto-focus - function handleAddItem() { - if (!isEditable) return; - - // 명시적으로 숫자 타입으로 지정 - append({ - itemCode: "", - description: "", - quantity: 1, - uom: "each" - }); - setHasUnsavedChanges(true); - - // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스 - setTimeout(() => { - const newIndex = fields.length; - const button = inputRefs.current[newIndex]; - if (button) { - button.click(); - } - }, 100); - } - - // 항목 직접 삭제 - 기존 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); - if (nextIndex >= 0 && inputRefs.current[nextIndex]) { - inputRefs.current[nextIndex]?.click(); - } - }, 50); - }; - - // 다이얼로그 닫기 전 확인 - const handleDialogClose = (open: boolean) => { - if (!open && hasUnsavedChanges && isEditable) { - setIsExitDialogOpen(true); - } else { - onOpenChange(open); - } - }; - - // 필드 포커스 유틸리티 함수 - const focusField = (selector: string) => { - if (!isEditable) return; - - setTimeout(() => { - const element = document.querySelector(selector) as HTMLInputElement | null; - if (element) { - element.focus(); - } - }, 10); - }; - - // 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 => - deleteRfqItem({ - id: id, - rfqId: rfqId, - rfqType: rfqType ?? RfqType.PURCHASE - }) - ); - - // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 - const upsertPromises = data.items.map((item) => - createRfqItem({ - rfqId: rfqId, - itemCode: item.itemCode, - description: item.description, - // 명시적으로 숫자로 변환 - quantity: Number(item.quantity), - uom: item.uom, - rfqType: rfqType ?? RfqType.PURCHASE, - id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성 - }) - ); - - // 모든 요청 병렬 처리 - await Promise.all([...deletePromises, ...upsertPromises]); - - toast.success("RFQ 아이템이 성공적으로 저장되었습니다!"); - setHasUnsavedChanges(false); - onOpenChange(false); - } catch (err) { - toast.error(`오류가 발생했습니다: ${String(err)}`); - } finally { - setIsSubmitting(false); - } - } - - // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화 - React.useEffect(() => { - if (!isEditable) return; - - const handleKeyDown = (e: KeyboardEvent) => { - // Alt+N: 새 항목 추가 - if (e.altKey && e.key === 'n') { - e.preventDefault(); - handleAddItem(); - } - // Ctrl+S: 저장 - if ((e.ctrlKey || e.metaKey) && e.key === 's') { - e.preventDefault(); - form.handleSubmit(onSubmit)(); - } - // Esc: 포커스된 팝오버 닫기 - if (e.key === 'Escape') { - document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach( - (el) => (el as HTMLButtonElement).click() - ); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [form, isEditable]); - - return ( - <> - <Dialog open={open} onOpenChange={handleDialogClose}> - <DialogContent className="max-w-none w-[1200px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"} - <Badge variant="outline" className="ml-2"> - {rfq?.rfqCode || `RFQ #${rfqId}`} - </Badge> - {rfqType && ( - <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1"> - {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"} - </Badge> - )} - {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} - className="ml-1" - > - {rfq.status} - </Badge> - )} - </DialogTitle> - <DialogDescription> - {isEditable - ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') - : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'} - </DialogDescription> - </DialogHeader> - <div className="overflow-x-auto w-full"> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4"> - {/* 헤더 행 (라벨) */} - <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm"> - <div className="w-[250px] pl-3">아이템</div> - <div className="w-[400px] pl-2">설명</div> - <div className="w-[80px] pl-2 text-center">수량</div> - <div className="w-[80px] pl-2 text-center">단위</div> - {isEditable && <div className="w-[42px]"></div>} - </div> - - {/* 아이템 행들 */} - <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3"> - {fields.map((field, index) => { - // 현재 row의 itemCode - const codeValue = watchItems[index]?.itemCode || ""; - // "이미" 사용된 코드를 모두 구함 - const usedCodes = watchItems - .map((it, i) => i === index ? null : it.itemCode) - .filter(Boolean) as string[]; - - // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고, - // 다른 행에서 이미 사용한 code는 제거 - const filteredItems = (itemsList || []) - .filter((it) => { - if (!it.code) return false; - if (it.code === codeValue) return true; - return !usedCodes.includes(it.code); - }) - .map((it) => ({ - code: it.code ?? "", // fallback - name: it.name, - })); - - // 선택된 아이템 찾기 - const selected = filteredItems.find(it => it.code === codeValue); - - return ( - <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`} - render={({ field }) => { - const [popoverOpen, setPopoverOpen] = React.useState(false); - const selected = filteredItems.find(it => it.code === field.value); - - return ( - <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}> - <FormControl> - <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> - <PopoverTrigger asChild> - <Button - // 컴포넌트에 ref 전달 - ref={el => { - inputRefs.current[index] = el; - }} - variant="outline" - role="combobox" - aria-expanded={popoverOpen} - className="flex items-center" - data-error={!!form.formState.errors.items?.[index]?.itemCode} - data-state={selected ? "filled" : "empty"} - style={{width:250}} - > - <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"> - <Command> - <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus /> - <CommandList> - <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty> - <CommandGroup> - {filteredItems.map((it) => { - const label = `${it.code} - ${it.name}`; - return ( - <CommandItem - key={it.code} - value={label} - onSelect={() => { - field.onChange(it.code); - setPopoverOpen(false); - // 자동으로 다음 필드로 포커스 이동 - focusField(`input[name="items.${index}.description"]`); - }} - > - <div className="flex-1 overflow-hidden"> - <span className="block truncate">{label}</span> - </div> - <Check - className={ - "ml-auto h-4 w-4" + - (it.code === field.value ? " opacity-100" : " opacity-0") - } - /> - </CommandItem> - ); - })} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - </FormControl> - {form.formState.errors.items?.[index]?.itemCode && ( - <AlertCircle className="h-4 w-4 text-destructive" /> - )} - </FormItem> - ); - }} - /> - ) : ( - <div className="flex items-center w-[250px] pl-3"> - {selected ? `${selected.code} - ${selected.name}` : codeValue} - </div> - )} - - {/* ID 필드 추가 (숨김) */} - <FormField - control={form.control} - name={`items.${index}.id`} - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* description */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.description`} - render={({ field }) => ( - <FormItem className="w-[400px]"> - <FormControl> - <Input - className="w-full" - placeholder="아이템 상세 정보" - {...field} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - focusField(`input[name="items.${index}.quantity"]`); - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - ) : ( - <div className="w-[400px] pl-2"> - {watchItems[index]?.description || ""} - </div> - )} - - {/* quantity */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.quantity`} - render={({ field }) => ( - <FormItem className="w-[80px] relative"> - <FormControl> - <Input - type="number" - className="w-full text-center" - min="1" - {...field} - // 값 변경 핸들러 개선 - onChange={(e) => { - const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10); - field.onChange(isNaN(value) ? 1 : value); - }} - // 최소값 보장 (빈 문자열 방지) - onBlur={(e) => { - if (e.target.value === '' || parseInt(e.target.value, 10) < 1) { - field.onChange(1); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - focusField(`input[name="items.${index}.uom"]`); - } - }} - /> - </FormControl> - {form.formState.errors.items?.[index]?.quantity && ( - <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" /> - )} - </FormItem> - )} - /> - ) : ( - <div className="w-[80px] text-center"> - {watchItems[index]?.quantity} - </div> - )} - - {/* uom */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.uom`} - render={({ field }) => ( - <FormItem className="w-[80px]"> - <FormControl> - <Input - placeholder="each" - className="w-full text-center" - {...field} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - // 마지막 행이면 새로운 행 추가 - if (index === fields.length - 1) { - handleAddItem(); - } else { - // 아니면 다음 행의 아이템 선택으로 이동 - const button = inputRefs.current[index + 1]; - if (button) { - setTimeout(() => button.click(), 10); - } - } - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - ) : ( - <div className="w-[80px] text-center"> - {watchItems[index]?.uom || "each"} - </div> - )} - - {/* remove row - 편집 모드에서만 표시 */} - {isEditable && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => handleRemoveItem(index)} - className="group-hover:opacity-100 transition-opacity" - aria-label="아이템 삭제" - > - <Trash2 className="h-4 w-4 text-destructive" /> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>아이템 삭제</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - </div> - ); - })} - </div> - - <div className="flex justify-between items-center pt-2 border-t"> - <div className="flex items-center gap-2"> - {isEditable ? ( - <> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1"> - <Plus className="h-4 w-4" /> - 아이템 추가 - </Button> - </TooltipTrigger> - <TooltipContent side="bottom"> - <p>단축키: Alt+N</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - <span className="text-sm text-muted-foreground"> - {fields.length}개 아이템 - </span> - {deletedItemIds.length > 0 && ( - <span className="text-sm text-destructive"> - ({deletedItemIds.length}개 아이템 삭제 예정) - </span> - )} - </> - ) : ( - <span className="text-sm text-muted-foreground"> - {fields.length}개 아이템 - </span> - )} - </div> - - {isEditable && ( - <div className="text-xs text-muted-foreground"> - <span className="inline-flex items-center gap-1 mr-2"> - <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd> - <span>필드 간 이동</span> - </span> - <span className="inline-flex items-center gap-1"> - <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd> - <span>다음 필드로 이동</span> - </span> - </div> - )} - </div> - </div> - - <DialogFooter className="mt-6 gap-2"> - {isEditable ? ( - <> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}> - <X className="mr-2 h-4 w-4" /> - 취소 - </Button> - </TooltipTrigger> - <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent> - </Tooltip> - </TooltipProvider> - - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - type="submit" - disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid} - > - {isSubmitting ? ( - <>처리 중...</> - ) : ( - <> - <Save className="mr-2 h-4 w-4" /> - 저장 - </> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - <p>단축키: Ctrl+S</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </> - ) : ( - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> - <X className="mr-2 h-4 w-4" /> - 닫기 - </Button> - )} - </DialogFooter> - </form> - </Form> - </div> - </DialogContent> - </Dialog> - - {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */} - {isEditable && ( - <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle> - <AlertDialogDescription> - 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까? - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>취소</AlertDialogCancel> - <AlertDialogAction onClick={() => { - setIsExitDialogOpen(false); - onOpenChange(false); - }}> - 저장하지 않고 나가기 - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - </> - ); -}
\ No newline at end of file diff --git a/lib/rfqs/table/ParentRfqSelector.tsx b/lib/rfqs/table/ParentRfqSelector.tsx deleted file mode 100644 index 0edb1233..00000000 --- a/lib/rfqs/table/ParentRfqSelector.tsx +++ /dev/null @@ -1,307 +0,0 @@ -"use client" - -import * as React from "react" -import { Check, ChevronsUpDown, Loader } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { useDebounce } from "@/hooks/use-debounce" -import { getBudgetaryRfqs, type BudgetaryRfq } from "../service" -import { RfqType } from "../validations" - -// ParentRfq 타입 정의 (서비스의 BudgetaryRfq와 호환되어야 함) -interface ParentRfq { - id: number; - rfqCode: string; - description: string | null; - rfqType: RfqType; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -interface ParentRfqSelectorProps { - selectedRfqId?: number; - onRfqSelect: (rfq: ParentRfq | null) => void; - rfqType: RfqType; // 현재 생성 중인 RFQ 타입 - parentRfqTypes: RfqType[]; // 선택 가능한 부모 RFQ 타입 목록 - placeholder?: string; -} - -export function ParentRfqSelector({ - selectedRfqId, - onRfqSelect, - rfqType, - parentRfqTypes, - placeholder = "부모 RFQ 선택..." -}: ParentRfqSelectorProps) { - const [searchTerm, setSearchTerm] = React.useState(""); - const debouncedSearchTerm = useDebounce(searchTerm, 300); - - const [open, setOpen] = React.useState(false); - const [loading, setLoading] = React.useState(false); - const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]); - const [selectedRfq, setSelectedRfq] = React.useState<ParentRfq | null>(null); - const [page, setPage] = React.useState(1); - const [hasMore, setHasMore] = React.useState(true); - const [totalCount, setTotalCount] = React.useState(0); - - const listRef = React.useRef<HTMLDivElement>(null); - - // 타입별로 적절한 검색 placeholder 생성 - const getSearchPlaceholder = () => { - if (rfqType === RfqType.PURCHASE) { - return "BUDGETARY/PURCHASE_BUDGETARY RFQ 검색..."; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "BUDGETARY RFQ 검색..."; - } - return "RFQ 코드/설명/프로젝트 검색..."; - }; - - // 초기 선택된 RFQ가 있을 경우 로드 - React.useEffect(() => { - if (selectedRfqId && open) { - const loadSelectedRfq = async () => { - try { - // 단일 RFQ를 id로 조회하는 API 호출 - const result = await getBudgetaryRfqs({ - limit: 1, - rfqId: selectedRfqId - }); - - if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { - setSelectedRfq(result.rfqs[0] as unknown as ParentRfq); - } - } catch (error) { - console.error("선택된 RFQ 로드 오류:", error); - } - }; - - if (!selectedRfq || selectedRfq.id !== selectedRfqId) { - loadSelectedRfq(); - } - } - }, [selectedRfqId, open, selectedRfq]); - - // 검색어 변경 시 데이터 리셋 및 재로드 - React.useEffect(() => { - if (open) { - setPage(1); - setHasMore(true); - setParentRfqs([]); - loadParentRfqs(1, true); - } - }, [debouncedSearchTerm, open, parentRfqTypes]); - - // 데이터 로드 함수 - const loadParentRfqs = async (pageToLoad: number, reset = false) => { - if (!open || parentRfqTypes.length === 0) return; - - setLoading(true); - try { - const limit = 20; // 한 번에 로드할 항목 수 - const result = await getBudgetaryRfqs({ - search: debouncedSearchTerm, - limit, - offset: (pageToLoad - 1) * limit, - rfqTypes: parentRfqTypes // 현재 RFQ 타입에 맞는 부모 RFQ 타입들로 필터링 - }); - - if ('rfqs' in result && result.rfqs) { - if (reset) { - setParentRfqs(result.rfqs as unknown as ParentRfq[]); - } else { - setParentRfqs(prev => [...prev, ...(result.rfqs as unknown as ParentRfq[])]); - } - - setTotalCount(result.totalCount); - setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount); - setPage(pageToLoad); - } - } catch (error) { - console.error("부모 RFQ 로드 오류:", error); - } finally { - setLoading(false); - } - }; - - // 무한 스크롤 처리 - const handleScroll = () => { - if (listRef.current) { - const { scrollTop, scrollHeight, clientHeight } = listRef.current; - - // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 - if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { - loadParentRfqs(page + 1); - } - } - }; - - // RFQ를 프로젝트별로 그룹화하는 함수 - const groupRfqsByProject = (rfqs: ParentRfq[]) => { - const groups: Record<string, { - projectId: number | null; - projectCode: string | null; - projectName: string | null; - rfqs: ParentRfq[]; - }> = {}; - - // 'No Project' 그룹 기본 생성 - groups['no-project'] = { - projectId: null, - projectCode: null, - projectName: null, - rfqs: [] - }; - - // 프로젝트별로 RFQ 그룹화 - rfqs.forEach(rfq => { - const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project'; - - if (!groups[key] && rfq.projectId) { - groups[key] = { - projectId: rfq.projectId, - projectCode: rfq.projectCode, - projectName: rfq.projectName, - rfqs: [] - }; - } - - groups[key].rfqs.push(rfq); - }); - - // 필터링된 결과가 있는 그룹만 남기기 - return Object.values(groups).filter(group => group.rfqs.length > 0); - }; - - // 그룹화된 RFQ 목록 - const groupedRfqs = React.useMemo(() => { - return groupRfqsByProject(parentRfqs); - }, [parentRfqs]); - - // RFQ 선택 처리 - const handleRfqSelect = (rfq: ParentRfq | null) => { - setSelectedRfq(rfq); - onRfqSelect(rfq); - setOpen(false); - }; - - // RFQ 타입에 따른 표시 형식 - const getRfqTypeLabel = (type: RfqType) => { - switch(type) { - case RfqType.BUDGETARY: - return "BUDGETARY"; - case RfqType.PURCHASE_BUDGETARY: - return "PURCHASE_BUDGETARY"; - case RfqType.PURCHASE: - return "PURCHASE"; - default: - return type; - } - }; - - return ( - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={open} - className="w-full justify-between" - > - {selectedRfq - ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}` - : placeholder} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput - placeholder={getSearchPlaceholder()} - value={searchTerm} - onValueChange={setSearchTerm} - /> - <CommandList - className="max-h-[300px]" - ref={listRef} - onScroll={handleScroll} - > - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - - <CommandGroup> - <CommandItem - value="none" - onSelect={() => handleRfqSelect(null)} - > - <Check - className={cn( - "mr-2 h-4 w-4", - !selectedRfq - ? "opacity-100" - : "opacity-0" - )} - /> - <span className="font-medium">선택 안함</span> - </CommandItem> - </CommandGroup> - - {groupedRfqs.map((group, index) => ( - <CommandGroup - key={`group-${group.projectId || index}`} - heading={ - group.projectId - ? `${group.projectCode || ""} - ${group.projectName || ""}` - : "프로젝트 없음" - } - > - {group.rfqs.map((rfq) => ( - <CommandItem - key={rfq.id} - value={`${rfq.rfqCode || ""} ${rfq.description || ""}`} - onSelect={() => handleRfqSelect(rfq)} - > - <Check - className={cn( - "mr-2 h-4 w-4", - selectedRfq?.id === rfq.id - ? "opacity-100" - : "opacity-0" - )} - /> - <div className="flex flex-col"> - <div className="flex items-center"> - <span className="font-medium">{rfq.rfqCode || ""}</span> - <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700"> - {getRfqTypeLabel(rfq.rfqType)} - </span> - </div> - {rfq.description && ( - <span className="text-sm text-gray-500 truncate"> - {rfq.description} - </span> - )} - </div> - </CommandItem> - ))} - </CommandGroup> - ))} - - {loading && ( - <div className="py-2 text-center"> - <Loader className="h-4 w-4 animate-spin mx-auto" /> - </div> - )} - - {!loading && !hasMore && parentRfqs.length > 0 && ( - <div className="py-2 text-center text-sm text-muted-foreground"> - 총 {totalCount}개 중 {parentRfqs.length}개 표시됨 - </div> - )} - </CommandList> - </Command> - </PopoverContent> - </Popover> - ); -}
\ No newline at end of file diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx deleted file mode 100644 index 67561b4f..00000000 --- a/lib/rfqs/table/add-rfq-dialog.tsx +++ /dev/null @@ -1,468 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { toast } from "sonner" - -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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" - -import { useSession } from "next-auth/react" -import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations" -import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service" -import { ProjectSelector } from "@/components/ProjectSelector" -import { type Project } from "../service" -import { ParentRfqSelector } from "./ParentRfqSelector" -import { EstimateProjectSelector } from "@/components/BidProjectSelector" - -// 부모 RFQ 정보 타입 정의 -interface ParentRfq { - id: number; - rfqCode: string; - description: string | null; - rfqType: RfqType; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -interface AddRfqDialogProps { - rfqType?: RfqType; -} - -export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) { - const [open, setOpen] = React.useState(false) - const { data: session, status } = useSession() - const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]) - const [isLoadingParents, setIsLoadingParents] = React.useState(false) - const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) - const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false) - - // Get the user ID safely, ensuring it's a valid number - const userId = React.useMemo(() => { - const id = session?.user?.id ? Number(session.user.id) : null; - - return id; - }, [session, status]); - - // RfqType에 따른 타이틀 생성 - const getTitle = () => { - switch (rfqType) { - case RfqType.PURCHASE: - return "Purchase RFQ"; - case RfqType.BUDGETARY: - return "Budgetary RFQ"; - case RfqType.PURCHASE_BUDGETARY: - return "Purchase Budgetary RFQ"; - default: - return "RFQ"; - } - }; - - // RfqType 설명 가져오기 - const getTypeDescription = () => { - switch (rfqType) { - case RfqType.PURCHASE: - return "실제 구매 발주 전에 가격을 요청"; - case RfqType.BUDGETARY: - return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; - case RfqType.PURCHASE_BUDGETARY: - return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; - default: - return ""; - } - }; - - // RHF + Zod - const form = useForm<CreateRfqSchema>({ - resolver: zodResolver(createRfqSchema), - defaultValues: { - rfqCode: "", - description: "", - projectId: undefined, - parentRfqId: undefined, - dueDate: new Date(), - status: "DRAFT", - rfqType: rfqType, - // Don't set createdBy yet - we'll set it when the form is submitted - createdBy: undefined, - }, - }); - - // Update form values when session loads - React.useEffect(() => { - if (status === "authenticated" && userId) { - form.setValue("createdBy", userId); - } - }, [status, userId, form]); - - // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성 - React.useEffect(() => { - if (open) { - const generateRfqCode = async () => { - setIsLoadingRfqCode(true); - try { - // 서버 액션 호출 - const result = await generateNextRfqCode(rfqType); - - if (result.error) { - toast.error(`RFQ 코드 생성 실패: ${result.error}`); - return; - } - - // 생성된 코드를 폼에 설정 - form.setValue("rfqCode", result.code); - } catch (error) { - console.error("RFQ 코드 생성 오류:", error); - toast.error("RFQ 코드 생성에 실패했습니다"); - } finally { - setIsLoadingRfqCode(false); - } - }; - - generateRfqCode(); - } - }, [open, rfqType, form]); - - // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 - const getParentRfqTypes = (): RfqType[] => { - switch (rfqType) { - case RfqType.PURCHASE: - // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 - return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; - case RfqType.PURCHASE_BUDGETARY: - // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 - return [RfqType.BUDGETARY]; - default: - return []; - } - }; - - // 선택 가능한 부모 RFQ 목록 로드 - React.useEffect(() => { - if ((rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY) && open) { - const loadParentRfqs = async () => { - setIsLoadingParents(true); - try { - // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기 - const parentTypes = getParentRfqTypes(); - - // 부모 RFQ 타입이 있을 때만 API 호출 - if (parentTypes.length > 0) { - const result = await getBudgetaryRfqs({ - rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요 - }); - - if ('rfqs' in result) { - setParentRfqs(result.rfqs as unknown as ParentRfq[]); - } else if ('error' in result) { - console.error("부모 RFQ 로드 오류:", result.error); - } - } - } catch (error) { - console.error("부모 RFQ 로드 오류:", error); - } finally { - setIsLoadingParents(false); - } - }; - - loadParentRfqs(); - } - }, [rfqType, open]); - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - - form.setValue("projectId", project.id); - }; - - const handleBidProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - - form.setValue("bidProjectId", project.id); - }; - - // 부모 RFQ 선택 처리 - const handleParentRfqSelect = (rfq: ParentRfq | null) => { - setSelectedParentRfq(rfq); - form.setValue("parentRfqId", rfq?.id); - }; - - async function onSubmit(data: CreateRfqSchema) { - // Check if user is authenticated before submitting - if (status !== "authenticated" || !userId) { - toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요."); - return; - } - - // Make sure createdBy is set with the current user ID - const submitData = { - ...data, - createdBy: userId - }; - - console.log("Submitting form data:", submitData); - - const result = await createRfq(submitData); - if (result.error) { - toast.error(`에러: ${result.error}`); - return; - } - - toast.success("RFQ가 성공적으로 생성되었습니다."); - form.reset(); - setSelectedParentRfq(null); - setOpen(false); - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset(); - setSelectedParentRfq(null); - } - setOpen(nextOpen); - } - - // Return a message or disabled state if user is not authenticated - if (status === "loading") { - return <Button variant="outline" size="sm" disabled>Loading...</Button>; - } - - // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정 - const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; - const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY; - - // 부모 RFQ 선택기 레이블 및 설명 가져오기 - const getParentRfqSelectorLabel = () => { - if (rfqType === RfqType.PURCHASE) { - return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "부모 RFQ (BUDGETARY)"; - } - return "부모 RFQ"; - }; - - const getParentRfqDescription = () => { - if (rfqType === RfqType.PURCHASE) { - return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; - } - return ""; - }; - - return ( - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - {/* 모달을 열기 위한 버튼 */} - <DialogTrigger asChild> - <Button variant="default" size="sm"> - Add {getTitle()} - </Button> - </DialogTrigger> - - <DialogContent> - <DialogHeader> - <DialogTitle>Create New {getTitle()}</DialogTitle> - <DialogDescription> - 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요. - <div className="mt-1 text-xs text-muted-foreground"> - {getTypeDescription()} - </div> - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> - {/* rfqType - hidden field */} - <FormField - control={form.control} - name="rfqType" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* Project Selector */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel>Project</FormLabel> - <FormControl> - - {shouldShowEstimateSelector ? - <EstimateProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleBidProjectSelect} - placeholder="견적 프로젝트 선택..." - /> : - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - />} - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} - {shouldShowParentRfqSelector && ( - <FormField - control={form.control} - name="parentRfqId" - render={({ field }) => ( - <FormItem> - <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> - <FormControl> - <ParentRfqSelector - selectedRfqId={field.value as number | undefined} - onRfqSelect={handleParentRfqSelect} - rfqType={rfqType} - parentRfqTypes={getParentRfqTypes()} - placeholder={ - rfqType === RfqType.PURCHASE - ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." - : "BUDGETARY RFQ 선택..." - } - /> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - {getParentRfqDescription()} - </div> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* rfqCode - 자동 생성되고 읽기 전용 */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Code</FormLabel> - <FormControl> - <div className="flex"> - <Input - placeholder="자동으로 생성 중..." - {...field} - disabled={true} - className="bg-muted" - /> - {isLoadingRfqCode && ( - <div className="ml-2 flex items-center"> - <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div> - </div> - )} - </div> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다 - </div> - <FormMessage /> - </FormItem> - )} - /> - - {/* description */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Description</FormLabel> - <FormControl> - <Input placeholder="e.g. 설명을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* dueDate */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel>Due Date</FormLabel> - <FormControl> - <Input - type="date" - value={field.value ? field.value.toISOString().slice(0, 10) : ""} - onChange={(e) => { - const val = e.target.value - if (val) { - const date = new Date(val); - // 날짜 1일씩 밀리는 문제로 우선 KTC로 입력 - // 추후 아래와 같이 수정 - // 1. 해당 유저 타임존 값으로 입력 - // 2. DB에는 UTC 타임존 값으로 저장 - // 3. 출력시 유저별 타임존 값으로 변환해 출력 - // 4. 어떤 타임존으로 나오는지도 함께 렌더링 - // field.onChange(new Date(val + "T00:00:00")) - field.onChange(date); - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* status (Read-only) */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Input - disabled - className="capitalize" - {...field} - onChange={() => { }} // Prevent changes - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setOpen(false)} - > - Cancel - </Button> - <Button - type="submit" - disabled={form.formState.isSubmitting || status !== "authenticated"} - > - Create - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx deleted file mode 100644 index fdfb5e9a..00000000 --- a/lib/rfqs/table/attachment-rfq-sheet.tsx +++ /dev/null @@ -1,429 +0,0 @@ -"use client" - -import * as React from "react" -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" - -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription -} from "@/components/ui/form" -import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { Badge } from "@/components/ui/badge" - -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, -} from "@/components/ui/file-list" - -import prettyBytes from "pretty-bytes" -import { processRfqAttachments } from "../service" -import { formatDate } from "@/lib/utils" -import { RfqType } from "../validations" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { quickDownload } from "@/lib/file-download" -import { type FileRejection } from "react-dropzone" - -const MAX_FILE_SIZE = 6e8 // 600MB - -/** 기존 첨부 파일 정보 */ -interface ExistingAttachment { - id: number - fileName: string - filePath: string - createdAt?: Date // or Date - vendorId?: number | null - size?: number -} - -/** 새로 업로드할 파일 */ -const newUploadSchema = z.object({ - fileObj: z.any().optional(), // 실제 File -}) - -/** 기존 첨부 (react-hook-form에서 관리) */ -const existingAttachSchema = z.object({ - id: z.number(), - fileName: z.string(), - filePath: z.string(), - vendorId: z.number().nullable().optional(), - createdAt: z.custom<Date>().optional(), // or use z.any().optional() - size: z.number().optional(), -}) - -/** RHF 폼 전체 스키마 */ -const attachmentsFormSchema = z.object({ - rfqId: z.number().int(), - existing: z.array(existingAttachSchema), - newUploads: z.array(newUploadSchema), -}) - -type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> - -interface RfqAttachmentsSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - defaultAttachments?: ExistingAttachment[] - rfqType?: RfqType - rfq: RfqWithItemCount | null - /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ - onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void -} - -/** - * RfqAttachmentsSheet: - * - 기존 첨부 목록 (다운로드 + 삭제) - * - 새 파일 Dropzone - * - Save 시 processRfqAttachments(server action) - */ -export function RfqAttachmentsSheet({ - defaultAttachments = [], - onAttachmentsUpdated, - rfq, - rfqType, - ...props -}: RfqAttachmentsSheetProps) { - const { toast } = useToast() - const [isPending, startUpdate] = React.useTransition() - const rfqId = rfq?.rfqId ?? 0; - - // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 - const isEditable = rfq?.status === "DRAFT"; - - // React Hook Form - const form = useForm<AttachmentsFormValues>({ - resolver: zodResolver(attachmentsFormSchema), - defaultValues: { - rfqId, - existing: [], - newUploads: [], - }, - }) - - const { reset, control, handleSubmit } = form - - // defaultAttachments가 바뀔 때마다, RHF 상태를 reset - React.useEffect(() => { - reset({ - rfqId, - existing: defaultAttachments.map((att) => ({ - ...att, - vendorId: att.vendorId ?? null, - size: att.size ?? undefined, - })), - newUploads: [], - }) - }, [rfqId, defaultAttachments, reset]) - - // Field Arrays - const { - fields: existingFields, - remove: removeExisting, - } = useFieldArray({ control, name: "existing" }) - - const { - fields: newUploadFields, - append: appendNewUpload, - remove: removeNewUpload, - } = useFieldArray({ control, name: "newUploads" }) - - // 기존 첨부 항목 중 삭제된 것 찾기 - function findRemovedExistingIds(data: AttachmentsFormValues): number[] { - const finalIds = data.existing.map((att) => att.id) - const originalIds = defaultAttachments.map((att) => att.id) - return originalIds.filter((id) => !finalIds.includes(id)) - } - - async function onSubmit(data: AttachmentsFormValues) { - // 편집 불가능한 상태에서는 제출 방지 - if (!isEditable) return; - - startUpdate(async () => { - try { - const removedExistingIds = findRemovedExistingIds(data) - const newFiles = data.newUploads - .map((it) => it.fileObj) - .filter((f): f is File => !!f) - - // 서버 액션 - const res = await processRfqAttachments({ - rfqId, - removedExistingIds, - newFiles, - vendorId: null, // vendor ID if needed - rfqType - }) - - if (!res.ok) throw new Error(res.error ?? "Unknown error") - - const newCount = res.updatedItemCount ?? 0 - - toast({ - variant: "default", - title: "Success", - description: "File(s) updated", - }) - - // 상위 테이블 등에 itemCount 업데이트 - onAttachmentsUpdated?.(rfqId, newCount) - - // 모달 닫기 - props.onOpenChange?.(false) - } catch (err) { - toast({ - variant: "destructive", - title: "Error", - description: String(err), - }) - } - }) - } - - /** 기존 첨부 - X 버튼 */ - function handleRemoveExisting(idx: number) { - // 편집 불가능한 상태에서는 삭제 방지 - if (!isEditable) return; - removeExisting(idx) - } - - /** 드롭존에서 파일 받기 */ - function handleDropAccepted(acceptedFiles: File[]) { - // 편집 불가능한 상태에서는 파일 추가 방지 - if (!isEditable) return; - const mapped = acceptedFiles.map((file) => ({ fileObj: file })) - appendNewUpload(mapped) - } - - /** 드롭존에서 파일 거부(에러) */ - function handleDropRejected(fileRejections: FileRejection[]) { - // 편집 불가능한 상태에서는 무시 - if (!isEditable) return; - - fileRejections.forEach((rej) => { - toast({ - variant: "destructive", - title: "File Error", - description: rej.file.name + " not accepted", - }) - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> - <SheetHeader> - <SheetTitle className="flex items-center gap-2"> - {isEditable ? "Manage Attachments" : "View Attachments"} - {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} - className="ml-1" - > - {rfq.status} - </Badge> - )} - </SheetTitle> - <SheetDescription> - {`RFQ ${rfq?.rfqCode} - `} - {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} - {!isEditable && ( - <div className="mt-1 text-xs flex items-center gap-1 text-amber-600"> - <AlertCircle className="h-3 w-3" /> - <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span> - </div> - )} - </SheetDescription> - </SheetHeader> - - <Form {...form}> - <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> - {/* 1) 기존 첨부 목록 */} - <div className="space-y-2"> - <p className="font-semibold text-sm">Existing Attachments</p> - {existingFields.length === 0 && ( - <p className="text-sm text-muted-foreground">No existing attachments</p> - )} - {existingFields.map((field, index) => { - const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" - return ( - <div - key={field.id} - className="flex items-center justify-between rounded border p-2" - > - <div className="flex flex-col text-sm"> - <span className="font-medium"> - {field.fileName} {vendorLabel} - </span> - {field.size && ( - <span className="text-xs text-muted-foreground"> - {Math.round(field.size / 1024)} KB - </span> - )} - {field.createdAt && ( - <span className="text-xs text-muted-foreground"> - Created at {formatDate(field.createdAt, "KR")} - </span> - )} - </div> - <div className="flex items-center gap-2"> - {/* 1) Download button (if filePath) */} - {field.filePath && ( - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => quickDownload(field.filePath, field.fileName)} - > - <Download className="h-4 w-4" /> - </Button> - )} - {/* 2) Remove button - 편집 가능할 때만 표시 */} - {isEditable && ( - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => handleRemoveExisting(index)} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - </div> - ) - })} - </div> - - {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} - {isEditable ? ( - <> - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - > - {({ maxSize }) => ( - <FormField - control={control} - name="newUploads" // not actually used for storing each file detail - render={() => ( - <FormItem> - <FormLabel>Drop Files Here</FormLabel> - <DropzoneZone className="flex justify-center"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to upload</DropzoneTitle> - <DropzoneDescription> - Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription>Alternatively, click browse.</FormDescription> - <FormMessage /> - </FormItem> - )} - /> - )} - </Dropzone> - - {/* newUpload fields -> FileList */} - {newUploadFields.length > 0 && ( - <div className="grid gap-4"> - <h6 className="font-semibold leading-none tracking-tight"> - {`Files (${newUploadFields.length})`} - </h6> - <FileList> - {newUploadFields.map((field, idx) => { - const fileObj = form.getValues(`newUploads.${idx}.fileObj`) - if (!fileObj) return null - - const fileName = fileObj.name - const fileSize = fileObj.size - return ( - <FileListItem key={field.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileName}</FileListName> - <FileListDescription> - {`${prettyBytes(fileSize)}`} - </FileListDescription> - </FileListInfo> - <FileListAction onClick={() => removeNewUpload(idx)}> - <X /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> - )} - </> - ) : ( - <div className="p-3 bg-muted rounded-md flex items-center justify-center"> - <div className="text-center text-sm text-muted-foreground"> - <Eye className="h-4 w-4 mx-auto mb-2" /> - <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> - </div> - </div> - )} - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - {isEditable ? "Cancel" : "Close"} - </Button> - </SheetClose> - {isEditable && ( - <Button - type="submit" - disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} - > - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - )} - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/delete-rfqs-dialog.tsx b/lib/rfqs/table/delete-rfqs-dialog.tsx deleted file mode 100644 index 09596bc7..00000000 --- a/lib/rfqs/table/delete-rfqs-dialog.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"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 { Rfq, RfqWithItemCount } from "@/db/schema/rfq" -import { removeRfqs } from "../service" - -interface DeleteRfqsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqs: Row<RfqWithItemCount>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteRfqsDialog({ - rfqs, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqsDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startDeleteTransition(async () => { - const { error } = await removeRfqs({ - ids: rfqs.map((rfq) => rfq.rfqId), - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - toast.success("Tasks 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 ({rfqs.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">{rfqs.length}</span> - {rfqs.length === 1 ? " task" : " rfqs"} 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 ({rfqs.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">{rfqs.length}</span> - {rfqs.length === 1 ? " task" : " rfqs"} 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/rfqs/table/feature-flags-provider.tsx b/lib/rfqs/table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs/table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"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/rfqs/table/feature-flags.tsx b/lib/rfqs/table/feature-flags.tsx deleted file mode 100644 index aaae6af2..00000000 --- a/lib/rfqs/table/feature-flags.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"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/rfqs/table/rfqs-table-columns.tsx b/lib/rfqs/table/rfqs-table-columns.tsx deleted file mode 100644 index 5c09fcf0..00000000 --- a/lib/rfqs/table/rfqs-table-columns.tsx +++ /dev/null @@ -1,315 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, Paperclip, Package } from "lucide-react" -import { toast } from "sonner" - -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 { getRFQStatusIcon } from "@/lib/tasks/utils" -import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" -import { RfqType } from "../validations" - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null> - > - openItemsModal: (rfqId: number) => void - openAttachmentsSheet: (rfqId: number) => void - router: NextRouter - rfqType?: RfqType -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - openItemsModal, - openAttachmentsSheet, - router, - rfqType, -}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<RfqWithItemCount> = { - 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<RfqWithItemCount> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - // Proceed 버튼 클릭 시 호출되는 함수 - const handleProceed = () => { - const rfq = row.original - const itemCount = Number(rfq.itemCount || 0) - const attachCount = Number(rfq.attachCount || 0) - - // 아이템과 첨부파일이 모두 0보다 커야 진행 가능 - if (itemCount > 0 && attachCount > 0) { - router.push( - rfqType === RfqType.PURCHASE - ? `/evcp/rfq/${rfq.rfqId}` - : `/evcp/budgetary/${rfq.rfqId}` - ) - } else { - // 조건을 충족하지 않는 경우 토스트 알림 표시 - if (itemCount === 0 && attachCount === 0) { - toast.error("아이템과 첨부파일을 먼저 추가해주세요.") - } else if (itemCount === 0) { - toast.error("아이템을 먼저 추가해주세요.") - } else { - toast.error("첨부파일을 먼저 추가해주세요.") - } - } - } - - 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> - <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={handleProceed}> - {row.original.status ==="DRAFT"?"Proceed":"View Detail"} - <DropdownMenuShortcut>↵</DropdownMenuShortcut> - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge) - // ---------------------------------------------------------------- - const itemsColumn: ColumnDef<RfqWithItemCount> = { - id: "items", - header: "Items", - cell: ({ row }) => { - const rfq = row.original - const itemCount = rfq.itemCount || 0 - - const handleClick = () => { - openItemsModal(rfq.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - itemCount > 0 ? `View ${itemCount} items` : "Add items" - } - > - <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {itemCount > 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" - > - {itemCount} - </Badge> - )} - <span className="sr-only"> - {itemCount > 0 ? `${itemCount} Items` : "Add Items"} - </span> - </Button> - ) - }, - enableSorting: false, - size: 60, - } - - // ---------------------------------------------------------------- - // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge) - // ---------------------------------------------------------------- - const attachmentsColumn: ColumnDef<RfqWithItemCount> = { - id: "attachments", - header: "Attachments", - cell: ({ row }) => { - const fileCount = row.original.attachCount ?? 0 - - const handleClick = () => { - openAttachmentsSheet(row.original.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - fileCount > 0 ? `View ${fileCount} files` : "Add files" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {fileCount > 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" - > - {fileCount} - </Badge> - )} - <span className="sr-only"> - {fileCount > 0 ? `${fileCount} Files` : "Add Files"} - </span> - </Button> - ) - }, - enableSorting: false, - size: 60, - } - - // ---------------------------------------------------------------- - // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {} - - rfqsColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<RfqWithItemCount> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple 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 = getRFQStatusIcon( - statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED" - ) - 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" || cfg.id === "updatedAt") { - const dateVal = cell.getValue() as Date - return formatDate(dateVal, "KR") - } - - return row.getValue(cfg.id) ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap -> nestedColumns - const nestedColumns: ColumnDef<RfqWithItemCount>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - attachmentsColumn, // 첨부파일 - actionsColumn, - itemsColumn, // 아이템 - ] -}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table-floating-bar.tsx b/lib/rfqs/table/rfqs-table-floating-bar.tsx deleted file mode 100644 index daef7e0b..00000000 --- a/lib/rfqs/table/rfqs-table-floating-bar.tsx +++ /dev/null @@ -1,338 +0,0 @@ -"use client" - -import * as React from "react" -import { Table } from "@tanstack/react-table" -import { toast } from "sonner" -import { Calendar, type CalendarProps } from "@/components/ui/calendar" -import { Button } from "@/components/ui/button" -import { Portal } from "@/components/ui/portal" -import { - Select, - SelectTrigger, - SelectContent, - SelectGroup, - SelectItem, - SelectValue, -} from "@/components/ui/select" -import { Separator } from "@/components/ui/separator" -import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip" -import { Kbd } from "@/components/kbd" -import { ActionConfirmDialog } from "@/components/ui/action-dialog" - -import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react" - -import { exportTableToExcel } from "@/lib/export" - -import { RfqWithItemCount, rfqs } from "@/db/schema/rfq" -import { modifyRfqs, removeRfqs } from "../service" - -interface RfqsTableFloatingBarProps { - table: Table<RfqWithItemCount> -} - -/** - * 추가된 로직: - * - 달력(캘린더) 아이콘 버튼 - * - 눌렀을 때 Popover로 Calendar 표시 - * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate }) - */ -export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) { - const rows = table.getFilteredSelectedRowModel().rows - const [isPending, startTransition] = React.useTransition() - const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">() - const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) - - const [confirmProps, setConfirmProps] = React.useState<{ - title: string - description?: string - onConfirm: () => Promise<void> | void - }>({ - title: "", - description: "", - onConfirm: () => {}, - }) - - // 캘린더 Popover 열림 여부 - const [calendarOpen, setCalendarOpen] = React.useState(false) - const [selectedDate, setSelectedDate] = React.useState<Date | null>(null) - - // 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]) - - function handleDeleteConfirm() { - setAction("delete") - setConfirmProps({ - title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`, - description: "This action cannot be undone.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await removeRfqs({ - ids: rows.map((row) => row.original.rfqId), - }) - if (error) { - toast.error(error) - return - } - toast.success("RFQs deleted") - table.toggleAllRowsSelected(false) - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - function handleSelectStatus(newStatus: RfqWithItemCount["status"]) { - setAction("update-status") - setConfirmProps({ - title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, - description: "This action will override their current status.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyRfqs({ - ids: rows.map((row) => row.original.rfqId), - status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", - }) - if (error) { - toast.error(error) - return - } - toast.success("RFQs updated") - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그 - function handleDueDateSelect(newDate: Date) { - setAction("update-dueDate") - - setConfirmProps({ - title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`, - description: "This action will override their current due date.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyRfqs({ - ids: rows.map((r) => r.original.rfqId), - dueDate: newDate, - }) - if (error) { - toast.error(error) - return - } - toast.success("Due date updated") - setConfirmDialogOpen(false) - setCalendarOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - // 2) Export - function handleExport() { - setAction("export") - startTransition(() => { - exportTableToExcel(table, { - excludeColumns: ["select", "actions"], - onlySelected: true, - }) - }) - } - - // Floating bar UI - return ( - <Portal> - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5"> - <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"> - {/* Selection Info + Clear */} - <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"> - {/* 1) Status Update */} - <Select - onValueChange={(value: RfqWithItemCount["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> - {rfqs.status.enumValues.map((status) => ( - <SelectItem key={status} value={status} className="capitalize"> - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - - {/* 2) Due Date Update: Calendar Popover */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - disabled={isPending} - onClick={() => setCalendarOpen((open) => !open)} - > - {isPending && action === "update-dueDate" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <CalendarIcon className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update Due Date</p> - </TooltipContent> - </Tooltip> - - {/* Calendar Popover (간단 구현) */} - {calendarOpen && ( - <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow"> - <Calendar - mode="single" - selected={selectedDate || new Date()} - onSelect={(date) => { - if (date) { - setSelectedDate(date) - handleDueDateSelect(date) - } - }} - initialFocus - /> - </div> - )} - - {/* 3) Export */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleExport} - 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 tasks</p> - </TooltipContent> - </Tooltip> - - {/* 4) Delete */} - <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 tasks</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 === "update-dueDate") - } - confirmLabel={ - action === "delete" - ? "Delete" - : action === "update-status" - ? "Update" - : action === "update-dueDate" - ? "Update" - : "Confirm" - } - confirmVariant={action === "delete" ? "destructive" : "default"} - /> - </Portal> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx deleted file mode 100644 index 6402e625..00000000 --- a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { DeleteRfqsDialog } from "./delete-rfqs-dialog" -import { AddRfqDialog } from "./add-rfq-dialog" -import { RfqType } from "../validations" - - -interface RfqsTableToolbarActionsProps { - table: Table<RfqWithItemCount> - rfqType?: RfqType; -} - -export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: RfqsTableToolbarActionsProps) { - return ( - <div className="flex items-center gap-2"> - {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteRfqsDialog - rfqs={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - - {/** 2) 새 Task 추가 다이얼로그 */} - <AddRfqDialog rfqType={rfqType} /> - - - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - 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/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx deleted file mode 100644 index 287f1d53..00000000 --- a/lib/rfqs/table/rfqs-table.tsx +++ /dev/null @@ -1,263 +0,0 @@ -"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 { getRFQStatusIcon } from "@/lib/tasks/utils" -import { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./rfqs-table-columns" -import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service" -import { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq" -import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar" -import { UpdateRfqSheet } from "./update-rfq-sheet" -import { DeleteRfqsDialog } from "./delete-rfqs-dialog" -import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions" -import { RfqsItemsDialog } from "./ItemsDialog" -import { getAllItems } from "@/lib/items/service" -import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" -import { useRouter } from "next/navigation" -import { RfqType } from "../validations" - -interface RfqsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getRfqs>>, - Awaited<ReturnType<typeof getRfqStatusCounts>>, - Awaited<ReturnType<typeof getAllItems>>, - ] - >; - rfqType?: RfqType; // rfqType props 추가 -} - -export interface ExistingAttachment { - id: number; - fileName: string; - filePath: string; - createdAt?: Date; - vendorId?: number | null; - size?: number; -} - -export interface ExistingItem { - id?: number; - itemCode: string; - description: string | null; - quantity: number | null; - uom: string | null; -} - -export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTableProps) { - const { featureFlags } = useFeatureFlags() - - const [{ data, pageCount }, statusCounts, items] = React.use(promises) - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) - const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) - const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([]) - - const router = useRouter() - - const itemsList = items?.map((v) => ({ - code: v.itemCode ?? "", - name: v.itemName ?? "", - })); - - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<RfqWithItemCount> | null>(null) - - const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data) - - const [itemsModalOpen, setItemsModalOpen] = React.useState(false); - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null); - - - const selectedRfq = React.useMemo(() => { - return rowData.find(row => row.rfqId === selectedRfqId) || null; - }, [rowData, selectedRfqId]); - - // rfqType에 따른 제목 계산 - const getRfqTypeTitle = () => { - return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ"; - }; - - async function openItemsModal(rfqId: number) { - const itemList = await fetchRfqItems(rfqId) - setItemsDefault(itemList) - setSelectedRfqId(rfqId); - setItemsModalOpen(true); - } - - async function openAttachmentsSheet(rfqId: number) { - // 4.1) Fetch current attachments from server (server action) - const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[] - setAttachDefault(list) - setSelectedRfqIdForAttachments(rfqId) - setAttachmentsOpen(true) - setSelectedRfqId(rfqId); - } - - function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) { - // 5.1) update rowData itemCount - setRowData(prev => - prev.map(r => - r.rfqId === rfqId - ? { ...r, itemCount: newCount } - : r - ) - ) - // 5.2) if newList is provided, store it - if (newList) { - setAttachDefault(newList) - } - } - - const columns = React.useMemo(() => getColumns({ - setRowAction, router, - // we pass openItemsModal as a prop so the itemsColumn can call it - openItemsModal, - openAttachmentsSheet, - rfqType - }), [setRowAction, router, rfqType]); - - /** - * This component can render either a faceted filter or a search filter based on the `options` prop. - */ - const filterFields: DataTableFilterField<RfqWithItemCount>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", - }, - { - id: "status", - label: "Status", - options: rfqs.status.enumValues?.map((status) => { - // 명시적으로 status를 허용된 리터럴 타입으로 변환 - const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; - return { - label: toSentenceCase(s), - value: s, - icon: getRFQStatusIcon(s), - count: statusCounts[s], - }; - }), - - } - ] - - /** - * Advanced filter fields for the data table. - */ - const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - type: "text", - }, - { - id: "description", - label: "Description", - type: "text", - }, - { - id: "projectCode", - label: "Project Code", - type: "text", - }, - { - id: "dueDate", - label: "Due Date", - type: "date", - }, - { - id: "status", - label: "Status", - type: "multi-select", - options: rfqs.status.enumValues?.map((status) => { - // 명시적으로 status를 허용된 리터럴 타입으로 변환 - const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; - return { - label: toSentenceCase(s), - value: s, - icon: getRFQStatusIcon(s), - count: statusCounts[s], - }; - }), - - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.rfqId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <div style={{ maxWidth: '100vw' }}> - <DataTable - table={table} - // floatingBar={<RfqsTableFloatingBar table={table} />} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqsTableToolbarActions table={table} rfqType={rfqType} /> - </DataTableAdvancedToolbar> - </DataTable> - - <UpdateRfqSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - rfq={rowAction?.row.original ?? null} - /> - - <DeleteRfqsDialog - open={rowAction?.type === "delete"} - onOpenChange={() => setRowAction(null)} - rfqs={rowAction?.row.original ? [rowAction?.row.original] : []} - showTrigger={false} - onSuccess={() => rowAction?.row.toggleSelected(false)} - /> - - <RfqsItemsDialog - open={itemsModalOpen} - onOpenChange={setItemsModalOpen} - rfq={selectedRfq ?? null} - itemsList={itemsList} - defaultItems={itemsDefault} - rfqType={rfqType} - /> - - <RfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - defaultAttachments={attachDefault} - rfqType={rfqType} - rfq={selectedRfq ?? null} - onAttachmentsUpdated={handleAttachmentsUpdated} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx deleted file mode 100644 index 22ca2c37..00000000 --- a/lib/rfqs/table/update-rfq-sheet.tsx +++ /dev/null @@ -1,406 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { useSession } from "next-auth/react" - -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 { Rfq, RfqWithItemCount } from "@/db/schema/rfq" -import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations" -import { modifyRfq, getBudgetaryRfqs } from "../service" -import { ProjectSelector } from "@/components/ProjectSelector" -import { type Project } from "../service" -import { ParentRfqSelector } from "./ParentRfqSelector" - -interface UpdateRfqSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - rfq: RfqWithItemCount | null -} - -// 부모 RFQ 정보 타입 정의 -interface ParentRfq { - id: number; - rfqCode: string; - description: string | null; - rfqType: RfqType; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const { data: session } = useSession() - const userId = Number(session?.user?.id || 1) - const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) - - // RFQ의 타입 가져오기 - const rfqType = rfq?.rfqType || RfqType.PURCHASE; - - // 초기 부모 RFQ ID 가져오기 - const initialParentRfqId = rfq?.parentRfqId; - - // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 - const getParentRfqTypes = (): RfqType[] => { - switch(rfqType) { - case RfqType.PURCHASE: - // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 - return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; - case RfqType.PURCHASE_BUDGETARY: - // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 - return [RfqType.BUDGETARY]; - default: - return []; - } - }; - - // 부모 RFQ 타입들 - const parentRfqTypes = getParentRfqTypes(); - - // 부모 RFQ를 보여줄지 결정 - const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; - - // 타입에 따른 타이틀 생성 - const getTypeTitle = () => { - switch(rfqType) { - case RfqType.PURCHASE: - return "Purchase RFQ"; - case RfqType.BUDGETARY: - return "Budgetary RFQ"; - case RfqType.PURCHASE_BUDGETARY: - return "Purchase Budgetary RFQ"; - default: - return "RFQ"; - } - }; - - // 타입 설명 가져오기 - const getTypeDescription = () => { - switch(rfqType) { - case RfqType.PURCHASE: - return "실제 구매 발주 전에 가격을 요청"; - case RfqType.BUDGETARY: - return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; - case RfqType.PURCHASE_BUDGETARY: - return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; - default: - return ""; - } - }; - - // 부모 RFQ 선택기 레이블 및 설명 가져오기 - const getParentRfqSelectorLabel = () => { - if (rfqType === RfqType.PURCHASE) { - return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "부모 RFQ (BUDGETARY)"; - } - return "부모 RFQ"; - }; - - const getParentRfqDescription = () => { - if (rfqType === RfqType.PURCHASE) { - return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; - } - return ""; - }; - - // 초기 부모 RFQ 로드 - React.useEffect(() => { - if (initialParentRfqId && shouldShowParentRfqSelector) { - const loadInitialParentRfq = async () => { - try { - const result = await getBudgetaryRfqs({ - rfqId: initialParentRfqId - }); - - if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { - setSelectedParentRfq(result.rfqs[0] as unknown as ParentRfq); - } - } catch (error) { - console.error("부모 RFQ 로드 오류:", error); - } - }; - - loadInitialParentRfq(); - } - }, [initialParentRfqId, shouldShowParentRfqSelector]); - - // RHF setup - const form = useForm<UpdateRfqSchema>({ - resolver: zodResolver(updateRfqSchema), - defaultValues: { - id: rfq?.rfqId ?? 0, // PK - rfqCode: rfq?.rfqCode ?? "", - description: rfq?.description ?? "", - projectId: rfq?.projectId, // 프로젝트 ID - parentRfqId: rfq?.parentRfqId, // 부모 RFQ ID - dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 - status: rfq?.status ?? "DRAFT", - createdBy: rfq?.createdBy ?? userId, - }, - }); - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - form.setValue("projectId", project.id); - }; - - // 부모 RFQ 선택 처리 - const handleParentRfqSelect = (rfq: ParentRfq | null) => { - setSelectedParentRfq(rfq); - form.setValue("parentRfqId", rfq?.id); - }; - - async function onSubmit(input: UpdateRfqSchema) { - startUpdateTransition(async () => { - if (!rfq) return - - const { error } = await modifyRfq({ - ...input, - rfqType: rfqType as RfqType, - - }) - - if (error) { - toast.error(error) - return - } - - form.reset() - props.onOpenChange?.(false) // close the sheet - toast.success("RFQ updated!") - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Update {getTypeTitle()}</SheetTitle> - <SheetDescription> - Update the {getTypeTitle()} details and save the changes - <div className="mt-1 text-xs text-muted-foreground"> - {getTypeDescription()} - </div> - </SheetDescription> - </SheetHeader> - - {/* RHF Form */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - - {/* Hidden or code-based id field */} - <FormField - control={form.control} - name="id" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* Hidden rfqType field */} - {/* <FormField - control={form.control} - name="rfqType" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> */} - - {/* Project Selector - 재사용 컴포넌트 사용 */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel>Project</FormLabel> - <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} - {shouldShowParentRfqSelector && ( - <FormField - control={form.control} - name="parentRfqId" - render={({ field }) => ( - <FormItem> - <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> - <FormControl> - <ParentRfqSelector - selectedRfqId={field.value as number | undefined} - onRfqSelect={handleParentRfqSelect} - rfqType={rfqType} - parentRfqTypes={parentRfqTypes} - placeholder={ - rfqType === RfqType.PURCHASE - ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." - : "BUDGETARY RFQ 선택..." - } - /> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - {getParentRfqDescription()} - </div> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* rfqCode */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Code</FormLabel> - <FormControl> - <Input placeholder="e.g. RFQ-2025-001" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* description */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>Description</FormLabel> - <FormControl> - <Input placeholder="Description" {...field} value={field.value || ""} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* dueDate (type="date") */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel>Due Date</FormLabel> - <FormControl> - <Input - type="date" - // convert Date -> yyyy-mm-dd - value={field.value ? field.value.toISOString().slice(0, 10) : ""} - onChange={(e) => { - const val = e.target.value - field.onChange(val ? new Date(val + "T00:00:00") : undefined) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* status (Select) */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Select - onValueChange={field.onChange} - value={field.value ?? "DRAFT"} - > - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => ( - <SelectItem key={item} value={item} className="capitalize"> - {item} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* createdBy (hidden or read-only) */} - <FormField - control={form.control} - name="createdBy" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 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/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx deleted file mode 100644 index b3cdbc60..00000000 --- a/lib/rfqs/tbe-table/comments-sheet.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { createRfqCommentWithAttachments } from "../service" -import { formatDate } from "@/lib/utils" - - -export interface TbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: TbeComment[] - currentUserId: number - rfqId: number - tbeId: number - vendorId: number - onCommentsUpdated?: (comments: TbeComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - tbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - console.log("tbeId", tbeId) - - const [comments, setComments] = React.useState<TbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt, "KR") : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - console.log("rfqId", rfqId) - console.log("vendorId", vendorId) - console.log("tbeId", tbeId) - console.log("currentUserId", currentUserId) - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: tbeId, - cbeId: null, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: TbeComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/feature-flags-provider.tsx b/lib/rfqs/tbe-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs/tbe-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"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/rfqs/tbe-table/file-dialog.tsx b/lib/rfqs/tbe-table/file-dialog.tsx deleted file mode 100644 index e19430a3..00000000 --- a/lib/rfqs/tbe-table/file-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client" - -import * as React from "react" -import { Download, X } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDateTime } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" - -import { - FileList, - FileListItem, - FileListIcon, - FileListInfo, - FileListName, - FileListDescription, - FileListAction, -} from "@/components/ui/file-list" -import { getTbeFilesForVendor, getTbeSubmittedFiles } from "../service" - -interface TBEFileDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - tbeId: number - vendorId: number - rfqId: number - onRefresh?: () => void -} - -export function TBEFileDialog({ - isOpen, - onOpenChange, - vendorId, - rfqId, - onRefresh, -}: TBEFileDialogProps) { - const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([]) - const [isFetchingFiles, setIsFetchingFiles] = React.useState(false) - - - // Fetch submitted files when dialog opens - React.useEffect(() => { - if (isOpen && rfqId && vendorId) { - fetchSubmittedFiles() - } - }, [isOpen, rfqId, vendorId]) - - // Fetch submitted files using the service function - const fetchSubmittedFiles = async () => { - if (!rfqId || !vendorId) return - - setIsFetchingFiles(true) - try { - const { files, error } = await getTbeFilesForVendor(rfqId, vendorId) - - if (error) { - throw new Error(error) - } - - setSubmittedFiles(files) - } catch (error) { - toast.error("Failed to load files: " + getErrorMessage(error)) - } finally { - setIsFetchingFiles(false) - } - } - - // Download submitted file - const downloadSubmittedFile = async (file: any) => { - try { - const response = await fetch(`/api/tbe-download?path=${encodeURIComponent(file.filePath)}`) - if (!response.ok) { - throw new Error("Failed to download file") - } - - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = file.fileName - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) - } catch (error) { - toast.error("Failed to download file: " + getErrorMessage(error)) - } - } - - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> - <DialogTitle>TBE 응답 파일</DialogTitle> - <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription> - </DialogHeader> - - {/* 제출된 파일 목록 */} - {isFetchingFiles ? ( - <div className="flex justify-center items-center py-8"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> - </div> - ) : submittedFiles.length > 0 ? ( - <div className="grid gap-2"> - <FileList> - {submittedFiles.map((file) => ( - <FileListItem key={file.id} className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-3 flex-1"> - <FileListIcon className="flex-shrink-0" /> - <FileListInfo className="flex-1 min-w-0"> - <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> - <FileListDescription className="text-xs text-muted-foreground"> - {file.uploadedAt ? formatDateTime(file.uploadedAt, "KR") : ""} - </FileListDescription> - </FileListInfo> - </div> - <FileListAction className="flex-shrink-0 ml-2"> - <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> - <Download className="h-4 w-4" /> - <span className="sr-only">파일 다운로드</span> - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - </div> - ) : ( - <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx deleted file mode 100644 index 935d2bf3..00000000 --- a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,220 +0,0 @@ -"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 { Input } from "@/components/ui/input" - -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { inviteTbeVendorsAction } from "../service" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Badge } from "@/components/ui/badge" -import { Label } from "@/components/ui/label" - -interface InviteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - vendors: Row<VendorWithTbeFields>["original"][] - rfqId: number - showTrigger?: boolean - onSuccess?: () => void -} - -export function InviteVendorsDialog({ - vendors, - rfqId, - showTrigger = true, - onSuccess, - ...props -}: InviteVendorsDialogProps) { - const [isInvitePending, startInviteTransition] = React.useTransition() - - - // multiple 파일을 받을 state - const [files, setFiles] = React.useState<FileList | null>(null) - - // 미디어쿼리 (desktop 여부) - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onInvite() { - startInviteTransition(async () => { - // 파일이 선택되지 않았다면 에러 - if (!files || files.length === 0) { - toast.error("Please attach TBE files before inviting.") - return - } - - // FormData 생성 - const formData = new FormData() - formData.append("rfqId", String(rfqId)) - vendors.forEach((vendor) => { - formData.append("vendorIds[]", String(vendor.id)) - }) - - // multiple 파일 - for (let i = 0; i < files.length; i++) { - formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles" - } - - // 서버 액션 호출 - const { error } = await inviteTbeVendorsAction(formData) - - if (error) { - toast.error(error) - return - } - - // 성공 - props.onOpenChange?.(false) - toast.success("Vendors invited with TBE!") - onSuccess?.() - }) - } - - // 파일 선택 UI - const fileInput = ( -<> - <div className="space-y-2"> - <Label>선택된 협력업체 ({vendors.length})</Label> - <ScrollArea className="h-20 border rounded-md p-2"> - <div className="flex flex-wrap gap-2"> - {vendors.map((vendor, index) => ( - <Badge key={index} variant="secondary" className="py-1"> - {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} - </Badge> - ))} - </div> - </ScrollArea> - <p className="text-[0.8rem] font-medium text-muted-foreground"> - 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다. - </p> - </div> - - <div className="mb-4"> - <label className="mb-2 block font-medium">TBE Sheets</label> - <Input - type="file" - multiple - onChange={(e) => { - setFiles(e.target.files) - }} - /> - </div> - </> - ) - - // Desktop Dialog - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - TBE 평가 생성 ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>TBE 평가 시트 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. - </DialogDescription> - </DialogHeader> - - {/* 파일 첨부 */} - {fileInput} - - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onInvite} - // 파일이 없거나 초대 진행중이면 비활성화 - disabled={isInvitePending || !files || files.length === 0} - > - {isInvitePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Invite - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - // Mobile Drawer - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DialogTitle>TBE 평가 시트 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. - </DialogDescription> - </DrawerHeader> - - {/* 파일 첨부 */} - {fileInput} - - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onInvite} - // 파일이 없거나 초대 진행중이면 비활성화 - disabled={isInvitePending || !files || files.length === 0} - > - {isInvitePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Invite - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-result-dialog.tsx b/lib/rfqs/tbe-table/tbe-result-dialog.tsx deleted file mode 100644 index 8400ecac..00000000 --- a/lib/rfqs/tbe-table/tbe-result-dialog.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" - -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { getErrorMessage } from "@/lib/handle-error" -import { saveTbeResult } from "../service" - -// Define the props for the TbeResultDialog component -interface TbeResultDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - tbe: VendorWithTbeFields | null - onRefresh?: () => void -} - -// Define TBE result options -const TBE_RESULT_OPTIONS = [ - { value: "pass", label: "Pass", badgeVariant: "default" }, - { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" }, - { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" }, -] as const - -type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"] - -export function TbeResultDialog({ - open, - onOpenChange, - tbe, - onRefresh, -}: TbeResultDialogProps) { - // Initialize state for form inputs - const [result, setResult] = React.useState<TbeResultOption | "">("") - const [note, setNote] = React.useState("") - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // Update form values when the tbe prop changes - React.useEffect(() => { - if (tbe) { - setResult((tbe.tbeResult as TbeResultOption) || "") - setNote(tbe.tbeNote || "") - } - }, [tbe]) - - // Reset form when dialog closes - React.useEffect(() => { - if (!open) { - // Small delay to avoid visual glitches when dialog is closing - const timer = setTimeout(() => { - if (!tbe) { - setResult("") - setNote("") - } - }, 300) - return () => clearTimeout(timer) - } - }, [open, tbe]) - - // Handle form submission with server action - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!tbe || !result) return - - setIsSubmitting(true) - - try { - // Call the server action to save the TBE result - const response = await saveTbeResult({ - id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table - vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table - result: result, // The selected evaluation result - notes: note, // The evaluation notes - }) - - if (!response.success) { - throw new Error(response.message || "Failed to save TBE result") - } - - // Show success toast - toast.success("TBE result saved successfully") - - // Close the dialog - onOpenChange(false) - - // Refresh the data if refresh callback is provided - if (onRefresh) { - onRefresh() - } - } catch (error) { - // Show error toast - toast.error(`Failed to save: ${getErrorMessage(error)}`) - } finally { - setIsSubmitting(false) - } - } - - // Find the selected result option - const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result) - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle className="text-xl font-semibold"> - {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"} - </DialogTitle> - {tbe && ( - <DialogDescription className="text-sm text-muted-foreground mt-1"> - <div className="flex flex-col gap-1"> - <span> - <strong>Vendor:</strong> {tbe.vendorName} - </span> - <span> - <strong>RFQ Code:</strong> {tbe.rfqCode} - </span> - {tbe.email && ( - <span> - <strong>Email:</strong> {tbe.email} - </span> - )} - </div> - </DialogDescription> - )} - </DialogHeader> - - <form onSubmit={handleSubmit} className="space-y-6 py-2"> - <div className="space-y-2"> - <Label htmlFor="tbe-result" className="text-sm font-medium"> - Evaluation Result - </Label> - <Select - value={result} - onValueChange={(value) => setResult(value as TbeResultOption)} - required - > - <SelectTrigger id="tbe-result" className="w-full"> - <SelectValue placeholder="Select a result" /> - </SelectTrigger> - <SelectContent> - {TBE_RESULT_OPTIONS.map((option) => ( - <SelectItem key={option.value} value={option.value}> - <div className="flex items-center"> - <Badge variant={option.badgeVariant as any} className="mr-2"> - {option.label} - </Badge> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label htmlFor="tbe-note" className="text-sm font-medium"> - Evaluation Note - </Label> - <Textarea - id="tbe-note" - placeholder="Enter evaluation notes..." - value={note} - onChange={(e) => setNote(e.target.value)} - className="min-h-[120px] resize-y" - /> - </div> - - <DialogFooter className="gap-2 sm:gap-0"> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - Cancel - </Button> - <Button - type="submit" - disabled={!result || isSubmitting} - className="min-w-[100px]" - > - {isSubmitting ? "Saving..." : "Save"} - </Button> - </DialogFooter> - </form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx deleted file mode 100644 index 0538d354..00000000 --- a/lib/rfqs/tbe-table/tbe-table-columns.tsx +++ /dev/null @@ -1,373 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } 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 { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { - vendorTbeColumnsConfig, - VendorWithTbeFields, -} from "@/config/vendorTbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - openFilesDialog: (tbeId:number , vendorId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - openCommentSheet, - openFilesDialog, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithTbeFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {} - - vendorTbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithTbeFields> - const childCol: ColumnDef<VendorWithTbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - if (cfg.id === "tbeResult") { - const vendor = row.original; - const tbeResult = vendor.tbeResult; - const filesCount = vendor.files?.length ?? 0; - - // Only show button or link if there are files - if (filesCount > 0) { - // Function to handle clicking on the result - const handleTbeResultClick = () => { - setRowAction({ row, type: "tbeResult" }); - }; - - if (!tbeResult) { - // No result yet, but files exist - show "결과 입력" button - return ( - <Button - variant="outline" - size="sm" - onClick={handleTbeResultClick} - > - 결과 입력 - </Button> - ); - } else { - // Result exists - show as a hyperlink - let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; - - // Set badge variant based on result - if (tbeResult === "pass") { - badgeVariant = "default"; - } else if (tbeResult === "non-pass") { - badgeVariant = "destructive"; - } else if (tbeResult === "conditional pass") { - badgeVariant = "secondary"; - } - - return ( - <Button - variant="link" - className="p-0 h-auto underline" - onClick={handleTbeResultClick} - > - <Badge variant={badgeVariant}> - {tbeResult} - </Badge> - </Button> - ); - } - } - - // No files available, return empty cell - return null; - } - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - - - // 예) TBE Updated (날짜) - if (cfg.id === "tbeUpdated") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal, "KR") - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithTbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - -// ---------------------------------------------------------------- -// 3) Comments 컬럼 -// ---------------------------------------------------------------- -const commentsColumn: ColumnDef<VendorWithTbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.tbeId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize:80 -} - - // ---------------------------------------------------------------- - // 4) Actions 컬럼 (예: 초대하기 버튼) - // ---------------------------------------------------------------- - // const actionsColumn: ColumnDef<VendorWithTbeFields> = { - // id: "actions", - // cell: ({ row }) => { - // const status = row.original.tbeResult - // // 예: 만약 tbeResult가 없을 때만 초대하기 버튼 표시 - // if (status) { - // return null - // } - - // return ( - // <Button - // onClick={() => setRowAction({ row, type: "invite" })} - // size="sm" - // variant="outline" - // > - // 발행하기 - // </Button> - // ) - // }, - // size: 80, - // enableSorting: false, - // enableHiding: false, - // } -// ---------------------------------------------------------------- -// 3) Files Column - Add before Comments column -// ---------------------------------------------------------------- -const filesColumn: ColumnDef<VendorWithTbeFields> = { - id: "files", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response Files" /> - ), - cell: ({ row }) => { - const vendor = row.original - // We'll assume that files count will be populated from the backend - // You'll need to modify your getTBE function to include files - const filesCount = vendor.files?.length ?? 0 - - function handleClick() { - // Open files dialog - setRowAction({ row, type: "files" }) - openFilesDialog(vendor.tbeId ?? 0, vendor.vendorId ?? 0) - } - - return ( - <div className="flex items-center justify-center"> -<Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"} -> - {/* 아이콘: 중앙 정렬을 위해 Button 자체가 flex container */} - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - - {/* 파일 개수가 1개 이상이면 뱃지 표시 */} - {filesCount > 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" - > - {filesCount} - </Badge> - )} - - <span className="sr-only"> - {filesCount > 0 ? `${filesCount} Files` : "Upload File"} - </span> -</Button> - </div> - ) - }, - enableSorting: false, - maxSize: 80 -} - -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - filesColumn, // Add the files column before comments - commentsColumn, - // actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx deleted file mode 100644 index a8f8ea82..00000000 --- a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithTbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.technicalResponseStatus === null); - }, [table.getFilteredSelectedRowModel().rows]); - - return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && - ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId = {rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) - } - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - 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/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx deleted file mode 100644 index 0add8927..00000000 --- a/lib/rfqs/tbe-table/tbe-table.tsx +++ /dev/null @@ -1,220 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -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 { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./tbe-table-columns" -import { Vendor, vendors } from "@/db/schema/vendors" -import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" -import { fetchRfqAttachmentsbyCommentId, getTBE } from "../service" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { CommentSheet, TbeComment } from "./comments-sheet" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { TBEFileDialog } from "./file-dialog" -import { TbeResultDialog } from "./tbe-result-dialog" -import { VendorContactsDialog } from "./vendor-contact-dialog" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getTBE>>, - ] - > - rfqId: number -} - - -export function TbeTable({ promises, rfqId }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - console.log("data", data) - const { data: session } = useSession() // 세션 정보 가져오기 - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) - - // **router** 획득 - const router = useRouter() - - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) - - // Add handleRefresh function - const handleRefresh = React.useCallback(() => { - router.refresh(); - }, [router]); - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.id)) - } else if (rowAction?.type === "files") { - // Handle files action - const vendorId = rowAction.row.original.vendorId; - const tbeId = rowAction.row.original.tbeId ?? 0; - openFilesDialog(tbeId, vendorId); - } - }, [rowAction]) - - async function openCommentSheet(a: number) { - setInitialComments([]) - - const comments = rowAction?.row.original.comments - const rfqId = rowAction?.row.original.rfqId - const vendorId = rowAction?.row.original.vendorId - const tbeId = rowAction?.row.original.tbeId - console.log("original", rowAction?.row.original) - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 - setInitialComments(commentWithAttachments) - } - setSelectedTbeId(tbeId ?? 0) - setSelectedVendorId(vendorId ?? 0) - setSelectedRfqIdForComments(rfqId ?? 0) - setCommentSheetOpen(true) - } - - const openFilesDialog = (tbeId: number, vendorId: number) => { - setSelectedTbeId(tbeId) - setSelectedVendorId(vendorId) - setIsFileDialogOpen(true) - } - const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) - } - - // getColumns() 호출 시, router를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }), - [setRowAction, router] - ) - - const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [ - ] - - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - ] - - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["comments"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - - - return ( - <div style={{ maxWidth: '80vw' }}> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={rfqId} - open={rowAction?.type === "invite"} - showTrigger={false} - /> - <CommentSheet - currentUserId={currentUserId} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={rfqId} - tbeId={selectedTbeId ?? 0} - vendorId={selectedVendorId ?? 0} - initialComments={initialComments} - /> - - <TBEFileDialog - isOpen={isFileDialogOpen} - onOpenChange={setIsFileDialogOpen} - tbeId={selectedTbeId ?? 0} - vendorId={selectedVendorId ?? 0} - rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId - onRefresh={handleRefresh} - /> - - <TbeResultDialog - open={rowAction?.type === "tbeResult"} - onOpenChange={() => setRowAction(null)} - tbe={rowAction?.row.original ?? null} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx deleted file mode 100644 index 3619fe77..00000000 --- a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { VendorContactsTable } from "./vendor-contact/vendor-contact-table" -import { Badge } from "@/components/ui/badge" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" - -interface VendorContactsDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - vendorId: number | null - vendor: VendorWithTbeFields | null -} - -export function VendorContactsDialog({ - isOpen, - onOpenChange, - vendorId, - vendor, -}: VendorContactsDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>협력업체 연락처</DialogTitle> - {vendor && ( - <div className="flex flex-col space-y-1 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{vendor.vendorName}</span> - {vendor.vendorCode && ( - <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> - )} - </div> - <div className="flex items-center"> - {vendor.vendorStatus && ( - <Badge variant="outline" className="mr-2"> - {vendor.vendorStatus} - </Badge> - )} - {vendor.rfqVendorStatus && ( - <Badge - variant={ - vendor.rfqVendorStatus === "INVITED" ? "default" : - vendor.rfqVendorStatus === "DECLINED" ? "destructive" : - vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline" - } - > - {vendor.rfqVendorStatus} - </Badge> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {vendorId && ( - <div className="py-4"> - <VendorContactsTable vendorId={vendorId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx deleted file mode 100644 index efc395b4..00000000 --- a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client" -// Because columns rely on React state/hooks for row actions - -import * as React from "react" -import { ColumnDef, Row } from "@tanstack/react-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" -import { VendorData } from "./vendor-contact-table" - - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns(): ColumnDef<VendorData>[] { - return [ - - // Vendor Name - { - accessorKey: "contactName", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" /> - ), - cell: ({ row }) => row.getValue("contactName"), - }, - - // Vendor Code - { - accessorKey: "contactPosition", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Position" /> - ), - cell: ({ row }) => row.getValue("contactPosition"), - }, - - // Status - { - accessorKey: "contactEmail", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Email" /> - ), - cell: ({ row }) => row.getValue("contactEmail"), - }, - - // Country - { - accessorKey: "contactPhone", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> - ), - cell: ({ row }) => row.getValue("contactPhone"), - }, - - // Created At - { - accessorKey: "createdAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"), - }, - - // Updated At - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"), - }, - ] -}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx deleted file mode 100644 index c079da02..00000000 --- a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client' - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { getColumns } from "./vendor-contact-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { Loader2 } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { getVendorContactsByVendorId } from "../../service" - -export interface VendorData { - id: number - contactName: string - contactPosition: string | null - contactEmail: string - contactPhone: string | null - isPrimary: boolean | null - createdAt: Date - updatedAt: Date -} - -interface VendorContactsTableProps { - vendorId: number -} - -export function VendorContactsTable({ vendorId }: VendorContactsTableProps) { - const { toast } = useToast() - - const columns = React.useMemo( - () => getColumns(), - [] - ) - - const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - React.useEffect(() => { - async function loadVendorContacts() { - setIsLoading(true) - try { - const result = await getVendorContactsByVendorId(vendorId) - if (result.success && result.data) { - // undefined 체크 추가 및 타입 캐스팅 - setVendorContacts(result.data as VendorData[]) - } else { - throw new Error(result.error || "Unknown error occurred") - } - } catch (error) { - console.error("협력업체 연락처 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load vendor contacts", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - loadVendorContacts() - }, [toast, vendorId]) - - const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [ - { id: "contactName", label: "Contact Name", type: "text" }, - { id: "contactPosition", label: "Posiotion", type: "text" }, - { id: "contactEmail", label: "Email", type: "text" }, - { id: "contactPhone", label: "Phone", type: "text" }, - - - ] - - // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading) { - return ( - <div className="flex h-full w-full items-center justify-center"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) - } - - // Otherwise, show the table - return ( - <ClientDataTable - data={vendorContacts} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - </ClientDataTable> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts deleted file mode 100644 index 8752f693..00000000 --- a/lib/rfqs/validations.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum,parseAsBoolean -} from "nuqs/server" -import * as z from "zod" - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Rfq, rfqs, RfqsView, VendorCbeView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; -import { Vendor, vendors } from "@/db/schema/vendors"; - -export const RfqType = { - PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY", - PURCHASE: "PURCHASE", - BUDGETARY: "BUDGETARY" -} as const; - -export type RfqType = typeof RfqType[keyof typeof RfqType]; - -// ======================= -// 1) SearchParams (목록 필터링/정렬) -// ======================= -export const searchParamsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<RfqsView>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 간단 검색 필드 - rfqCode: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - dueDate: parseAsString.withDefault(""), - - // 상태 - 여러 개일 수 있다고 가정 - status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - search: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), - -}); - -export type GetRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; - - -export const searchParamsMatchedVCache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<VendorRfqViewBase>().withDefault([ - { id: "rfqVendorUpdated", desc: true }, - ]), - - // 4) 간단 검색 필드 - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - country: parseAsString.withDefault(""), - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), - - // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" - // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 - vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs - filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), -}) -export type GetMatchedVendorsSchema = Awaited<ReturnType<typeof searchParamsMatchedVCache.parse>>; - -export const searchParamsTBECache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<VendorTbeView>().withDefault([ - { id: "tbeUpdated", desc: true }, - ]), - - // 4) 간단 검색 필드 - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - country: parseAsString.withDefault(""), - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), - - tbeResult: parseAsString.withDefault(""), - tbeNote: parseAsString.withDefault(""), - tbeUpdated: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), - - // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" - // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 - vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs - filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), -}) -export type GetTBESchema = Awaited<ReturnType<typeof searchParamsTBECache.parse>>; - -// ======================= -// 2) Create RFQ Schema -// ======================= -export const createRfqSchema = z.object({ - rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"), - description: z.string().optional(), - projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) - bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) - parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) - dueDate: z.date(), - status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), - rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]).default(RfqType.PURCHASE), - createdBy: z.number(), -}); - -export type CreateRfqSchema = z.infer<typeof createRfqSchema>; - -export const createRfqItemSchema = z.object({ - rfqId: z.number().int().min(1, "Invalid RFQ ID"), - itemCode: z.string().min(1), - itemName: z.string().optional(), - description: z.string().optional(), - quantity: z.number().min(1).optional(), - uom: z.string().optional(), - rfqType: z.string().default("PURCHASE"), // rfqType 필드 추가 - -}); - -export type CreateRfqItemSchema = z.infer<typeof createRfqItemSchema>; - -// ======================= -// 3) Update RFQ Schema -// (현재 코드엔 updateTaskSchema라고 되어 있는데, -// RFQ 업데이트이므로 'updateRfqSchema'라 명명하는 게 자연스러움) -// ======================= -export const updateRfqSchema = z.object({ - // PK id -> 실제로는 URL params로 받을 수도 있지만, - // 여기서는 body에서 받는다고 가정 - id: z.number().int().min(1, "Invalid ID"), - - // 업데이트 시 대부분 optional - rfqCode: z.string().max(50).optional(), - projectId: z.number().nullable().optional(), // null 값도 허용 - description: z.string().optional(), - parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) - dueDate: z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - rfqType: z.enum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).optional(), - status: z.union([ - z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), - z.string().refine( - (val) => ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].includes(val), - { message: "Invalid status value" } - ) - ]).optional(), - createdBy: z.number().int().min(1).optional(), -}); -export type UpdateRfqSchema = z.infer<typeof updateRfqSchema>; - -export const searchParamsRfqsForVendorsCache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (rfqs 테이블) - sort: getSortingStateParser<Rfq>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 4) 간단 검색 필드 (예: rfqCode, projectName, projectCode 등) - rfqCode: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - - // 5) 상태 배열 (rfqs.status.enumValues: "DRAFT" | "PUBLISHED" | ...) - status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), -}) - -/** - * 최종 타입 - * `Awaited<ReturnType<...parse>>` 형태로 - * Next.js 13 서버 액션이나 클라이언트에서 사용 가능 - */ -export type GetRfqsForVendorsSchema = Awaited<ReturnType<typeof searchParamsRfqsForVendorsCache.parse>> - -export const updateRfqVendorSchema = z.object({ - id: z.number().int().min(1, "Invalid ID"), // rfq_vendors.id - status: z.enum(["INVITED","ACCEPTED","DECLINED","REVIEWING", "RESPONDED"]) -}) - -export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema> - - -export const searchParamsCBECache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (VendorResponseCBEView 테이블) - // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤 - sort: getSortingStateParser<VendorResponseCBEView>().withDefault([ - { id: "totalPrice", desc: true }, - ]), - - // 4) 간단 검색 필드 - 기본 정보 - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - country: parseAsString.withDefault(""), - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), - - // CBE 관련 필드 - commercialResponseId: parseAsString.withDefault(""), - totalPrice: parseAsString.withDefault(""), - currency: parseAsString.withDefault(""), - paymentTerms: parseAsString.withDefault(""), - incoterms: parseAsString.withDefault(""), - deliveryPeriod: parseAsString.withDefault(""), - warrantyPeriod: parseAsString.withDefault(""), - validityPeriod: parseAsString.withDefault(""), - - // RFQ 관련 필드 - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), - - // 응답 상태 - responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"), - - // 5) 상태 (배열) - vendor 상태 - vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs - filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), - - // 8) 첨부파일 관련 필터 - hasAttachments: parseAsBoolean.withDefault(false), - - // 9) 날짜 범위 필터 - respondedAtRange: parseAsString.withDefault(""), - commercialUpdatedAtRange: parseAsString.withDefault(""), -}) - -export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>; - - -export const createCbeEvaluationSchema = z.object({ - paymentTerms: z.string().min(1, "지급 조건을 입력하세요"), - incoterms: z.string().min(1, "Incoterms를 입력하세요"), - deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), - notes: z.string().optional(), -}) - -// 타입 추출 -export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema>
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/add-vendor-dialog.tsx b/lib/rfqs/vendor-table/add-vendor-dialog.tsx deleted file mode 100644 index 8ec5b9f4..00000000 --- a/lib/rfqs/vendor-table/add-vendor-dialog.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client" - -import * as React from "react" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { VendorsListTable } from "./vendor-list/vendor-list-table" - -interface VendorsListTableProps { - rfqId: number // so we know which RFQ to insert into - } - - -/** - * A dialog that contains a client-side table or infinite scroll - * for "all vendors," allowing the user to select vendors and add them to the RFQ. - */ -export function AddVendorDialog({ rfqId }: VendorsListTableProps) { - const [open, setOpen] = React.useState(false) - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button size="sm"> - Add Vendor - </Button> - </DialogTrigger> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}> - <DialogHeader> - <DialogTitle>Add Vendor to RFQ</DialogTitle> - </DialogHeader> - - <VendorsListTable rfqId={rfqId}/> - - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx deleted file mode 100644 index 441fdcf1..00000000 --- a/lib/rfqs/vendor-table/comments-sheet.tsx +++ /dev/null @@ -1,318 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { createRfqCommentWithAttachments } from "../service" -import { formatDate } from "@/lib/utils" - - -export interface MatchedVendorComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: MatchedVendorComment[] - currentUserId: number - rfqId: number - vendorId: number - onCommentsUpdated?: (comments: MatchedVendorComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: null, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: MatchedVendorComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/feature-flags-provider.tsx b/lib/rfqs/vendor-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs/vendor-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"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/rfqs/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx deleted file mode 100644 index 23853e2f..00000000 --- a/lib/rfqs/vendor-table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client" - -import * as React from "react" -import { type Row } from "@tanstack/react-table" -import { Loader, Send, Trash, AlertTriangle } 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 { Alert, AlertDescription } from "@/components/ui/alert" - -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" -import { inviteVendors } from "../service" -import { RfqType } from "@/lib/rfqs/validations" - -interface DeleteTasksDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - vendors: Row<MatchedVendorRow>["original"][] - rfqId:number - rfqType: RfqType - showTrigger?: boolean - onSuccess?: () => void -} - -export function InviteVendorsDialog({ - vendors, - rfqId, - rfqType, - showTrigger = true, - onSuccess, - ...props -}: DeleteTasksDialogProps) { - const [isInvitePending, startInviteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startInviteTransition(async () => { - const { error } = await inviteVendors({ - rfqId, - vendorIds: vendors.map((vendor) => Number(vendor.id)), - rfqType - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - toast.success("Vendor invited") - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> - <DialogDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. - </DialogDescription> - </DialogHeader> - - {/* 편집 제한 경고 메시지 */} - <Alert variant="destructive" className="mt-4"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription className="font-medium"> - 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. - </AlertDescription> - </Alert> - - <DialogFooter className="gap-2 sm:space-x-0 mt-6"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onDelete} - disabled={isInvitePending} - > - {isInvitePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Invite - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently invite {" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"} from our servers. - </DrawerDescription> - </DrawerHeader> - - {/* 편집 제한 경고 메시지 (모바일용) */} - <div className="px-4"> - <Alert variant="destructive"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription className="font-medium"> - 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. - </AlertDescription> - </Alert> - </div> - - <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Delete selected rows" - variant="destructive" - onClick={onDelete} - disabled={isInvitePending} - > - {isInvitePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Invite - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx deleted file mode 100644 index bfcbe75b..00000000 --- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client" -// Because columns rely on React state/hooks for row actions - -import * as React from "react" -import { ColumnDef, Row } from "@tanstack/react-table" -import { VendorData } from "./vendor-list-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" - -export interface DataTableRowAction<TData> { - row: Row<TData> - type: "open" | "update" | "delete" -} - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>> - setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array -} - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns({ - setRowAction, - setSelectedVendorIds, // Changed parameter name -}: GetColumnsProps): ColumnDef<VendorData>[] { - return [ - // MULTIPLE SELECT COLUMN - { - id: "select", - enableSorting: false, - enableHiding: false, - size: 40, - // Add checkbox in header for select all functionality - header: ({ table }) => ( - <Checkbox - checked={ - table.getFilteredSelectedRowModel().rows.length > 0 && - table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length - } - onCheckedChange={(checked) => { - table.toggleAllRowsSelected(!!checked) - - // Update selectedVendorIds based on all rows selection - if (checked) { - const allIds = table.getFilteredRowModel().rows.map(row => row.original.id) - setSelectedVendorIds(allIds) - } else { - setSelectedVendorIds([]) - } - }} - aria-label="Select all" - /> - ), - cell: ({ row }) => { - const isSelected = row.getIsSelected() - - return ( - <Checkbox - checked={isSelected} - onCheckedChange={(checked) => { - row.toggleSelected(!!checked) - - // Update the selectedVendorIds state by adding or removing this ID - setSelectedVendorIds(prevIds => { - if (checked) { - // Add this ID if it doesn't exist - return prevIds.includes(row.original.id) - ? prevIds - : [...prevIds, row.original.id] - } else { - // Remove this ID - return prevIds.filter(id => id !== row.original.id) - } - }) - }} - aria-label="Select row" - /> - ) - }, - }, - - // Vendor Name - { - accessorKey: "vendorName", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" /> - ), - cell: ({ row }) => row.getValue("vendorName"), - }, - - // Vendor Code - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" /> - ), - cell: ({ row }) => row.getValue("vendorCode"), - }, - - // Status - { - accessorKey: "status", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Status" /> - ), - cell: ({ row }) => row.getValue("status"), - }, - - // Country - { - accessorKey: "country", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Country" /> - ), - cell: ({ row }) => row.getValue("country"), - }, - - // Email - { - accessorKey: "email", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Email" /> - ), - cell: ({ row }) => row.getValue("email"), - }, - - // Phone - { - accessorKey: "phone", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> - ), - cell: ({ row }) => row.getValue("phone"), - }, - - // Created At - { - accessorKey: "createdAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - - // Updated At - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - ] -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx deleted file mode 100644 index e34a5052..00000000 --- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client" - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { DataTableRowAction, getColumns } from "./vendor-list-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { addItemToVendors, getAllVendors } from "../../service" -import { Loader2, Plus } from "lucide-react" -import { Button } from "@/components/ui/button" -import { useToast } from "@/hooks/use-toast" - -export interface VendorData { - id: number - vendorName: string - vendorCode: string | null - taxId: string - address: string | null - country: string | null - phone: string | null - email: string | null - website: string | null - status: string - createdAt: Date - updatedAt: Date -} - -interface VendorsListTableProps { - rfqId: number -} - -export function VendorsListTable({ rfqId }: VendorsListTableProps) { - const { toast } = useToast() - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<VendorData> | null>(null) - - // Changed to array for multiple selection - const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]) - const [isSubmitting, setIsSubmitting] = React.useState(false) - - const columns = React.useMemo( - () => getColumns({ setRowAction, setSelectedVendorIds }), - [setRowAction, setSelectedVendorIds] - ) - - const [vendors, setVendors] = React.useState<VendorData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - React.useEffect(() => { - async function loadAllVendors() { - setIsLoading(true) - try { - const allVendors = await getAllVendors() - setVendors(allVendors) - } catch (error) { - console.error("협력업체 목록 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load vendors", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - loadAllVendors() - }, [toast]) - - const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [] - - async function handleAddVendors() { - if (selectedVendorIds.length === 0) return // Safety check - - setIsSubmitting(true) - try { - // Update to use the multiple vendor service - const result = await addItemToVendors(rfqId, selectedVendorIds) - - if (result.success) { - toast({ - title: "Success", - description: `Added items to ${selectedVendorIds.length} vendors`, - }) - // Reset selection after successful addition - setSelectedVendorIds([]) - } else { - toast({ - title: "Error", - description: result.error || "Failed to add items to vendors", - variant: "destructive", - }) - } - } catch (err) { - console.error("Failed to add vendors:", err) - toast({ - title: "Error", - description: "An unexpected error occurred", - variant: "destructive", - }) - } finally { - setIsSubmitting(false) - } - } - - // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading) { - return ( - <div className="flex h-full w-full items-center justify-center"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) - } - - // Otherwise, show the table - return ( - <ClientDataTable - data={vendors} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - <div className="flex items-center gap-2"> - <Button - variant="default" - size="sm" - onClick={handleAddVendors} - disabled={selectedVendorIds.length === 0 || isSubmitting} - > - {isSubmitting ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - Adding... - </> - ) : ( - <> - <Plus className="mr-2 h-4 w-4" /> - Add Vendors ({selectedVendorIds.length}) - </> - )} - </Button> - </div> - </ClientDataTable> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table-columns.tsx b/lib/rfqs/vendor-table/vendors-table-columns.tsx deleted file mode 100644 index f152cec5..00000000 --- a/lib/rfqs/vendor-table/vendors-table-columns.tsx +++ /dev/null @@ -1,276 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, MessageSquare } 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 { useRouter } from "next/navigation" - -import { vendors } from "@/db/schema/vendors" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" -import { Separator } from "@/components/ui/separator" -import { MatchedVendorRow, vendorRfqColumnsConfig } from "@/config/vendorRfbColumnsConfig" - - -type NextRouter = ReturnType<typeof useRouter>; - - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<MatchedVendorRow> | null>>; - router: NextRouter; - openCommentSheet: (rfqId: number) => void; - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<MatchedVendorRow>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<MatchedVendorRow> = { - 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, - } - - // ---------------------------------------------------------------- - // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<MatchedVendorRow>[] } - const groupMap: Record<string, ColumnDef<MatchedVendorRow>[]> = {} - - vendorRfqColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<MatchedVendorRow> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row, cell }) => { - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqVendorUpdated") { - const dateVal = cell.getValue() as Date - if (!dateVal) return null - return formatDate(dateVal) - } - - - // code etc... - return row.getValue(cfg.id) ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - const commentsColumn: ColumnDef<MatchedVendorRow> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(Number(vendor.id) ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80 - } - - const actionsColumn: ColumnDef<MatchedVendorRow> = { - id: "actions", - cell: ({ row }) => { - const rfq = row.original - const status = row.original.rfqVendorStatus - const isDisabled = !status || status === 'INVITED' || status === 'ACCEPTED' - - if (isDisabled) { - return ( - <div className="relative group"> - <Button - aria-label="Actions disabled" - variant="ghost" - className="flex size-8 p-0 opacity-50 cursor-not-allowed" - disabled - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - {/* Tooltip explaining why it's disabled */} - <div className="absolute hidden group-hover:block right-0 -bottom-8 bg-popover text-popover-foreground text-xs p-2 rounded shadow-md whitespace-nowrap z-50"> - 초대 상태에서는 사용할 수 없습니다 - </div> - </div> - ) - } - - - 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"> - {/* 기존 기능: status가 INVITED일 때만 표시 */} - {(!status || status === 'INVITED') && ( - <DropdownMenuItem onSelect={() => setRowAction({ row, type: "invite" })}> - 발행하기 - </DropdownMenuItem> - )} - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<MatchedVendorRow>[] = [] - - // 순서를 고정하고 싶다면 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, comments, actions - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - commentsColumn, - actionsColumn - ] -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx deleted file mode 100644 index 9b32cf5f..00000000 --- a/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"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, -} 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 { vendors } from "@/db/schema/vendors" -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" - -interface VendorsTableFloatingBarProps { - table: Table<MatchedVendorRow> -} - - -export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { - const rows = table.getFilteredSelectedRowModel().rows - - const [isPending, startTransition] = React.useTransition() - const [action, setAction] = React.useState< - "update-status" | "export" | "delete" - >() - 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: () => { }, - }) - - - - - - 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> - - </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")} - confirmLabel={ - action === "delete" - ? "Delete" - : action === "update-status" - ? "Update" - : "Confirm" - } - confirmVariant={ - action === "delete" ? "destructive" : "default" - } - /> - </Portal> - ) -} diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx deleted file mode 100644 index 864d0f4b..00000000 --- a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" - -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { AddVendorDialog } from "./add-vendor-dialog" -import { Button } from "@/components/ui/button" -import { useToast } from "@/hooks/use-toast" - -interface VendorsTableToolbarActionsProps { - table: Table<MatchedVendorRow> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - const { toast } = useToast() - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 선택된 모든 행 - const selectedRows = table.getFilteredSelectedRowModel().rows - - // 조건에 맞는 협력업체만 필터링 - const eligibleVendors = React.useMemo(() => { - return selectedRows - .map(row => row.original) - .filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED") - }, [selectedRows]) - - // 조건에 맞지 않는 협력업체 수 - const ineligibleCount = selectedRows.length - eligibleVendors.length - - function handleImportClick() { - fileInputRef.current?.click() - } - - function handleInviteClick() { - // 조건에 맞지 않는 협력업체가 있다면 토스트 메시지 표시 - if (ineligibleCount > 0) { - toast({ - title: "일부 협력업체만 초대됩니다", - description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`, - // variant: "warning", - }) - } - } - - // 다이얼로그 표시 여부 - 적합한 협력업체가 1개 이상 있으면 표시 - const showInviteDialog = eligibleVendors.length > 0 - - return ( - <div className="flex items-center gap-2"> - {selectedRows.length > 0 && ( - <> - {showInviteDialog ? ( - <InviteVendorsDialog - vendors={eligibleVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - onOpenChange={(open) => { - // 다이얼로그가 열릴 때만 경고 표시 - if (open && ineligibleCount > 0) { - handleInviteClick() - } - }} - /> - ) : ( - <Button - variant="default" - size="sm" - disabled={true} - title="선택된 협력업체 중 초대 가능한 협력업체가 없습니다" - > - 초대 불가 - </Button> - )} - </> - )} - - <AddVendorDialog rfqId={rfqId} /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx deleted file mode 100644 index b2e4d5ad..00000000 --- a/lib/rfqs/vendor-table/vendors-table.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 -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 { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./vendors-table-columns" -import { vendors } from "@/db/schema/vendors" -import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" -import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" -import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { CommentSheet, MatchedVendorComment } from "./comments-sheet" -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" -import { RfqType } from "@/lib/rfqs/validations" -import { toast } from "sonner" - -interface VendorsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]> - rfqId: number - rfqType: RfqType -} - -export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() - const { data: session } = useSession() // 세션 정보 가져오기 - - - - // 1) Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - // data는 MatchedVendorRow[] 형태 (getMatchedVendors에서 반환) - - console.log(data) - - // 2) Row 액션 상태 - const [rowAction, setRowAction] = React.useState< - DataTableRowAction<MatchedVendorRow> | null - >(null) - - // **router** 획득 - const router = useRouter() - - // 3) CommentSheet 에 넣을 상태 - // => "댓글"은 MatchedVendorComment[] 로 관리해야 함 - const [initialComments, setInitialComments] = React.useState< - MatchedVendorComment[] - >([]) - - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedVendorIdForComments, setSelectedVendorIdForComments] = - React.useState<number | null>(null) - - // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open - React.useEffect(() => { - if (rowAction?.type === "comments") { - openCommentSheet(rowAction.row.original.id) - } - }, [rowAction]) - - // 5) 댓글 시트 오픈 함수 - async function openCommentSheet(vendorId: number) { - // Clear previous comments - setInitialComments([]) - - // Start loading - setIsLoadingComments(true) - - // Open the sheet immediately with loading state - setSelectedVendorIdForComments(vendorId) - setCommentSheetOpen(true) - - // (a) 현재 Row의 comments 불러옴 - const comments = rowAction?.row.original.comments - - try { - if (comments && comments.length > 0) { - // (b) 각 comment마다 첨부파일 fetch - const commentWithAttachments: MatchedVendorComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - attachments, - } - }) - ) - setInitialComments(commentWithAttachments) - } - } catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } - } - - // 6) 컬럼 정의 (memo) - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet }), - [setRowAction, router] - ) - - // 7) 필터 정의 - const filterFields: DataTableFilterField<MatchedVendorRow>[] = [] - - const advancedFilterFields: DataTableAdvancedFilterField<MatchedVendorRow>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { - id: "rfqVendorStatus", - label: "RFQ Status", - type: "multi-select", - options: ["INVITED", "ACCEPTED", "REJECTED", "QUOTED"].map((s) => ({ - label: s, - value: s, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - ] - - // 8) 테이블 생성 - const { table } = useDataTable({ - data, // MatchedVendorRow[] - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, - }, - // 행의 고유 ID - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - // 세션에서 userId 추출하고 숫자로 변환 - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - - return ( - <> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 초대 다이얼로그 */} - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={rfqId} - open={rowAction?.type === "invite"} - showTrigger={false} - rfqType={rfqType} - /> - - {/* 댓글 시트 */} - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - initialComments={initialComments} - rfqId={rfqId} - vendorId={selectedVendorIdForComments ?? 0} - currentUserId={currentUserId} - isLoading={isLoadingComments} // Pass the loading state - onCommentsUpdated={(updatedComments) => { - // Row 의 comments 필드도 업데이트 - if (!rowAction?.row) return - rowAction.row.original.comments = updatedComments - }} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/tbe/service.ts b/lib/tbe/service.ts deleted file mode 100644 index e69de29b..00000000 --- a/lib/tbe/service.ts +++ /dev/null diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx deleted file mode 100644 index 35c29d39..00000000 --- a/lib/tbe/table/comments-sheet.tsx +++ /dev/null @@ -1,345 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X ,Loader2} from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Textarea, -} from "@/components/ui/textarea" - -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput -} from "@/components/ui/dropzone" - -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell -} from "@/components/ui/table" - -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -// (실제 프로젝트에 맞춰 import를 수정하세요.) -import { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" - -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 -export interface TbeComment { - id: number - commentText: string - commentedBy?: number - createdAt?: string | Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - /** 코멘트를 작성할 RFQ 정보 */ - /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ - initialComments?: TbeComment[] - - /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ - currentUserId: number - rfqId:number - vendorId:number - isLoading?: boolean // New prop - /** 댓글 저장 후 갱신용 콜백 (옵션) */ - onCommentsUpdated?: (comments: TbeComment[]) => void -} - -// 새 코멘트 작성 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional() // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - onCommentsUpdated, - isLoading = false, - ...props -}: CommentSheetProps) { - const [comments, setComments] = React.useState<TbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - - // RHF 세팅 - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [] - } - }) - - // formFieldArray 예시 (파일 목록) - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles" - }) - - // 1) 기존 코멘트 + 첨부 보여주기 - // 간단히 테이블 하나로 표현 - // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {/* 첨부파일 표시 */} - {(!c.attachments || c.attachments.length === 0) && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments && c.attachments.length > 0 && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={att.filePath} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt, "KR"): "-"}</TableCell> - <TableCell> - {c.commentedBy ?? "-"} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // 2) 새 파일 Drop - function handleDropAccepted(files: File[]) { - // 드롭된 File[]을 RHF field array에 추가 - const toAppend = files.map((f) => f) - append(toAppend) - } - - - // 3) 저장(Submit) - async function onSubmit(data: CommentFormValues) { - - if (!rfqId) return - startTransition(async () => { - try { - // 서버 액션 호출 - const res = await createRfqCommentWithAttachments({ - rfqId: rfqId, - vendorId: vendorId, // 필요시 세팅 - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, // 필요시 세팅 - files: data.newFiles - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 - const newComment: TbeComment = { - id: res.commentId, // 서버에서 반환된 commentId - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: new Date().toISOString(), - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || []) - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - // 폼 리셋 - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - {/* 기존 코멘트 목록 */} - <div className="max-h-[300px] overflow-y-auto"> - {renderExistingComments()} - </div> - - {/* 새 코멘트 작성 Form */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea - placeholder="Enter your comment..." - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Dropzone (파일 첨부) */} - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {/* 선택된 파일 목록 */} - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div key={field.id} className="flex items-center justify-between border rounded p-2"> - <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/tbe/table/feature-flags-provider.tsx b/lib/tbe/table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/tbe/table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"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/tbe/table/file-dialog.tsx b/lib/tbe/table/file-dialog.tsx deleted file mode 100644 index d22671da..00000000 --- a/lib/tbe/table/file-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client" - -import * as React from "react" -import { Download, X } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDateTime } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" - -import { - FileList, - FileListItem, - FileListIcon, - FileListInfo, - FileListName, - FileListDescription, - FileListAction, -} from "@/components/ui/file-list" -import { getTbeFilesForVendor } from "@/lib/rfqs/service" - -interface TBEFileDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - tbeId: number - vendorId: number - rfqId: number - onRefresh?: () => void -} - -export function TBEFileDialog({ - isOpen, - onOpenChange, - vendorId, - rfqId, - onRefresh, -}: TBEFileDialogProps) { - const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([]) - const [isFetchingFiles, setIsFetchingFiles] = React.useState(false) - - - // Fetch submitted files when dialog opens - React.useEffect(() => { - if (isOpen && rfqId && vendorId) { - fetchSubmittedFiles() - } - }, [isOpen, rfqId, vendorId]) - - // Fetch submitted files using the service function - const fetchSubmittedFiles = async () => { - if (!rfqId || !vendorId) return - - setIsFetchingFiles(true) - try { - const { files, error } = await getTbeFilesForVendor(rfqId, vendorId) - - if (error) { - throw new Error(error) - } - - setSubmittedFiles(files) - } catch (error) { - toast.error("Failed to load files: " + getErrorMessage(error)) - } finally { - setIsFetchingFiles(false) - } - } - - // Download submitted file - const downloadSubmittedFile = async (file: any) => { - try { - const response = await fetch(`/api/file/${file.id}/download`) - if (!response.ok) { - throw new Error("Failed to download file") - } - - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = file.fileName - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) - } catch (error) { - toast.error("Failed to download file: " + getErrorMessage(error)) - } - } - - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> - <DialogTitle>TBE 응답 파일</DialogTitle> - <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription> - </DialogHeader> - - {/* 제출된 파일 목록 */} - {isFetchingFiles ? ( - <div className="flex justify-center items-center py-8"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> - </div> - ) : submittedFiles.length > 0 ? ( - <div className="grid gap-2"> - <FileList> - {submittedFiles.map((file) => ( - <FileListItem key={file.id} className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-3 flex-1"> - <FileListIcon className="flex-shrink-0" /> - <FileListInfo className="flex-1 min-w-0"> - <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> - <FileListDescription className="text-xs text-muted-foreground"> - {file.uploadedAt ? formatDateTime(file.uploadedAt, "KR") : ""} - </FileListDescription> - </FileListInfo> - </div> - <FileListAction className="flex-shrink-0 ml-2"> - <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> - <Download className="h-4 w-4" /> - <span className="sr-only">파일 다운로드</span> - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - </div> - ) : ( - <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx deleted file mode 100644 index 59535278..00000000 --- a/lib/tbe/table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"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 { Input } from "@/components/ui/input" - -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { inviteTbeVendorsAction } from "@/lib/rfqs/service" - -interface InviteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - vendors: Row<VendorWithTbeFields>["original"][] - rfqId: number - showTrigger?: boolean - onSuccess?: () => void - hasMultipleRfqIds?: boolean -} - -export function InviteVendorsDialog({ - vendors, - rfqId, - showTrigger = true, - onSuccess, - hasMultipleRfqIds, - ...props -}: InviteVendorsDialogProps) { - const [isInvitePending, startInviteTransition] = React.useTransition() - - - // multiple 파일을 받을 state - const [files, setFiles] = React.useState<FileList | null>(null) - - // 미디어쿼리 (desktop 여부) - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onInvite() { - startInviteTransition(async () => { - // 파일이 선택되지 않았다면 에러 - if (!files || files.length === 0) { - toast.error("Please attach TBE files before inviting.") - return - } - - // FormData 생성 - const formData = new FormData() - formData.append("rfqId", String(rfqId)) - vendors.forEach((vendor) => { - formData.append("vendorIds[]", String(vendor.id)) - }) - - // multiple 파일 - for (let i = 0; i < files.length; i++) { - formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles" - } - - // 서버 액션 호출 - const { error } = await inviteTbeVendorsAction(formData) - - if (error) { - toast.error(error) - return - } - - // 성공 - props.onOpenChange?.(false) - toast.success("Vendors invited with TBE!") - onSuccess?.() - }) - } - - // 파일 선택 UI - const fileInput = ( - <div className="mb-4"> - <label className="mb-2 block font-medium">TBE Sheets</label> - <Input - type="file" - multiple - onChange={(e) => { - setFiles(e.target.files) - }} - /> - </div> - ) - if (hasMultipleRfqIds) { - toast.error("동일한 RFQ에 대해 선택해주세요"); - return; - } - // Desktop Dialog - if (isDesktop) { - return ( - - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> - <DialogDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. - </DialogDescription> - </DialogHeader> - - {/* 파일 첨부 */} - {fileInput} - - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onInvite} - // 파일이 없거나 초대 진행중이면 비활성화 - disabled={isInvitePending || !files || files.length === 0} - > - {isInvitePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Invite - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - // Mobile Drawer - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. - </DrawerDescription> - </DrawerHeader> - - {/* 파일 첨부 */} - {fileInput} - - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onInvite} - // 파일이 없거나 초대 진행중이면 비활성화 - disabled={isInvitePending || !files || files.length === 0} - > - {isInvitePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Invite - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-result-dialog.tsx b/lib/tbe/table/tbe-result-dialog.tsx deleted file mode 100644 index 59e2f49b..00000000 --- a/lib/tbe/table/tbe-result-dialog.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" - -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { getErrorMessage } from "@/lib/handle-error" -import { saveTbeResult } from "@/lib/rfqs/service" - -// Define the props for the TbeResultDialog component -interface TbeResultDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - tbe: VendorWithTbeFields | null - onRefresh?: () => void -} - -// Define TBE result options -const TBE_RESULT_OPTIONS = [ - { value: "pass", label: "Pass", badgeVariant: "default" }, - { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" }, - { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" }, -] as const - -type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"] - -export function TbeResultDialog({ - open, - onOpenChange, - tbe, - onRefresh, -}: TbeResultDialogProps) { - // Initialize state for form inputs - const [result, setResult] = React.useState<TbeResultOption | "">("") - const [note, setNote] = React.useState("") - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // Update form values when the tbe prop changes - React.useEffect(() => { - if (tbe) { - setResult((tbe.tbeResult as TbeResultOption) || "") - setNote(tbe.tbeNote || "") - } - }, [tbe]) - - // Reset form when dialog closes - React.useEffect(() => { - if (!open) { - // Small delay to avoid visual glitches when dialog is closing - const timer = setTimeout(() => { - if (!tbe) { - setResult("") - setNote("") - } - }, 300) - return () => clearTimeout(timer) - } - }, [open, tbe]) - - // Handle form submission with server action - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!tbe || !result) return - - setIsSubmitting(true) - - try { - // Call the server action to save the TBE result - const response = await saveTbeResult({ - id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table - vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table - result: result, // The selected evaluation result - notes: note, // The evaluation notes - }) - - if (!response.success) { - throw new Error(response.message || "Failed to save TBE result") - } - - // Show success toast - toast.success("TBE result saved successfully") - - // Close the dialog - onOpenChange(false) - - // Refresh the data if refresh callback is provided - if (onRefresh) { - onRefresh() - } - } catch (error) { - // Show error toast - toast.error(`Failed to save: ${getErrorMessage(error)}`) - } finally { - setIsSubmitting(false) - } - } - - // Find the selected result option - const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result) - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle className="text-xl font-semibold"> - {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"} - </DialogTitle> - {tbe && ( - <DialogDescription className="text-sm text-muted-foreground mt-1"> - <div className="flex flex-col gap-1"> - <span> - <strong>Vendor:</strong> {tbe.vendorName} - </span> - <span> - <strong>RFQ Code:</strong> {tbe.rfqCode} - </span> - {tbe.email && ( - <span> - <strong>Email:</strong> {tbe.email} - </span> - )} - </div> - </DialogDescription> - )} - </DialogHeader> - - <form onSubmit={handleSubmit} className="space-y-6 py-2"> - <div className="space-y-2"> - <Label htmlFor="tbe-result" className="text-sm font-medium"> - Evaluation Result - </Label> - <Select - value={result} - onValueChange={(value) => setResult(value as TbeResultOption)} - required - > - <SelectTrigger id="tbe-result" className="w-full"> - <SelectValue placeholder="Select a result" /> - </SelectTrigger> - <SelectContent> - {TBE_RESULT_OPTIONS.map((option) => ( - <SelectItem key={option.value} value={option.value}> - <div className="flex items-center"> - <Badge variant={option.badgeVariant as any} className="mr-2"> - {option.label} - </Badge> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label htmlFor="tbe-note" className="text-sm font-medium"> - Evaluation Note - </Label> - <Textarea - id="tbe-note" - placeholder="Enter evaluation notes..." - value={note} - onChange={(e) => setNote(e.target.value)} - className="min-h-[120px] resize-y" - /> - </div> - - <DialogFooter className="gap-2 sm:gap-0"> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - Cancel - </Button> - <Button - type="submit" - disabled={!result || isSubmitting} - className="min-w-[100px]" - > - {isSubmitting ? "Saving..." : "Save"} - </Button> - </DialogFooter> - </form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx deleted file mode 100644 index f30cd0e0..00000000 --- a/lib/tbe/table/tbe-table-columns.tsx +++ /dev/null @@ -1,344 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } 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, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { - VendorTbeColumnConfig, - vendorTbeColumnsConfig, - VendorWithTbeFields, -} from "@/config/vendorTbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>> - router: NextRouter - openCommentSheet: (vendorId: number, rfqId: number) => void - openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 - -} - - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - openFilesDialog, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithTbeFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {} - - vendorTbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithTbeFields> - const childCol: ColumnDef<VendorWithTbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - if (cfg.id === "tbeResult") { - const vendor = row.original; - const tbeResult = vendor.tbeResult; - const filesCount = vendor.files?.length ?? 0; - - // Only show button or link if there are files - if (filesCount > 0) { - // Function to handle clicking on the result - const handleTbeResultClick = () => { - setRowAction({ row, type: "tbeResult" }); - }; - - if (!tbeResult) { - // No result yet, but files exist - show "결과 입력" button - return ( - <Button - variant="outline" - size="sm" - onClick={handleTbeResultClick} - > - 결과 입력 - </Button> - ); - } else { - // Result exists - show as a hyperlink - let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; - - // Set badge variant based on result - if (tbeResult === "pass") { - badgeVariant = "default"; - } else if (tbeResult === "non-pass") { - badgeVariant = "destructive"; - } else if (tbeResult === "conditional pass") { - badgeVariant = "secondary"; - } - - return ( - <Button - variant="link" - className="p-0 h-auto underline" - onClick={handleTbeResultClick} - > - <Badge variant={badgeVariant}> - {tbeResult} - </Badge> - </Button> - ); - } - } - - // No files available, return empty cell - return null; - } - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) TBE Updated (날짜) - if (cfg.id === "tbeUpdated") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal, "KR") - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithTbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) -// 파일 칼럼 -const filesColumn: ColumnDef<VendorWithTbeFields> = { - id: "files", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response Files" /> - ), - cell: ({ row }) => { - const vendor = row.original - const filesCount = vendor.files?.length ?? 0 - - function handleClick() { - // setRowAction으로 타입만 설정하고 끝내는 방법도 가능하지만 - // 혹은 바로 openFilesDialog()를 호출해도 됨. - setRowAction({ row, type: "files" }) - // 필요한 값을 직접 호출해서 넘겨줄 수도 있음. - openFilesDialog( - vendor.tbeId ?? 0, - vendor.vendorId ?? 0, - vendor.rfqId ?? 0, - ) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"} - > - <Download className="h-4 w-4" /> - {filesCount > 0 && ( - <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"> - {filesCount} - </Badge> - )} - </Button> - ) - }, - enableSorting: false, - minSize: 80, -} - -// 댓글 칼럼 -const commentsColumn: ColumnDef<VendorWithTbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // setRowAction() 로 type 설정 - setRowAction({ row, type: "comments" }) - // 필요하면 즉시 openCommentSheet() 직접 호출 - openCommentSheet( - vendor.vendorId ?? 0, - vendor.rfqId ?? 0, - ) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - minSize: 80, -} -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - filesColumn, // Add the files column before comments - commentsColumn, - // actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx deleted file mode 100644 index cf6a041e..00000000 --- a/lib/tbe/table/tbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithTbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - // 선택된 행이 있는 경우 rfqId 확인 - const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0 - ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))] - : []; - - const hasMultipleRfqIds = uniqueRfqIds.length > 1; - - const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.technicalResponseStatus === null); - }, [table.getFilteredSelectedRowModel().rows]); - - return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - hasMultipleRfqIds={hasMultipleRfqIds} - /> - )} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - 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/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx deleted file mode 100644 index 83d601b3..00000000 --- a/lib/tbe/table/tbe-table.tsx +++ /dev/null @@ -1,243 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -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 { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./tbe-table-columns" -import { Vendor, vendors } from "@/db/schema/vendors" -import { CommentSheet, TbeComment } from "./comments-sheet" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { TBEFileDialog } from "./file-dialog" -import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs/service" -import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" -import { TbeResultDialog } from "./tbe-result-dialog" -import { toast } from "sonner" -import { VendorContactsDialog } from "./vendor-contact-dialog" - -interface VendorsTableProps { - promises: Promise<[ - Awaited<ReturnType<typeof getAllTBE>>, - ]> -} - -export function AllTbeTable({ promises }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() - const router = useRouter() - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) - - // 댓글 시트 관련 state - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 파일 다이얼로그 관련 state - const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) - const [selectedVendorIdForFiles, setSelectedVendorIdForFiles] = React.useState<number | null>(null) - const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null) - const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null) - - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - - // 테이블 리프레시용 - const handleRefresh = React.useCallback(() => { - router.refresh(); - }, [router]); - - // ----------------------------------------------------------- - // 특정 action이 설정될 때마다 실행되는 effect - // ----------------------------------------------------------- - React.useEffect(() => { - if (!rowAction) return - - if (rowAction.type === "comments") { - // rowAction가 새로 세팅되면 openCommentSheet 실행 - // row.original에 rfqId가 있다고 가정 - openCommentSheet( - rowAction.row.original.vendorId ?? 0, - rowAction.row.original.rfqId ?? 0, - ) - } else if (rowAction.type === "files") { - openFilesDialog( - rowAction.row.original.tbeId ?? 0, - rowAction.row.original.vendorId ?? 0, - rowAction.row.original.rfqId ?? 0, - ) - } - }, [rowAction]) - - // ----------------------------------------------------------- - // 댓글 시트 열기 - // ----------------------------------------------------------- - async function openCommentSheet(vendorId: number, rfqId: number) { - setInitialComments([]) - setIsLoadingComments(true) - const comments = rowAction?.row.original.comments - try { - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - setInitialComments(commentWithAttachments) - } - - setSelectedVendorIdForComments(vendorId) - setSelectedRfqIdForComments(rfqId) - setCommentSheetOpen(true) - } catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } - } - - // ----------------------------------------------------------- - // 파일 다이얼로그 열기 - // ----------------------------------------------------------- - const openFilesDialog = (tbeId: number, vendorId: number, rfqId: number) => { - setSelectedTbeIdForFiles(tbeId) - setSelectedVendorIdForFiles(vendorId) - setSelectedRfqIdForFiles(rfqId) - setIsFileDialogOpen(true) - } - - const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) - } - - - // ----------------------------------------------------------- - // 테이블 컬럼 - // ----------------------------------------------------------- - const columns = React.useMemo( - () => - getColumns({ - setRowAction, - router, - openCommentSheet, // 필요하면 직접 호출 가능 - openFilesDialog, - openVendorContactsDialog, - }), - [setRowAction, router] - ) - - // ----------------------------------------------------------- - // 필터 필드 - // ----------------------------------------------------------- - const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [ - // 예: 표준 필터 - ] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - ] - - // ----------------------------------------------------------- - // 테이블 생성 훅 - // ----------------------------------------------------------- - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["files", "comments"] }, - }, - getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 댓글 시트 */} - <CommentSheet - currentUserId={1} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - vendorId={selectedVendorIdForComments ?? 0} - rfqId={selectedRfqIdForComments ?? 0} - isLoading={isLoadingComments} - initialComments={initialComments} - /> - - {/* 파일 업로드/다운로드 다이얼로그 */} - <TBEFileDialog - isOpen={isFileDialogOpen} - onOpenChange={setIsFileDialogOpen} - tbeId={selectedTbeIdForFiles ?? 0} - vendorId={selectedVendorIdForFiles ?? 0} - rfqId={selectedRfqIdForFiles ?? 0} // ← 여기! - onRefresh={handleRefresh} - /> - - <TbeResultDialog - open={rowAction?.type === "tbeResult"} - onOpenChange={() => setRowAction(null)} - tbe={rowAction?.row.original ?? null} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - - </> - ) -}
\ No newline at end of file diff --git a/lib/tbe/table/vendor-contact-dialog.tsx b/lib/tbe/table/vendor-contact-dialog.tsx deleted file mode 100644 index 6c96d2ef..00000000 --- a/lib/tbe/table/vendor-contact-dialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { VendorContactsTable } from "@/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table" - -interface VendorContactsDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - vendorId: number | null - vendor: VendorWithTbeFields | null -} - -export function VendorContactsDialog({ - isOpen, - onOpenChange, - vendorId, - vendor, -}: VendorContactsDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>협력업체 연락처</DialogTitle> - {vendor && ( - <div className="flex flex-col space-y-1 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{vendor.vendorName}</span> - {vendor.vendorCode && ( - <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> - )} - </div> - <div className="flex items-center"> - {vendor.vendorStatus && ( - <Badge variant="outline" className="mr-2"> - {vendor.vendorStatus} - </Badge> - )} - {vendor.rfqVendorStatus && ( - <Badge - variant={ - vendor.rfqVendorStatus === "INVITED" ? "default" : - vendor.rfqVendorStatus === "DECLINED" ? "destructive" : - vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline" - } - > - {vendor.rfqVendorStatus} - </Badge> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {vendorId && ( - <div className="py-4"> - <VendorContactsTable vendorId={vendorId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts deleted file mode 100644 index 8f2954d7..00000000 --- a/lib/vendor-rfq-response/service.ts +++ /dev/null @@ -1,464 +0,0 @@ -'use server' - -import { revalidateTag, unstable_cache } from "next/cache"; -import db from "@/db/db"; -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq"; -import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq"; -import { items } from "@/db/schema/items"; -import { GetRfqsForVendorsSchema } from "../rfqs/validations"; -import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table"; -import * as z from "zod" - - - -export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) { - return unstable_cache( - async () => { - const offset = (input.page - 1) * input.perPage; - const limit = input.perPage; - - // 1) 메인 쿼리: vendorResponsesView 사용 - const { rows, total } = await db.transaction(async (tx) => { - // 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponsesView.rfqCode} ILIKE ${s}`, - sql`${vendorResponsesView.projectName} ILIKE ${s}`, - sql`${vendorResponsesView.rfqDescription} ILIKE ${s}` - ); - } - - // 협력업체 ID 필터링 - const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); - - // 정렬: 응답 시간순 - const orderBy = [desc(vendorResponsesView.respondedAt)]; - - // (A) 데이터 조회 - const data = await tx - .select() - .from(vendorResponsesView) - .where(mainWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - // (B) 전체 개수 카운트 - const [{ count }] = await tx - .select({ - count: sql<number>`count(*)`.as("count"), - }) - .from(vendorResponsesView) - .where(mainWhere); - - return { rows: data, total: Number(count) }; - }); - - // 2) rfqId 고유 목록 추출 - const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))]; - if (distinctRfqs.length === 0) { - return { data: [], pageCount: 0 }; - } - - // 3) 추가 데이터 조회 - // 3-A) RFQ 아이템 - const itemsAll = await db - .select({ - id: rfqItems.id, - rfqId: rfqItems.rfqId, - itemCode: rfqItems.itemCode, - itemName: items.itemName, - quantity: rfqItems.quantity, - description: rfqItems.description, - uom: rfqItems.uom, - }) - .from(rfqItems) - .leftJoin(items, eq(rfqItems.itemCode, items.itemCode)) - .where(inArray(rfqItems.rfqId, distinctRfqs)); - - // 3-B) RFQ 첨부 파일 (협력업체용) - const attachAll = await db - .select() - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.rfqId, distinctRfqs), - isNull(rfqAttachments.vendorId) - ) - ); - - // 3-C) RFQ 코멘트 - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.rfqId, distinctRfqs), - or( - isNull(rfqComments.vendorId), - eq(rfqComments.vendorId, vendorId) - ) - ) - ); - - - // 3-E) 협력업체 응답 상세 - 기술 - const technicalResponsesAll = await db - .select() - .from(vendorTechnicalResponses) - .where( - inArray( - vendorTechnicalResponses.responseId, - rows.map((r) => r.responseId) - ) - ); - - // 3-F) 협력업체 응답 상세 - 상업 - const commercialResponsesAll = await db - .select() - .from(vendorCommercialResponses) - .where( - inArray( - vendorCommercialResponses.responseId, - rows.map((r) => r.responseId) - ) - ); - - // 3-G) 협력업체 응답 첨부 파일 - const responseAttachmentsAll = await db - .select() - .from(vendorResponseAttachments) - .where( - inArray( - vendorResponseAttachments.responseId, - rows.map((r) => r.responseId) - ) - ); - - // 4) 데이터 그룹화 - // RFQ 아이템 그룹화 - const itemsByRfqId = new Map<number, any[]>(); - for (const it of itemsAll) { - if (!itemsByRfqId.has(it.rfqId)) { - itemsByRfqId.set(it.rfqId, []); - } - itemsByRfqId.get(it.rfqId)!.push({ - id: it.id, - itemCode: it.itemCode, - itemName: it.itemName, - quantity: it.quantity, - description: it.description, - uom: it.uom, - }); - } - - // RFQ 첨부 파일 그룹화 - const attachByRfqId = new Map<number, any[]>(); - for (const att of attachAll) { - const rid = att.rfqId!; - if (!attachByRfqId.has(rid)) { - attachByRfqId.set(rid, []); - } - attachByRfqId.get(rid)!.push({ - id: att.id, - fileName: att.fileName, - filePath: att.filePath, - vendorId: att.vendorId, - evaluationId: att.evaluationId, - }); - } - - // RFQ 코멘트 그룹화 - const commByRfqId = new Map<number, any[]>(); - for (const c of commAll) { - const rid = c.rfqId!; - if (!commByRfqId.has(rid)) { - commByRfqId.set(rid, []); - } - commByRfqId.get(rid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - }); - } - - - // 기술 응답 그룹화 - const techResponseByResponseId = new Map<number, any>(); - for (const tr of technicalResponsesAll) { - techResponseByResponseId.set(tr.responseId, { - id: tr.id, - summary: tr.summary, - notes: tr.notes, - createdAt: tr.createdAt, - updatedAt: tr.updatedAt, - }); - } - - // 상업 응답 그룹화 - const commResponseByResponseId = new Map<number, any>(); - for (const cr of commercialResponsesAll) { - commResponseByResponseId.set(cr.responseId, { - id: cr.id, - totalPrice: cr.totalPrice, - currency: cr.currency, - paymentTerms: cr.paymentTerms, - incoterms: cr.incoterms, - deliveryPeriod: cr.deliveryPeriod, - warrantyPeriod: cr.warrantyPeriod, - validityPeriod: cr.validityPeriod, - priceBreakdown: cr.priceBreakdown, - commercialNotes: cr.commercialNotes, - createdAt: cr.createdAt, - updatedAt: cr.updatedAt, - }); - } - - // 응답 첨부 파일 그룹화 - const respAttachByResponseId = new Map<number, any[]>(); - for (const ra of responseAttachmentsAll) { - const rid = ra.responseId!; - if (!respAttachByResponseId.has(rid)) { - respAttachByResponseId.set(rid, []); - } - respAttachByResponseId.get(rid)!.push({ - id: ra.id, - fileName: ra.fileName, - filePath: ra.filePath, - attachmentType: ra.attachmentType, - description: ra.description, - uploadedAt: ra.uploadedAt, - uploadedBy: ra.uploadedBy, - }); - } - - // 5) 최종 데이터 결합 - const final = rows.map((row) => { - return { - // 응답 정보 - responseId: row.responseId, - responseStatus: row.responseStatus, - respondedAt: row.respondedAt, - - // RFQ 기본 정보 - rfqId: row.rfqId, - rfqCode: row.rfqCode, - rfqDescription: row.rfqDescription, - rfqDueDate: row.rfqDueDate, - rfqStatus: row.rfqStatus, - rfqType: row.rfqType, - rfqCreatedAt: row.rfqCreatedAt, - rfqUpdatedAt: row.rfqUpdatedAt, - rfqCreatedBy: row.rfqCreatedBy, - - // 프로젝트 정보 - projectId: row.projectId, - projectCode: row.projectCode, - projectName: row.projectName, - - // 협력업체 정보 - vendorId: row.vendorId, - vendorName: row.vendorName, - vendorCode: row.vendorCode, - - // RFQ 관련 데이터 - items: itemsByRfqId.get(row.rfqId) || [], - attachments: attachByRfqId.get(row.rfqId) || [], - comments: commByRfqId.get(row.rfqId) || [], - - // 평가 정보 - tbeEvaluation: row.tbeId ? { - id: row.tbeId, - result: row.tbeResult, - } : null, - cbeEvaluation: row.cbeId ? { - id: row.cbeId, - result: row.cbeResult, - } : null, - - // 협력업체 응답 상세 - technicalResponse: techResponseByResponseId.get(row.responseId) || null, - commercialResponse: commResponseByResponseId.get(row.responseId) || null, - responseAttachments: respAttachByResponseId.get(row.responseId) || [], - - // 응답 상태 표시 - hasTechnicalResponse: row.hasTechnicalResponse, - hasCommercialResponse: row.hasCommercialResponse, - attachmentCount: row.attachmentCount || 0, - }; - }); - - const pageCount = Math.ceil(total / input.perPage); - return { data: final, pageCount }; - }, - [JSON.stringify(input), `${vendorId}`], - { - revalidate: 600, - tags: ["rfqs-vendor", `vendor-${vendorId}`], - } - )(); -} - - -export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> { - try { - if (!rfqId || isNaN(Number(rfqId))) { - return { - success: false, - error: "Invalid RFQ ID provided", - } - } - - // Query the database to get all items for the given RFQ ID - const items = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - .orderBy(rfqItems.itemCode) - - - return { - success: true, - data: items as ItemData[], - } - } catch (error) { - console.error("Error fetching RFQ items:", error) - - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items", - } - } -} - - -// Define the schema for validation -const commercialResponseSchema = z.object({ - responseId: z.number(), - vendorId: z.number(), // Added vendorId field - responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), - totalPrice: z.number().optional(), - currency: z.string().default("USD"), - paymentTerms: z.string().optional(), - incoterms: z.string().optional(), - deliveryPeriod: z.string().optional(), - warrantyPeriod: z.string().optional(), - validityPeriod: z.string().optional(), - priceBreakdown: z.string().optional(), - commercialNotes: z.string().optional(), -}) - -type CommercialResponseInput = z.infer<typeof commercialResponseSchema> - -interface ResponseType { - success: boolean - error?: string - data?: any -} - -export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> { - try { - // Validate input data - const validated = commercialResponseSchema.parse(input) - - // Check if a commercial response already exists for this responseId - const existingResponse = await db - .select() - .from(vendorCommercialResponses) - .where(eq(vendorCommercialResponses.responseId, validated.responseId)) - .limit(1) - - const now = new Date() - - if (existingResponse.length > 0) { - // Update existing record - await db - .update(vendorCommercialResponses) - .set({ - responseStatus: validated.responseStatus, - totalPrice: validated.totalPrice, - currency: validated.currency, - paymentTerms: validated.paymentTerms, - incoterms: validated.incoterms, - deliveryPeriod: validated.deliveryPeriod, - warrantyPeriod: validated.warrantyPeriod, - validityPeriod: validated.validityPeriod, - priceBreakdown: validated.priceBreakdown, - commercialNotes: validated.commercialNotes, - updatedAt: now, - }) - .where(eq(vendorCommercialResponses.responseId, validated.responseId)) - - } else { - // Return error instead of creating a new record - return { - success: false, - error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다." - } - } - - // Also update the main vendor response status if submitted - if (validated.responseStatus === "SUBMITTED") { - // Get the vendor response - const vendorResponseResult = await db - .select() - .from(vendorResponses) - .where(eq(vendorResponses.id, validated.responseId)) - .limit(1) - - if (vendorResponseResult.length > 0) { - // Update the main response status to RESPONDED - await db - .update(vendorResponses) - .set({ - responseStatus: "RESPONDED", - updatedAt: now, - }) - .where(eq(vendorResponses.id, validated.responseId)) - } - } - - // Use vendorId for revalidateTag - revalidateTag(`cbe-vendor-${validated.vendorId}`) - - return { - success: true, - data: { responseId: validated.responseId } - } - - } catch (error) { - console.error("Error updating commercial response:", error) - - if (error instanceof z.ZodError) { - return { - success: false, - error: "유효하지 않은 데이터가 제공되었습니다." - } - } - - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error occurred" - } - } -} -// Helper function to get responseId from rfqId and vendorId -export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> { - try { - const response = await db - .select() - .from(vendorCommercialResponses) - .where(eq(vendorCommercialResponses.responseId, responseId)) - .limit(1) - - return response.length > 0 ? response[0] : null - } catch (error) { - console.error("Error getting commercial response:", error) - return null - } -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts deleted file mode 100644 index 3f595ebb..00000000 --- a/lib/vendor-rfq-response/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -// RFQ 아이템 타입 -export interface RfqResponseItem { - id: number; - itemCode: string; - itemName: string; - quantity?: number; - uom?: string; - description?: string | null; -} - -// RFQ 첨부 파일 타입 -export interface RfqResponseAttachment { - id: number; - fileName: string; - filePath: string; - vendorId?: number | null; - evaluationId?: number | null; -} - -// RFQ 코멘트 타입 -export interface RfqResponseComment { - id: number; - commentText: string; - vendorId?: number | null; - evaluationId?: number | null; - createdAt: Date; - commentedBy?: number; -} - -// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화 -export interface RfqResponse { - // 응답 정보 - responseId: number; - responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED"; - respondedAt: Date; - - // RFQ 기본 정보 - rfqId: number; - rfqCode: string; - rfqDescription?: string | null; - rfqDueDate?: Date | null; - rfqStatus: string; - rfqType?: string | null; - rfqCreatedAt: Date; - rfqUpdatedAt: Date; - rfqCreatedBy?: number | null; - - // 프로젝트 정보 - projectId?: number | null; - projectCode?: string | null; - projectName?: string | null; - - // 협력업체 정보 - vendorId: number; - vendorName: string; - vendorCode?: string | null; - - // RFQ 관련 데이터 - items: RfqResponseItem[]; - attachments: RfqResponseAttachment[]; - comments: RfqResponseComment[]; -} - -// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입 -export interface RfqResponseWithId extends RfqResponse { - id: number; // rfqId와 동일하게 사용 -} - -// 페이지네이션 결과 타입 -export interface RfqResponsesResult { - data: RfqResponseWithId[]; - pageCount: number; -} - -// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지) -export type RfqWithAll = RfqResponseWithId;
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx deleted file mode 100644 index c7be0bf4..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx +++ /dev/null @@ -1,365 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" -import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" -import { toast } from "sonner" - - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void - loadingVendors: Record<string, boolean> - openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void - // New prop for handling commercial response - openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openVendorContactsDialog, - openCommercialResponseSheet -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorResponseCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithCbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - maxSize: 120, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - - if (cfg.id === "rfqCode") { - const rfq = row.original; - const rfqId = rfq.rfqId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (rfqId) { - openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - // Commercial Response Status에 배지 적용 - if (cfg.id === "commercialResponseStatus") { - const status = val as string; - - if (!status) return <span className="text-muted-foreground">-</span>; - - let variant: "default" | "outline" | "secondary" | "destructive" = "outline"; - - switch (status) { - case "SUBMITTED": - variant = "default"; // Green - break; - case "IN_PROGRESS": - variant = "secondary"; // Orange/Yellow - break; - case "PENDING": - variant = "outline"; // Gray - break; - default: - variant = "outline"; - } - - return ( - <Badge variant={variant} className="capitalize"> - {status.toLowerCase().replace("_", " ")} - </Badge> - ); - } - - // 예) TBE Updated (날짜) - if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 3) Respond 컬럼 (새로 추가) - // ---------------------------------------------------------------- - const respondColumn: ColumnDef<VendorWithCbeFields> = { - id: "respond", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response" /> - ), - cell: ({ row }) => { - const vendor = row.original - const responseId = vendor.responseId - - if (!responseId) { - return <div className="text-center text-muted-foreground">-</div> - } - - const handleClick = () => { - openCommercialResponseSheet(responseId, vendor) - } - - // Status에 따라 버튼 variant 변경 - let variant: "default" | "outline" | "ghost" | "secondary" = "default" - let buttonText = "Respond" - - if (vendor.commercialResponseStatus === "SUBMITTED") { - variant = "outline" - buttonText = "Update" - } else if (vendor.commercialResponseStatus === "IN_PROGRESS") { - variant = "secondary" - buttonText = "Continue" - } - - return ( - <Button - variant={variant} - size="sm" - // className="w-20" - onClick={handleClick} - > - <FileEdit className="h-3.5 w-3.5 mr-1" /> - {buttonText} - </Button> - ) - }, - enableSorting: false, - maxSize: 200, - minSize: 115, - } - - // ---------------------------------------------------------------- - // 4) Comments 컬럼 - // ---------------------------------------------------------------- - const commentsColumn: ColumnDef<VendorWithCbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.responseId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80 - } - - // ---------------------------------------------------------------- - // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용) - // ---------------------------------------------------------------- - const downloadColumn: ColumnDef<VendorWithCbeFields> = { - id: "attachDownload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Attach Download" /> - ), - cell: ({ row }) => { - const vendor = row.original - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const files = vendor.files?.length || 0 - - if (!vendorId || !rfqId) { - return <div className="text-center text-muted-foreground">-</div> - } - - // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용) - const rowKey = `${vendorId}_${rfqId}` - const isRowLoading = loadingVendors[rowKey] === true - - // 템플릿 파일이 없으면 다운로드 버튼 비활성화 - const isDisabled = files <= 0 || isRowLoading - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={ - isDisabled - ? undefined - : () => handleDownloadCbeFiles(vendorId, rfqId) - } - aria-label={ - isRowLoading - ? "다운로드 중..." - : files > 0 - ? `CBE 첨부 다운로드 (${files}개)` - : "다운로드할 파일 없음" - } - disabled={isDisabled} - > - {isRowLoading ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - )} - - {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */} - {!isRowLoading && files > 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" - > - {files} - </Badge> - )} - - <span className="sr-only"> - {isRowLoading - ? "다운로드 중..." - : files > 0 - ? `CBE 첨부 다운로드 (${files}개)` - : "다운로드할 파일 없음"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 (respondColumn 추가) - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - respondColumn, // 응답 컬럼 추가 - downloadColumn, - commentsColumn, - ] -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx deleted file mode 100644 index 8477f550..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx +++ /dev/null @@ -1,272 +0,0 @@ -"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 { getColumns } from "./cbe-table-columns" -import { - fetchRfqAttachmentsbyCommentId, - getCBEbyVendorId, - getFileFromRfqAttachmentsbyid, - fetchCbeFiles -} from "../../rfqs/service" -import { useSession } from "next-auth/react" -import { CbeComment, CommentSheet } from "./comments-sheet" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { toast } from "sonner" -import { RfqDeailDialog } from "./rfq-detail-dialog" -import { CommercialResponseSheet } from "./respond-cbe-sheet" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getCBEbyVendorId>>, - ] - > -} - -export function CbeVendorTable({ promises }: VendorsTableProps) { - const { data: session } = useSession() - const userVendorId = session?.user?.companyId - const userId = Number(session?.user?.id) - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - - // 개별 협력업체별 로딩 상태를 관리하는 맵 - const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({}) - - const router = useRouter() - - // 코멘트 관련 상태 - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 상업 응답 관련 상태 - const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false) - const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null) - const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null) - - // RFQ 상세 관련 상태 - const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false) - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null) - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - async function openCommentSheet(responseId: number) { - setInitialComments([]) - - const comments = rowAction?.row.original.comments - const rfqId = rowAction?.row.original.rfqId - - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: userId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - if(rfqId) { - setSelectedRfqIdForComments(rfqId) - } - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - } - - // 상업 응답 시트 열기 - function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) { - setSelectedResponseId(responseId) - setSelectedRfq(rfq) - setCommercialResponseSheetOpen(true) - } - - // RFQ 상세 대화상자 열기 - function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) { - setSelectedRfqId(rfqId) - setSelectedRfqDetail(rfq) - setRfqDetailDialogOpen(true) - } - - const handleDownloadCbeFiles = React.useCallback( - async (vendorId: number, rfqId: number) => { - // 고유 키 생성: vendorId_rfqId - const rowKey = `${vendorId}_${rfqId}` - - // 해당 협력업체의 로딩 상태만 true로 설정 - setLoadingVendors(prev => ({ - ...prev, - [rowKey]: true - })) - - try { - const { files, error } = await fetchCbeFiles(vendorId, rfqId); - if (error) { - toast.error(error); - return; - } - if (files.length === 0) { - toast.warning("다운로드할 CBE 파일이 없습니다"); - return; - } - // 순차적으로 파일 다운로드 - for (const file of files) { - await downloadFile(file.id); - } - toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`); - } catch (error) { - toast.error("CBE 파일을 다운로드하는 데 실패했습니다"); - console.error(error); - } finally { - // 해당 협력업체의 로딩 상태만 false로 되돌림 - setLoadingVendors(prev => ({ - ...prev, - [rowKey]: false - })) - } - }, - [] - ); - - const downloadFile = React.useCallback(async (fileId: number) => { - try { - const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); - if (error || !file) { - throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); - } - - const link = document.createElement("a"); - link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - return true; - } catch (error) { - console.error(error); - return false; - } - }, []); - - // 응답 성공 후 데이터 갱신 - const handleResponseSuccess = React.useCallback(() => { - // 필요한 경우 데이터 다시 가져오기 - router.refresh() - }, [router]); - - // getColumns() 호출 시 필요한 핸들러들 주입 - const columns = React.useMemo( - () => getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openVendorContactsDialog: openRfqDetailDialog, - openCommercialResponseSheet, - }), - [ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openRfqDetailDialog, - openCommercialResponseSheet - ] - ); - - // 필터 필드 정의 - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정 - }, - getRowId: (originalRow) => String(originalRow.responseId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - - {/* 코멘트 시트 */} - {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={selectedRfqIdForComments} - initialComments={initialComments} - vendorId={userVendorId || 0} - currentUserId={userId || 0} - cbeId={selectedCbeId} - /> - )} - - {/* 상업 응답 시트 */} - {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && ( - <CommercialResponseSheet - open={commercialResponseSheetOpen} - onOpenChange={setCommercialResponseSheetOpen} - responseId={selectedResponseId} - rfq={selectedRfq} - onSuccess={handleResponseSuccess} - /> - )} - - {/* RFQ 상세 대화상자 */} - {rfqDetailDialogOpen && selectedRfqId !== null && ( - <RfqDeailDialog - isOpen={rfqDetailDialogOpen} - onOpenChange={setRfqDetailDialogOpen} - rfqId={selectedRfqId} - rfq={selectedRfqDetail} - /> - )} - </> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx deleted file mode 100644 index 40d38145..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx +++ /dev/null @@ -1,323 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" - - -export interface CbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: CbeComment[] - currentUserId: number - rfqId: number - tbeId?: number - cbeId?: number - vendorId: number - onCommentsUpdated?: (comments: CbeComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - tbeId, - cbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - - const [comments, setComments] = React.useState<CbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: cbeId, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: CbeComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx deleted file mode 100644 index 8cc4fa6f..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" - -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 { Input } from "@/components/ui/input" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Textarea } from "@/components/ui/textarea" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service" - -// Define schema for form validation (client-side) -const commercialResponseFormSchema = z.object({ - responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), - totalPrice: z.coerce.number().optional(), - currency: z.string().default("USD"), - paymentTerms: z.string().optional(), - incoterms: z.string().optional(), - deliveryPeriod: z.string().optional(), - warrantyPeriod: z.string().optional(), - validityPeriod: z.string().optional(), - priceBreakdown: z.string().optional(), - commercialNotes: z.string().optional(), -}) - -type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema> - -interface CommercialResponseSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - rfq: VendorWithCbeFields | null - responseId: number | null // This is the vendor_responses.id - onSuccess?: () => void -} - -export function CommercialResponseSheet({ - rfq, - responseId, - onSuccess, - ...props -}: CommercialResponseSheetProps) { - const [isSubmitting, startSubmitTransition] = React.useTransition() - const [isLoading, setIsLoading] = React.useState(true) - - const form = useForm<CommercialResponseFormInput>({ - resolver: zodResolver(commercialResponseFormSchema), - defaultValues: { - responseStatus: "PENDING", - totalPrice: undefined, - currency: "USD", - paymentTerms: "", - incoterms: "", - deliveryPeriod: "", - warrantyPeriod: "", - validityPeriod: "", - priceBreakdown: "", - commercialNotes: "", - }, - }) - - // Load existing commercial response data when sheet opens - React.useEffect(() => { - async function loadCommercialResponse() { - if (!responseId) return - - setIsLoading(true) - try { - // Use the helper function to get existing data - const existingResponse = await getCommercialResponseByResponseId(responseId) - - if (existingResponse) { - // If we found existing data, populate the form - form.reset({ - responseStatus: existingResponse.responseStatus, - totalPrice: existingResponse.totalPrice, - currency: existingResponse.currency || "USD", - paymentTerms: existingResponse.paymentTerms || "", - incoterms: existingResponse.incoterms || "", - deliveryPeriod: existingResponse.deliveryPeriod || "", - warrantyPeriod: existingResponse.warrantyPeriod || "", - validityPeriod: existingResponse.validityPeriod || "", - priceBreakdown: existingResponse.priceBreakdown || "", - commercialNotes: existingResponse.commercialNotes || "", - }) - } else if (rfq) { - // If no existing data but we have rfq data with some values already - form.reset({ - responseStatus: rfq.commercialResponseStatus as any || "PENDING", - totalPrice: rfq.totalPrice || undefined, - currency: rfq.currency || "USD", - paymentTerms: rfq.paymentTerms || "", - incoterms: rfq.incoterms || "", - deliveryPeriod: rfq.deliveryPeriod || "", - warrantyPeriod: rfq.warrantyPeriod || "", - validityPeriod: rfq.validityPeriod || "", - priceBreakdown: "", - commercialNotes: "", - }) - } - } catch (error) { - console.error("Failed to load commercial response data:", error) - toast.error("상업 응답 데이터를 불러오는데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadCommercialResponse() - }, [responseId, rfq, form]) - - function onSubmit(formData: CommercialResponseFormInput) { - if (!responseId) { - toast.error("응답 ID를 찾을 수 없습니다") - return - } - - if (!rfq?.vendorId) { - toast.error("협력업체 ID를 찾을 수 없습니다") - return - } - - startSubmitTransition(async () => { - try { - // Pass both responseId and vendorId to the server action - const result = await updateCommercialResponse({ - responseId, - vendorId: rfq.vendorId, // Include vendorId for revalidateTag - ...formData, - }) - - if (!result.success) { - toast.error(result.error || "응답 제출 중 오류가 발생했습니다") - return - } - - toast.success("Commercial response successfully submitted") - props.onOpenChange?.(false) - - if (onSuccess) { - onSuccess() - } - } catch (error) { - console.error("Error submitting response:", error) - toast.error("응답 제출 중 오류가 발생했습니다") - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Commercial Response</SheetTitle> - <SheetDescription> - {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>} - <div className="mt-1">Please provide your commercial response for this RFQ</div> - </SheetDescription> - </SheetHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-8"> - <Loader className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) : ( - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2" - > - <FormField - control={form.control} - name="responseStatus" - render={({ field }) => ( - <FormItem> - <FormLabel>Response Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select response status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="PENDING">Pending</SelectItem> - <SelectItem value="IN_PROGRESS">In Progress</SelectItem> - <SelectItem value="SUBMITTED">Submitted</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="totalPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>Total Price</FormLabel> - <FormControl> - <Input - type="number" - placeholder="0.00" - {...field} - value={field.value || ''} - onChange={(e) => { - const value = e.target.value === '' ? undefined : parseFloat(e.target.value); - field.onChange(value); - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>Currency</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select currency" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - <SelectItem value="GBP">GBP</SelectItem> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="JPY">JPY</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* Other form fields remain the same */} - <FormField - control={form.control} - name="paymentTerms" - render={({ field }) => ( - <FormItem> - <FormLabel>Payment Terms</FormLabel> - <FormControl> - <Input placeholder="e.g. Net 30" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incoterms" - render={({ field }) => ( - <FormItem> - <FormLabel>Incoterms</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value || ''} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select incoterms" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="EXW">EXW (Ex Works)</SelectItem> - <SelectItem value="FCA">FCA (Free Carrier)</SelectItem> - <SelectItem value="FOB">FOB (Free On Board)</SelectItem> - <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem> - <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem> - <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="deliveryPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Delivery Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 4-6 weeks" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="warrantyPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Warranty Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 12 months" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="validityPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Validity Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 30 days" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="priceBreakdown" - render={({ field }) => ( - <FormItem> - <FormLabel>Price Breakdown (Optional)</FormLabel> - <FormControl> - <Textarea - placeholder="Enter price breakdown details here" - className="min-h-[100px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="commercialNotes" - render={({ field }) => ( - <FormItem> - <FormLabel>Additional Notes (Optional)</FormLabel> - <FormControl> - <Textarea - placeholder="Any additional comments or notes" - className="min-h-[100px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <SheetFooter className="gap-2 pt-4 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isSubmitting} type="submit"> - {isSubmitting && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Submit Response - </Button> - </SheetFooter> - </form> - </Form> - )} - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx deleted file mode 100644 index e9328641..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { RfqItemsTable } from "./rfq-items-table/rfq-items-table" -import { formatDateTime } from "@/lib/utils" -import { CalendarClock } from "lucide-react" - -interface RfqDeailDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - rfqId: number | null - rfq: VendorWithCbeFields | null -} - -export function RfqDeailDialog({ - isOpen, - onOpenChange, - rfqId, - rfq, -}: RfqDeailDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle> - {rfq && ( - <div className="flex flex-col space-y-3 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> - </div> - - {/* 정보를 두 행으로 나누어 표시 */} - <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> - {/* 첫 번째 행: 상태 배지 */} - <div className="flex items-center flex-wrap gap-2"> - {rfq.rfqType && ( - <Badge - variant={ - rfq.rfqType === "BUDGETARY" ? "default" : - rfq.rfqType === "PURCHASE" ? "destructive" : - rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline" - } - > - RFQ 유형: {rfq.rfqType} - </Badge> - )} - - - {rfq.vendorStatus && ( - <Badge variant="outline"> - RFQ 상태: {rfq.rfqStatus} - </Badge> - )} - - </div> - - {/* 두 번째 행: Due Date를 강조 표시 */} - {rfq.rfqDueDate && ( - <div className="flex items-center"> - <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> - <CalendarClock className="h-3.5 w-3.5" /> - <span className="font-semibold">Due Date:</span> - <span>{formatDateTime(rfq.rfqDueDate)}</span> - </Badge> - </div> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {rfqId && ( - <div className="py-4"> - <RfqItemsTable rfqId={rfqId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx deleted file mode 100644 index bf4ae709..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" -// Because columns rely on React state/hooks for row actions - -import * as React from "react" -import { ColumnDef, Row } from "@tanstack/react-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" -import { ItemData } from "./rfq-items-table" - - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns(): ColumnDef<ItemData>[] { - return [ - - // Vendor Name - { - accessorKey: "itemCode", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Item Code" /> - ), - cell: ({ row }) => row.getValue("itemCode"), - }, - - // Vendor Code - { - accessorKey: "description", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Description" /> - ), - cell: ({ row }) => row.getValue("description"), - }, - - // Status - { - accessorKey: "quantity", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Quantity" /> - ), - cell: ({ row }) => row.getValue("quantity"), - }, - - - // Created At - { - accessorKey: "createdAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - - // Updated At - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - ] -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx deleted file mode 100644 index c5c67e54..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client' - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { getColumns } from "./rfq-items-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { Loader2 } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { getItemsByRfqId } from "../../service" - -export interface ItemData { - id: number - itemCode: string - description: string | null - quantity: number - uom: string | null - createdAt: Date - updatedAt: Date -} - -interface RFQItemsTableProps { - rfqId: number -} - -export function RfqItemsTable({ rfqId }: RFQItemsTableProps) { - const { toast } = useToast() - - const columns = React.useMemo( - () => getColumns(), - [] - ) - - const [rfqItems, setRfqItems] = React.useState<ItemData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - React.useEffect(() => { - async function loadItems() { - setIsLoading(true) - try { - // Use the correct function name (camelCase) - const result = await getItemsByRfqId(rfqId) - if (result.success && result.data) { - setRfqItems(result.data as ItemData[]) - } else { - throw new Error(result.error || "Unknown error occurred") - } - } catch (error) { - console.error("RFQ 아이템 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load RFQ items", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - loadItems() - }, [toast, rfqId]) - - const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [ - { id: "itemCode", label: "Item Code", type: "text" }, - { id: "description", label: "Description", type: "text" }, - { id: "quantity", label: "Quantity", type: "number" }, - { id: "uom", label: "UoM", type: "text" }, - ] - - // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading) { - return ( - <div className="flex h-full w-full items-center justify-center"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) - } - - // Otherwise, show the table - return ( - <ClientDataTable - data={rfqItems} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - </ClientDataTable> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx deleted file mode 100644 index 504fc177..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { - Table, - TableBody, - TableCaption, - TableCell, - TableFooter, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { RfqWithAll } from "../types" -/** - * 아이템 구조 예시 - * - API 응답에서 quantity가 "string" 형태이므로, - * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다. - */ -export interface RfqItem { - id: number - itemCode: string - itemName: string - quantity: string - description: string - uom: string -} - -/** - * 첨부파일 구조 예시 - */ -export interface RfqAttachment { - id: number - fileName: string - filePath: string - vendorId: number | null - evaluationId: number | null -} - - -/** - * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용) - */ -export interface DefaultItem { - id?: number - itemCode: string - description?: string | null - quantity?: number | null - uom?: string | null -} - -/** - * RfqsItemsDialog 컴포넌트 Prop 타입 - */ -export interface RfqsItemsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - rfq: RfqWithAll - defaultItems?: DefaultItem[] -} - -export function RfqsItemsDialog({ - open, - onOpenChange, - rfq, -}: RfqsItemsDialogProps) { - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-none w-[1200px]"> - <DialogHeader> - <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle> - <DialogDescription> - Below is the list of items for this RFQ. - </DialogDescription> - </DialogHeader> - - <div className="overflow-x-auto w-full space-y-4"> - {rfq && rfq.items.length === 0 && ( - <p className="text-sm text-muted-foreground">No items found.</p> - )} - {rfq && rfq.items.length > 0 && ( - <Table> - {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */} - <TableHeader> - <TableRow> - <TableHead>Item Code</TableHead> - <TableHead>Item Code</TableHead> - <TableHead>Description</TableHead> - <TableHead>Qty</TableHead> - <TableHead>UoM</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {rfq.items.map((it, idx) => ( - <TableRow key={it.id ?? idx}> - <TableCell>{it.itemCode || "No Code"}</TableCell> - <TableCell>{it.itemName || "No Name"}</TableCell> - <TableCell>{it.description || "-"}</TableCell> - <TableCell>{it.quantity ?? 1}</TableCell> - <TableCell>{it.uom ?? "each"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - )} - </div> - - <DialogFooter className="mt-4"> - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> - Close - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx deleted file mode 100644 index 6c51c12c..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client" - -import * as React from "react" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { Download } from "lucide-react" -import { formatDate } from "@/lib/utils" - -// 첨부파일 구조 -interface RfqAttachment { - id: number - fileName: string - filePath: string - createdAt?: Date // or Date - vendorId?: number | null - size?: number -} - -// 컴포넌트 Prop -interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - rfqId: number - attachments?: RfqAttachment[] -} - -/** - * RfqAttachmentsSheet: - * - 단순히 첨부파일 리스트 + 다운로드 버튼만 - */ -export function RfqAttachmentsSheet({ - rfqId, - attachments = [], - ...props -}: RfqAttachmentsSheetProps) { - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> - <SheetHeader> - <SheetTitle>Attachments</SheetTitle> - <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription> - </SheetHeader> - - <div className="space-y-2"> - {/* 첨부파일이 없을 경우 */} - {attachments.length === 0 && ( - <p className="text-sm text-muted-foreground"> - No attachments - </p> - )} - - {/* 첨부파일 목록 */} - {attachments.map((att) => ( - <div - key={att.id} - className="flex items-center justify-between rounded border p-2" - > - <div className="flex flex-col text-sm"> - <span className="font-medium">{att.fileName}</span> - {att.size && ( - <span className="text-xs text-muted-foreground"> - {Math.round(att.size / 1024)} KB - </span> - )} - {att.createdAt && ( - <span className="text-xs text-muted-foreground"> - Created at {formatDate(att.createdAt)} - </span> - )} - </div> - {/* 파일 다운로드 버튼 */} - {att.filePath && ( - <a - href={att.filePath} - download - target="_blank" - rel="noreferrer" - className="text-sm" - > - <Button variant="ghost" size="icon" type="button"> - <Download className="h-4 w-4" /> - </Button> - </a> - )} - </div> - ))} - </div> - - <SheetFooter className="gap-2 pt-2"> - {/* 닫기 버튼 */} - <SheetClose asChild> - <Button type="button" variant="outline"> - Close - </Button> - </SheetClose> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx deleted file mode 100644 index 5bb8a16a..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx +++ /dev/null @@ -1,320 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" - - -export interface MatchedVendorComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: MatchedVendorComment[] - currentUserId: number - rfqId: number - vendorId: number - onCommentsUpdated?: (comments: MatchedVendorComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - console.log(initialComments) - - const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: null, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: MatchedVendorComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"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-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx deleted file mode 100644 index 70b91176..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx +++ /dev/null @@ -1,435 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { ColumnDef } from "@tanstack/react-table" -import { - Ellipsis, - MessageSquare, - Package, - Paperclip, -} from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { Badge } from "@/components/ui/badge" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate, formatDateTime } from "@/lib/utils" -import { modifyRfqVendor } from "../../rfqs/service" -import type { RfqWithAll } from "../types" -import type { DataTableRowAction } from "@/types/table" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<RfqWithAll> | null> - > - router: NextRouter - openAttachmentsSheet: (rfqId: number) => void - openCommentSheet: (rfqId: number) => void -} - -/** - * tanstack table 컬럼 정의 (Nested Header) - */ -export function getColumns({ - setRowAction, - router, - openAttachmentsSheet, - openCommentSheet, -}: GetColumnsProps): ColumnDef<RfqWithAll>[] { - // 1) 체크박스(Select) 컬럼 - const selectColumn: ColumnDef<RfqWithAll> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // 2) Actions (Dropdown) - const actionsColumn: ColumnDef<RfqWithAll> = { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon"> - <Ellipsis className="h-4 w-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuSub> - <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.responseStatus} - onValueChange={(value) => { - startUpdateTransition(async () => { - let newStatus: - | "ACCEPTED" - | "DECLINED" - | "REVIEWING" - - switch (value) { - case "ACCEPTED": - newStatus = "ACCEPTED" - break - case "DECLINED": - newStatus = "DECLINED" - break - default: - newStatus = "REVIEWING" - } - - await toast.promise( - modifyRfqVendor({ - id: row.original.responseId, - status: newStatus, - }), - { - loading: "Updating response status...", - success: "Response status updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {[ - { value: "ACCEPTED", label: "Accept RFQ" }, - { value: "DECLINED", label: "Decline RFQ" }, - ].map((rep) => ( - <DropdownMenuRadioItem - key={rep.value} - value={rep.value} - className="capitalize" - disabled={isUpdatePending} - > - {rep.label} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - {/* <DropdownMenuItem - onClick={() => { - router.push(`/vendor/rfqs/${row.original.rfqId}`) - }} - > - View Details - </DropdownMenuItem> */} - {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}> - View Attachments - </DropdownMenuItem> - <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}> - View Comments - </DropdownMenuItem> - <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}> - View Items - </DropdownMenuItem> */} - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // 3) RFQ Code 컬럼 - const rfqCodeColumn: ColumnDef<RfqWithAll> = { - id: "rfqCode", - accessorKey: "rfqCode", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> - ), - // cell: ({ row }) => { - // return ( - // <Button - // variant="link" - // className="p-0 h-auto font-medium" - // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)} - // > - // {row.original.rfqCode} - // </Button> - // ) - // }, - cell: ({ row }) => row.original.rfqCode || "-", - size: 150, - } - - const rfqTypeColumn: ColumnDef<RfqWithAll> = { - id: "rfqType", - accessorKey: "rfqType", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ Type" /> - ), - cell: ({ row }) => row.original.rfqType || "-", - size: 150, - } - - - // 4) 응답 상태 컬럼 - const responseStatusColumn: ColumnDef<RfqWithAll> = { - id: "responseStatus", - accessorKey: "responseStatus", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response Status" /> - ), - cell: ({ row }) => { - const status = row.original.responseStatus; - let variant: "default" | "secondary" | "destructive" | "outline"; - - switch (status) { - case "REVIEWING": - variant = "default"; - break; - case "ACCEPTED": - variant = "secondary"; - break; - case "DECLINED": - variant = "destructive"; - break; - default: - variant = "outline"; - } - - return <Badge variant={variant}>{status}</Badge>; - }, - size: 150, - } - - // 5) 프로젝트 이름 컬럼 - const projectNameColumn: ColumnDef<RfqWithAll> = { - id: "projectName", - accessorKey: "projectName", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Project" /> - ), - cell: ({ row }) => row.original.projectName || "-", - size: 150, - } - - // 6) RFQ Description 컬럼 - const descriptionColumn: ColumnDef<RfqWithAll> = { - id: "rfqDescription", - accessorKey: "rfqDescription", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Description" /> - ), - cell: ({ row }) => row.original.rfqDescription || "-", - size: 200, - } - - // 7) Due Date 컬럼 - const dueDateColumn: ColumnDef<RfqWithAll> = { - id: "rfqDueDate", - accessorKey: "rfqDueDate", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Due Date" /> - ), - cell: ({ row }) => { - const date = row.original.rfqDueDate; - return date ? formatDate(date) : "-"; - }, - size: 120, - } - - // 8) Last Updated 컬럼 - const updatedAtColumn: ColumnDef<RfqWithAll> = { - id: "respondedAt", - accessorKey: "respondedAt", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Last Updated" /> - ), - cell: ({ row }) => { - const date = row.original.respondedAt; - return date ? formatDateTime(date) : "-"; - }, - size: 150, - } - - // 9) Items 컬럼 - 뱃지로 아이템 개수 표시 - const itemsColumn: ColumnDef<RfqWithAll> = { - id: "items", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Items" /> - ), - cell: ({ row }) => { - const rfq = row.original - const count = rfq.items?.length ?? 0 - - function handleClick() { - setRowAction({ row, type: "items" }) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={count > 0 ? `View ${count} items` : "No items"} - > - <Package 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` : "No Items"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시 - const attachmentsColumn: ColumnDef<RfqWithAll> = { - id: "attachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Attachments" /> - ), - cell: ({ row }) => { - const attachCount = row.original.attachments?.length ?? 0 - - function handleClick(e: React.MouseEvent<HTMLButtonElement>) { - e.preventDefault() - openAttachmentsSheet(row.original.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - attachCount > 0 ? `View ${attachCount} files` : "No files" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {attachCount > 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" - > - {attachCount} - </Badge> - )} - <span className="sr-only"> - {attachCount > 0 ? `${attachCount} Files` : "No Files"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시 - const commentsColumn: ColumnDef<RfqWithAll> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const commCount = row.original.comments?.length ?? 0 - - function handleClick() { - setRowAction({ row, type: "comments" }) - openCommentSheet(row.original.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외 - return [ - selectColumn, - rfqCodeColumn, - rfqTypeColumn, - responseStatusColumn, - projectNameColumn, - descriptionColumn, - dueDateColumn, - itemsColumn, - attachmentsColumn, - commentsColumn, - updatedAtColumn, - actionsColumn, - ] -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx deleted file mode 100644 index 1bae99ef..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { RfqWithAll } from "../types" - - -interface RfqsTableToolbarActionsProps { - table: Table<RfqWithAll> -} - -export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) { - - - return ( - <div className="flex items-center gap-2"> - - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - 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-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx deleted file mode 100644 index 6aab7fef..00000000 --- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx +++ /dev/null @@ -1,280 +0,0 @@ -"use client" - -import * as React from "react" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" -import { useRouter } from "next/navigation" - -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 "./rfqs-table-columns" -import { RfqWithAll } from "../types" - -import { - fetchRfqAttachments, - fetchRfqAttachmentsbyCommentId, -} from "../../rfqs/service" - -import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions" -import { RfqsItemsDialog } from "./ItemsDialog" -import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" -import { CommentSheet } from "./comments-sheet" -import { getRfqResponsesForVendor } from "../service" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 - -interface RfqsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]> -} - -// 코멘트+첨부파일 구조 예시 -export interface RfqCommentWithAttachments { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -export interface ExistingAttachment { - id: number - fileName: string - filePath: string - createdAt?: Date - vendorId?: number | null - size?: number -} - -export interface ExistingItem { - id?: number - itemCode: string - description: string | null - quantity: number | null - uom: string | null -} - -export function RfqsVendorTable({ promises }: RfqsTableProps) { - const { featureFlags } = useFeatureFlags() - const { data: session } = useSession() // 세션 정보 가져오기 - - // 1) 테이블 데이터( RFQs ) - const [{ data: responseData, pageCount }] = React.use(promises) - - // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가) - const data: RfqWithAll[] = React.useMemo(() => { - return responseData.map(item => ({ - ...item, - id: item.rfqId, // id 필드를 rfqId와 동일하게 설정 - })); - }, [responseData]); - - const router = useRouter() - - // 2) 첨부파일 시트 + 관련 상태 - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) - const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) - - // 3) 코멘트 시트 + 관련 상태 - const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 4) rowAction으로 다양한 모달/시트 열기 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null) - - // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리 - React.useEffect(() => { - if (rowAction?.type === "comments" && rowAction?.row.original) { - openCommentSheet(rowAction.row.original.id) - } - }, [rowAction]) - - /** - * (A) 코멘트 시트를 열기 전에, - * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회. - */ - const openCommentSheet = React.useCallback(async (rfqId: number) => { - setInitialComments([]) - - // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기 - const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || [] - - if (comments && comments.length > 0) { - const commentWithAttachments = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - commentedBy: c.commentedBy || 1, - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - setSelectedRfqIdForComments(rfqId) - setCommentSheetOpen(true) - }, [data]) // data만 의존성으로 추가 - - /** - * (B) 첨부파일 시트 열기 - */ - const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { - const list = await fetchRfqAttachments(rfqId) - setAttachDefault(list) - setSelectedRfqIdForAttachments(rfqId) - setAttachmentsOpen(true) - }, []) - - // 5) DataTable 컬럼 세팅 - const columns = React.useMemo( - () => - getColumns({ - setRowAction, - router, - openAttachmentsSheet, - openCommentSheet - }), - [setRowAction, router, openAttachmentsSheet, openCommentSheet] - ) - - /** - * 간단한 filterFields 예시 - */ - const filterFields: DataTableFilterField<RfqWithAll>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", - }, - { - id: "projectName", - label: "Project", - placeholder: "Filter Project...", - }, - { - id: "rfqDescription", - label: "Description", - placeholder: "Filter Description...", - }, - ] - - /** - * Advanced filter fields 예시 - */ - const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - type: "text", - }, - { - id: "rfqDescription", - label: "Description", - type: "text", - }, - { - id: "projectCode", - label: "Project Code", - type: "text", - }, - { - id: "projectName", - label: "Project Name", - type: "text", - }, - { - id: "rfqDueDate", - label: "Due Date", - type: "date", - }, - { - id: "responseStatus", - label: "Response Status", - type: "select", - options: [ - { label: "Reviewing", value: "REVIEWING" }, - { label: "Accepted", value: "ACCEPTED" }, - { label: "Declined", value: "DECLINED" }, - ], - } - ] - - // useDataTable() 훅 -> pagination, sorting 등 관리 - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - const currentVendorId = session?.user?.id ? session.user.companyId : 0 - - - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqsVendorTableToolbarActions table={table} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 1) 아이템 목록 Dialog */} - {rowAction?.type === "items" && rowAction?.row.original && ( - <RfqsItemsDialog - open={true} - onOpenChange={() => setRowAction(null)} - rfq={rowAction.row.original} - /> - )} - - {/* 2) 코멘트 시트 */} - {selectedRfqIdForComments && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - initialComments={initialComments} - rfqId={selectedRfqIdForComments} - vendorId={currentVendorId??0} - currentUserId={currentUserId} - /> - )} - - {/* 3) 첨부파일 시트 */} - <RfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - rfqId={selectedRfqIdForAttachments ?? 0} - attachments={attachDefault} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx deleted file mode 100644 index e0bf9727..00000000 --- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx +++ /dev/null @@ -1,346 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Textarea, -} from "@/components/ui/textarea" - -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput -} from "@/components/ui/dropzone" - -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell -} from "@/components/ui/table" - -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -// (실제 프로젝트에 맞춰 import를 수정하세요.) -import { RfqWithAll } from "@/db/schema/rfq" -import { createRfqCommentWithAttachments } from "../../rfqs/service" -import { formatDate } from "@/lib/utils" - -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 -export interface TbeComment { - id: number - commentText: string - commentedBy?: number - createdAt?: string | Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - /** 코멘트를 작성할 RFQ 정보 */ - /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ - initialComments?: TbeComment[] - - /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ - currentUserId: number - rfqId:number - vendorId:number - /** 댓글 저장 후 갱신용 콜백 (옵션) */ - onCommentsUpdated?: (comments: TbeComment[]) => void - isLoading?: boolean // New prop - -} - -// 새 코멘트 작성 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional() // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - const [comments, setComments] = React.useState<TbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - - // RHF 세팅 - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [] - } - }) - - // formFieldArray 예시 (파일 목록) - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles" - }) - - // 1) 기존 코멘트 + 첨부 보여주기 - // 간단히 테이블 하나로 표현 - // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 - function renderExistingComments() { - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {/* 첨부파일 표시 */} - {(!c.attachments || c.attachments.length === 0) && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments && c.attachments.length > 0 && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={att.filePath} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> - <TableCell> - {c.commentedBy ?? "-"} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // 2) 새 파일 Drop - function handleDropAccepted(files: File[]) { - // 드롭된 File[]을 RHF field array에 추가 - const toAppend = files.map((f) => f) - append(toAppend) - } - - - // 3) 저장(Submit) - async function onSubmit(data: CommentFormValues) { - - if (!rfqId) return - startTransition(async () => { - try { - // 서버 액션 호출 - const res = await createRfqCommentWithAttachments({ - rfqId: rfqId, - vendorId: vendorId, // 필요시 세팅 - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, // 필요시 세팅 - files: data.newFiles - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 - const newComment: TbeComment = { - id: res.commentId, // 서버에서 반환된 commentId - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: new Date().toISOString(), - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || []) - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - // 폼 리셋 - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - {/* 기존 코멘트 목록 */} - <div className="max-h-[300px] overflow-y-auto"> - {renderExistingComments()} - </div> - - {/* 새 코멘트 작성 Form */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea - placeholder="Enter your comment..." - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Dropzone (파일 첨부) */} - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {/* 선택된 파일 목록 */} - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div key={field.id} className="flex items-center justify-between border rounded p-2"> - <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx deleted file mode 100644 index 2056a48f..00000000 --- a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { formatDateTime } from "@/lib/utils" -import { CalendarClock } from "lucide-react" -import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table" -import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" - -interface RfqDeailDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - rfqId: number | null - rfq: TbeVendorFields | null -} - -export function RfqDeailDialog({ - isOpen, - onOpenChange, - rfqId, - rfq, -}: RfqDeailDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle> - {rfq && ( - <div className="flex flex-col space-y-3 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> - </div> - - {/* 정보를 두 행으로 나누어 표시 */} - <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> - {/* 첫 번째 행: 상태 배지 */} - <div className="flex items-center flex-wrap gap-2"> - {rfq.vendorStatus && ( - <Badge variant="outline"> - {rfq.rfqStatus} - </Badge> - )} - {rfq.rfqType && ( - <Badge - variant={ - rfq.rfqType === "BUDGETARY" ? "default" : - rfq.rfqType === "PURCHASE" ? "destructive" : - rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline" - } - > - {rfq.rfqType} - </Badge> - )} - </div> - - {/* 두 번째 행: Due Date를 강조 표시 */} - {rfq.rfqDueDate && ( - <div className="flex items-center"> - <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> - <CalendarClock className="h-3.5 w-3.5" /> - <span className="font-semibold">Due Date:</span> - <span>{formatDateTime(rfq.rfqDueDate)}</span> - </Badge> - </div> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {rfqId && ( - <div className="py-4"> - <RfqItemsTable rfqId={rfqId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx deleted file mode 100644 index f664d9a3..00000000 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx +++ /dev/null @@ -1,350 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, MessageSquare, Upload } 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 { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { - tbeVendorColumnsConfig, - VendorTbeColumnConfig, - vendorTbeColumnsConfig, - TbeVendorFields, -} from "@/config/vendorTbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<TbeVendorFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void - handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void - openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadTbeTemplate, - handleUploadTbeResponse, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<TbeVendorFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<TbeVendorFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<TbeVendorFields>[]> = {} - - tbeVendorColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<TbeVendorFields> - const childCol: ColumnDef<TbeVendorFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - maxSize: 120, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqCode") { - const rfq = row.original; - const rfqId = rfq.rfqId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (rfqId) { - openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) TBE Updated (날짜) - if (cfg.id === "tbeUpdated") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<TbeVendorFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 3) Comments 컬럼 - // ---------------------------------------------------------------- - const commentsColumn: ColumnDef<TbeVendorFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.tbeId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 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" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80 - } - - // ---------------------------------------------------------------- - // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능 - // ---------------------------------------------------------------- - const tbeDownloadColumn: ColumnDef<TbeVendorFields> = { - id: "tbeDownload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="TBE Sheets" /> - ), - cell: ({ row }) => { - const vendor = row.original - const tbeId = vendor.tbeId - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const templateFileCount = vendor.templateFileCount || 0 - - if (!tbeId || !vendorId || !rfqId) { - return <div className="text-center text-muted-foreground">-</div> - } - - // 템플릿 파일이 없으면 다운로드 버튼 비활성화 - const isDisabled = templateFileCount <= 0 - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={ - isDisabled - ? undefined - : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId) - } - aria-label={ - templateFileCount > 0 - ? `TBE 템플릿 다운로드 (${templateFileCount}개)` - : "다운로드할 파일 없음" - } - disabled={isDisabled} - > - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - - {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */} - {templateFileCount > 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" - > - {templateFileCount} - </Badge> - )} - - <span className="sr-only"> - {templateFileCount > 0 - ? `TBE 템플릿 다운로드 (${templateFileCount}개)` - : "다운로드할 파일 없음"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - // ---------------------------------------------------------------- - // 5) TBE 업로드 컬럼 - 응답 업로드 기능 - // ---------------------------------------------------------------- - const tbeUploadColumn: ColumnDef<TbeVendorFields> = { - id: "tbeUpload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Upload Response" /> - ), - cell: ({ row }) => { - const vendor = row.original - const tbeId = vendor.tbeId - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const vendorResponseId = vendor.vendorResponseId || 0 - const status = vendor.rfqVendorStatus - const hasResponse = vendor.hasResponse || false - - - if (!tbeId || !vendorId || !rfqId || status === "REJECTED") { - return <div className="text-center text-muted-foreground">-</div> - } - - return ( - <div > - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 group relative" - onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)} - aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"} - > - <div className="flex items-center justify-center relative"> - <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - </div> - {hasResponse && ( - <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span> - )} - <span className="sr-only"> - {"TBE 응답 업로드"} - </span> - </Button> - </div> - ) - }, - enableSorting: false, - maxSize: 80 - } - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - commentsColumn, - tbeDownloadColumn, - tbeUploadColumn, - ] -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx deleted file mode 100644 index 13d5dc64..00000000 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" -import { toast } from "sonner" -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 { getColumns } from "./tbe-table-columns" -import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service" -import { CommentSheet, TbeComment } from "./comments-sheet" -import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" -import { useTbeFileHandlers } from "./tbeFileHandler" -import { useSession } from "next-auth/react" -import { RfqDeailDialog } from "./rfq-detail-dialog" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getTBEforVendor>>, - ] - > -} - -export function TbeVendorTable({ promises }: VendorsTableProps) { - const { data: session } = useSession() - const userVendorId = session?.user?.companyId - const userId = Number(session?.user?.id) - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null) - - - // router 획득 - const router = useRouter() - - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false) - - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null) - - const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => { - setSelectedRfqId(rfqId) - setSelectedRfq(rfq) - setIsRfqDetailDialogOpen(true) - } - - // TBE 파일 핸들러 훅 사용 - const { - handleDownloadTbeTemplate, - handleUploadTbeResponse, - UploadDialog, - } = useTbeFileHandlers() - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.id)) - } - }, [rowAction]) - - async function openCommentSheet(vendorId: number) { - setInitialComments([]) - setIsLoadingComments(true) - - const comments = rowAction?.row.original.comments - - try { - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: userId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - setSelectedRfqIdForComments(vendorId) - setCommentSheetOpen(true) - - } catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } -} - - // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입 - const columns = React.useMemo( - () => getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadTbeTemplate, - handleUploadTbeResponse, - openVendorContactsDialog - }), - [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog] - ) - - const filterFields: DataTableFilterField<TbeVendorFields>[] = [] - - const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [ - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "projectCode", label: "Project Code", type: "text" }, - { id: "projectName", label: "Project Name", type: "text" }, - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "tbeResult", label: "TBE Result", type: "text" }, - { id: "tbeNote", label: "TBE Note", type: "text" }, - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "hasResponse", label: "Response?", type: "boolean" }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - { id: "dueDate", label: "Project Name", type: "date" }, - - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정 - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - - {/* 코멘트 시트 */} - {commentSheetOpen && selectedRfqIdForComments && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={selectedRfqIdForComments} - initialComments={initialComments} - vendorId={userVendorId || 0} - currentUserId={userId || 0} - isLoading={isLoadingComments} // Pass the loading state - - /> - )} - - <RfqDeailDialog - isOpen={isRfqDetailDialogOpen} - onOpenChange={setIsRfqDetailDialogOpen} - rfqId={selectedRfqId} - rfq={selectedRfq} - /> - - {/* TBE 파일 다이얼로그 */} - <UploadDialog /> - </> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx deleted file mode 100644 index a0b6f805..00000000 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx +++ /dev/null @@ -1,355 +0,0 @@ -"use client"; - -import { useCallback, useState, useEffect } from "react"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { - fetchTbeTemplateFiles, - uploadTbeResponseFile, - getTbeSubmittedFiles, - getFileFromRfqAttachmentsbyid, -} from "../../rfqs/service"; -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone"; -import { - FileList, - FileListAction, - FileListDescription, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list"; -import { Download, X } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { formatDateTime } from "@/lib/utils"; - -export function useTbeFileHandlers() { - // 모달 열림 여부, 현재 선택된 IDs - const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); - const [currentTbeId, setCurrentTbeId] = useState<number | null>(null); - const [currentVendorId, setCurrentVendorId] = useState<number | null>(null); - const [currentRfqId, setCurrentRfqId] = useState<number | null>(null); - const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null); - - - - // 로딩 상태들 - const [isLoading, setIsLoading] = useState(false); - const [isFetchingFiles, setIsFetchingFiles] = useState(false); - - // 업로드할 파일, 제출된 파일 목록 - const [selectedFile, setSelectedFile] = useState<File | null>(null); - const [submittedFiles, setSubmittedFiles] = useState< - Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }> - >([]); - - // =================================== - // 1) 제출된 파일 목록 가져오기 - // =================================== - const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => { - if (!vendorResponseId ) return; - - setIsFetchingFiles(true); - try { - const { files, error } = await getTbeSubmittedFiles(vendorResponseId); - if (error) { - console.error(error); - return; - } - setSubmittedFiles(files); - } catch (error) { - console.error("Failed to fetch submitted files:", error); - } finally { - setIsFetchingFiles(false); - } - }, []); - - // =================================== - // 2) TBE 템플릿 다운로드 - // =================================== - const handleDownloadTbeTemplate = useCallback( - async (tbeId: number, vendorId: number, rfqId: number) => { - setCurrentTbeId(tbeId); - setCurrentVendorId(vendorId); - setCurrentRfqId(rfqId); - setIsLoading(true); - - try { - const { files, error } = await fetchTbeTemplateFiles(tbeId); - if (error) { - toast.error(error); - return; - } - if (files.length === 0) { - toast.warning("다운로드할 템플릿 파일이 없습니다"); - return; - } - // 순차적으로 파일 다운로드 - for (const file of files) { - await downloadFile(file.id); - } - toast.success("모든 템플릿 파일이 다운로드되었습니다"); - } catch (error) { - toast.error("템플릿 파일을 다운로드하는 데 실패했습니다"); - console.error(error); - } finally { - setIsLoading(false); - } - }, - [] - ); - - // 실제 다운로드 로직 - const downloadFile = useCallback(async (fileId: number) => { - try { - const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); - if (error || !file) { - throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); - } - - const link = document.createElement("a"); - link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - return true; - } catch (error) { - console.error(error); - return false; - } - }, []); - - // =================================== - // 3) 제출된 파일 다운로드 - // =================================== - const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => { - try { - const link = document.createElement("a"); - link.href = `/api/tbe-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - toast.success(`${file.fileName} 다운로드 시작`); - } catch (error) { - console.error("Failed to download file:", error); - toast.error("파일 다운로드에 실패했습니다"); - } - }, []); - - // =================================== - // 4) TBE 응답 업로드 모달 열기 - // (이 시점에서는 데이터 fetch하지 않음) - // =================================== - const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => { - setCurrentTbeId(tbeId); - setCurrentVendorId(vendorId); - setCurrentRfqId(rfqId); - setCurrentvendorResponseId(vendorResponseId); - setIsUploadDialogOpen(true); - }, []); - - // =================================== - // 5) Dialog 열고 닫힐 때 상태 초기화 - // 열렸을 때 -> useEffect로 파일 목록 가져오기 - // =================================== - useEffect(() => { - if (!isUploadDialogOpen) { - // 닫힐 때는 파일 상태들 초기화 - setSelectedFile(null); - setSubmittedFiles([]); - } - }, [isUploadDialogOpen]); - - useEffect(() => { - // Dialog가 열렸고, ID들이 유효하면 - if (isUploadDialogOpen &¤tvendorResponseId) { - fetchSubmittedFiles(currentvendorResponseId); - } - }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]); - - // =================================== - // 6) 드롭존 파일 선택 & 제거 - // =================================== - const handleFileDrop = useCallback((files: File[]) => { - if (files && files.length > 0) { - setSelectedFile(files[0]); - } - }, []); - - const handleRemoveFile = useCallback(() => { - setSelectedFile(null); - }, []); - - // =================================== - // 7) 응답 파일 업로드 - // =================================== - const handleSubmitResponse = useCallback(async () => { - if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) { - toast.error("업로드할 파일을 선택해주세요"); - return; - } - - setIsLoading(true); - try { - // FormData 생성 - const formData = new FormData(); - formData.append("file", selectedFile); - formData.append("rfqId", currentRfqId.toString()); - formData.append("vendorId", currentVendorId.toString()); - formData.append("evaluationId", currentTbeId.toString()); - formData.append("vendorResponseId", currentvendorResponseId.toString()); - - const result = await uploadTbeResponseFile(formData); - if (!result.success) { - throw new Error(result.error || "파일 업로드에 실패했습니다"); - } - - toast.success(result.message || "응답이 성공적으로 업로드되었습니다"); - - // 업로드 후 다시 제출된 파일 목록 가져오기 - await fetchSubmittedFiles(currentvendorResponseId); - - // 업로드 성공 시 선택 파일 초기화 - setSelectedFile(null); - } catch (error) { - toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다"); - console.error(error); - } finally { - setIsLoading(false); - } - }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]); - - // =================================== - // 8) 실제 Dialog 컴포넌트 - // =================================== - const UploadDialog = () => ( - <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> - <DialogTitle>TBE 응답 파일</DialogTitle> - <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription> - </DialogHeader> - - <Tabs defaultValue="upload" className="w-full"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="upload">새 파일 업로드</TabsTrigger> - <TabsTrigger - value="submitted" - disabled={submittedFiles.length === 0} - className={submittedFiles.length > 0 ? "relative" : ""} - > - 제출된 파일{" "} - {submittedFiles.length > 0 && ( - <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground"> - {submittedFiles.length} - </span> - )} - </TabsTrigger> - </TabsList> - - {/* 업로드 탭 */} - <TabsContent value="upload" className="pt-4"> - <div className="grid gap-4"> - {selectedFile ? ( - <FileList> - <FileListItem> - <FileListIcon /> - <FileListInfo> - <FileListName>{selectedFile.name}</FileListName> - <FileListSize>{selectedFile.size}</FileListSize> - </FileListInfo> - <FileListAction> - <Button variant="ghost" size="icon" onClick={handleRemoveFile}> - <X className="h-4 w-4" /> - <span className="sr-only">파일 제거</span> - </Button> - </FileListAction> - </FileListItem> - </FileList> - ) : ( - <Dropzone onDrop={handleFileDrop}> - <DropzoneInput className="sr-only" /> - <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6"> - <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> - <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> - <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription> - </DropzoneZone> - </Dropzone> - )} - - <DialogFooter className="mt-4"> - <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}> - {isLoading ? "업로드 중..." : "응답 업로드"} - </Button> - </DialogFooter> - </div> - </TabsContent> - - {/* 제출된 파일 탭 */} - <TabsContent value="submitted" className="pt-4"> - {isFetchingFiles ? ( - <div className="flex justify-center items-center py-8"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> - </div> - ) : submittedFiles.length > 0 ? ( - <div className="grid gap-2"> - <FileList> - {submittedFiles.map((file) => ( - <FileListItem key={file.id} className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-3 flex-1"> - <FileListIcon className="flex-shrink-0" /> - <FileListInfo className="flex-1 min-w-0"> - <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> - <FileListDescription className="text-xs text-muted-foreground"> - {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} - </FileListDescription> - </FileListInfo> - </div> - <FileListAction className="flex-shrink-0 ml-2"> - <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> - <Download className="h-4 w-4" /> - <span className="sr-only">파일 다운로드</span> - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - </div> - ) : ( - <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> - )} - </TabsContent> - </Tabs> - </DialogContent> - </Dialog> - ); - - // =================================== - // 9) Hooks 내보내기 - // =================================== - return { - handleDownloadTbeTemplate, - handleUploadTbeResponse, - UploadDialog, - }; -}
\ 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 index a9fe0e1a..d272a0ea 100644 --- a/lib/vendors/table/request-project-pq-dialog.tsx +++ b/lib/vendors/table/request-project-pq-dialog.tsx @@ -43,7 +43,7 @@ import { import { Label } from "@/components/ui/label" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" -import { getProjects, type Project } from "@/lib/rfqs/service" +import { getProjects } from "@/lib/projects/service" import { useSession } from "next-auth/react" interface RequestProjectPQDialogProps @@ -53,6 +53,13 @@ interface RequestProjectPQDialogProps onSuccess?: () => void } +export type Project = { + id: number; + projectCode: string; + projectName: string; + type: string; +} + export function RequestProjectPQDialog({ vendors, showTrigger = true, diff --git a/middleware.ts b/middleware.ts index 6a825e6f..2ff8408e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -82,22 +82,6 @@ function getDashboardPath(domain: string, lng: string): string { } } -// 도메인별 로그인 페이지 경로 정의 -function getLoginPath(domain: string, lng: string): string { - switch (domain) { - case 'partners': - return `/${lng}/partners`; - case 'pending': - return `/${lng}/pending`; - case 'evcp': - case 'procurement': - case 'sales': - case 'engineering': - default: - return `/${lng}/evcp`; - } -} - // 도메인-URL 일치 여부 확인 및 올바른 리다이렉트 경로 반환 function getDomainRedirectPath(path: string, domain: string, lng: string) { // 도메인이 없는 경우 리다이렉트 없음 @@ -107,14 +91,14 @@ function getDomainRedirectPath(path: string, domain: string, lng: string) { const domainPatterns = { pending: `/pending`, evcp: `/evcp`, - procurement: `/procurement`, - sales: `/sales`, - engineering: `/engineering`, + procurement: `/evcp`, + sales: `/evcp`, + engineering: `/evcp`, partners: `/partners` }; // 현재 경로가 어떤 도메인 패턴에 속하는지 확인 - let currentPathDomain = null; + let currentPathDomain: string | null = null; for (const [domainName, pattern] of Object.entries(domainPatterns)) { // 정확한 매칭을 위해 언어 코드를 포함한 전체 패턴으로 확인 const fullPattern = `/${lng}${pattern}`; |
