diff options
15 files changed, 734 insertions, 285 deletions
diff --git a/app/[lng]/evcp/(evcp)/approval/document-list-only/layout.tsx b/app/[lng]/evcp/(evcp)/approval/document-list-only/layout.tsx new file mode 100644 index 00000000..17e78c0a --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/document-list-only/layout.tsx @@ -0,0 +1,17 @@ +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]/evcp/(evcp)/approval/document-list-only/page.tsx b/app/[lng]/evcp/(evcp)/approval/document-list-only/page.tsx new file mode 100644 index 00000000..5b49a6ef --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/document-list-only/page.tsx @@ -0,0 +1,98 @@ +// 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]/evcp/(evcp)/document-list-only/layout.tsx b/app/[lng]/evcp/(evcp)/document-list-only/layout.tsx new file mode 100644 index 00000000..17e78c0a --- /dev/null +++ b/app/[lng]/evcp/(evcp)/document-list-only/layout.tsx @@ -0,0 +1,17 @@ +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]/evcp/(evcp)/document-list-only/page.tsx b/app/[lng]/evcp/(evcp)/document-list-only/page.tsx new file mode 100644 index 00000000..5b49a6ef --- /dev/null +++ b/app/[lng]/evcp/(evcp)/document-list-only/page.tsx @@ -0,0 +1,98 @@ +// 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]/evcp/(evcp)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx b/app/[lng]/evcp/(evcp)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx new file mode 100644 index 00000000..f69aa525 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx @@ -0,0 +1,79 @@ +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]/evcp/(evcp)/vendor-data/layout.tsx b/app/[lng]/evcp/(evcp)/vendor-data/layout.tsx new file mode 100644 index 00000000..7d00359c --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendor-data/layout.tsx @@ -0,0 +1,67 @@ +// 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]/evcp/(evcp)/vendor-data/page.tsx b/app/[lng]/evcp/(evcp)/vendor-data/page.tsx new file mode 100644 index 00000000..ddc21a2b --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendor-data/page.tsx @@ -0,0 +1,28 @@ +// 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]/evcp/(evcp)/vendor-data/tag/[id]/page.tsx b/app/[lng]/evcp/(evcp)/vendor-data/tag/[id]/page.tsx new file mode 100644 index 00000000..7250732f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendor-data/tag/[id]/page.tsx @@ -0,0 +1,43 @@ +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/components/document-lists/vendor-doc-list-client-evcp.tsx b/components/document-lists/vendor-doc-list-client-evcp.tsx new file mode 100644 index 00000000..cc9b6804 --- /dev/null +++ b/components/document-lists/vendor-doc-list-client-evcp.tsx @@ -0,0 +1,35 @@ +"use client" +import * as React from "react" + +import { InformationButton } from "@/components/information/information-button" + +interface VendorDocumentsClientEvcpProps { + children: React.ReactNode +} + +export default function VendorDocumentListClientEvcp({ + children, +}: VendorDocumentsClientEvcpProps) { + return ( + <> + {/* 상단 영역: 타이틀만 표시 */} + <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="evcp/document-list" /> + </div> + <p className="text-muted-foreground"> + 전체 계약 대상 문서를 관리하고 진행 상황을 추적할 수 있습니다. + </p> + </div> + </div> + + {/* 문서 목록/테이블 영역 */} + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow p-5"> + {children} + </section> + </> + ) +} diff --git a/components/vendor-data/sidebar.tsx b/components/vendor-data/sidebar.tsx index 3805d216..a6b35a9d 100644 --- a/components/vendor-data/sidebar.tsx +++ b/components/vendor-data/sidebar.tsx @@ -27,8 +27,7 @@ interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { selectedProjectId: number | null selectedContractId: number | null onSelectPackage: (itemId: number) => void - forms: FormInfo[] - selectedForm: string | null + forms?: FormInfo[] // 선택적 속성으로 변경 onSelectForm: (formName: string) => void isLoadingForms?: boolean mode: "IM" | "ENG" // 새로 추가: 현재 선택된 모드 @@ -43,7 +42,7 @@ export function Sidebar({ selectedContractId, onSelectPackage, forms, - selectedForm, + // selectedForm, // 사용되지 않음 onSelectForm, isLoadingForms = false, mode = "IM", // 기본값 설정 @@ -90,13 +89,9 @@ export function Sidebar({ * --------------------------- */ const handlePackageClick = (itemId: number) => { - // 상위 컴포넌트 상태 업데이트 + // 상위 컴포넌트 상태 업데이트만 수행 + // 라우팅은 하지 않음 (프로젝트 선택 상태 유지) onSelectPackage(itemId) - - // 해당 태그 페이지로 라우팅 - // 예: /vendor-data/tag/123 - const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") - router.push(`/${baseSegments}/tag/${itemId}`) } /** @@ -204,7 +199,7 @@ export function Sidebar({ <Skeleton className="h-8 w-full" /> </div> )) - ) : forms.length === 0 ? ( + ) : !forms || forms.length === 0 ? ( <p className="text-sm text-muted-foreground px-2"> (No forms loaded) </p> diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index a549594c..3974b791 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -1,63 +1,42 @@ "use client" import * as React from "react" -import { TooltipProvider } from "@/components/ui/tooltip" +import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation" +import { useAtom } from "jotai" +import { selectedModeAtom } from "@/atoms" +import { Sidebar } from "./sidebar" +import { ProjectSwitcher } from "./project-swicher" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" import { cn } from "@/lib/utils" -import { ProjectSwitcher } from "./project-swicher" -import { Sidebar } from "./sidebar" -import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation" -import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" import { Separator } from "@/components/ui/separator" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" -import { ScrollArea } from "@/components/ui/scroll-area" import { Button } from "@/components/ui/button" -import { FormInput } from "lucide-react" -import { Skeleton } from "@/components/ui/skeleton" -import { selectedModeAtom } from '@/atoms' -import { useAtom } from 'jotai' +import { TooltipProvider } from "@/components/ui/tooltip" interface PackageData { itemId: number itemName: string } -interface ContractData { - contractId: number - contractName: string - packages: PackageData[] -} - -interface ProjectData { - projectId: number - projectCode: string - projectName: string - projectType: string - contracts: ContractData[] -} - interface VendorDataContainerProps { - projects: ProjectData[] + projects: { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: { + contractId: number + contractNo: string + contractName: string + packages: PackageData[] + }[] + }[] defaultLayout?: number[] defaultCollapsed?: boolean - navCollapsedSize: number + navCollapsedSize?: number children: React.ReactNode } -function getTagIdFromPathname(path: string | null): number | null { - if (!path) return null; - - // 태그 패턴 검사 (/tag/123) - const tagMatch = path.match(/\/tag\/(\d+)/) - if (tagMatch) return parseInt(tagMatch[1], 10) - - // 폼 패턴 검사 (/form/123/...) - const formMatch = path.match(/\/form\/(\d+)/) - if (formMatch) return parseInt(formMatch[1], 10) - - return null -} - export function VendorDataContainer({ projects, defaultLayout = [20, 80], @@ -71,8 +50,6 @@ export function VendorDataContainer({ const params = useParams() const currentLng = params?.lng as string || 'en' - const tagIdNumber = getTagIdFromPathname(pathname) - // 기본 상태 const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) @@ -80,15 +57,12 @@ export function VendorDataContainer({ projects[0]?.contracts[0]?.contractId || 0 ) const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) - const [formList, setFormList] = React.useState<FormInfo[]>([]) - const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) - const [isLoadingForms, setIsLoadingForms] = React.useState(false) // 현재 선택된 프로젝트/계약/패키지 const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) ?? currentProject?.contracts[0] - + // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 const isShipProject = currentProject?.projectType === "ship" @@ -104,31 +78,6 @@ export function VendorDataContainer({ React.useEffect(() => { setSelectedMode(initialMode as "IM" | "ENG") }, [initialMode, setSelectedMode]) - - const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false - const currentPackageName = isTagOrFormRoute - ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" - : "None" - - // 폼 목록에서 고유한 폼 이름만 추출 - const formNames = React.useMemo(() => { - return [...new Set(formList.map((form) => form.formName))] - }, [formList]) - - // URL에서 현재 폼 코드 추출 - const getCurrentFormCode = (path: string): string | null => { - const segments = path.split("/").filter(Boolean) - const formIndex = segments.indexOf("form") - if (formIndex !== -1 && segments[formIndex + 2]) { - return segments[formIndex + 2] - } - return null - } - - const currentFormCode = React.useMemo(() => { - return pathname ? getCurrentFormCode(pathname) : null - }, [pathname]) - // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) React.useEffect(() => { @@ -152,23 +101,7 @@ export function VendorDataContainer({ } }, [isShipProject, router]) - // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 - React.useEffect(() => { - if (!currentContract) return - - if (tagIdNumber) { - setSelectedPackageId(tagIdNumber) - } else { - // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 - if (currentContract.packages?.length) { - setSelectedPackageId(currentContract.packages[0].itemId) - } else { - setSelectedPackageId(null) - } - } - }, [tagIdNumber, currentContract]) - - // (2) 프로젝트 변경 시 계약 초기화 + // (1) 프로젝트 변경 시 계약 초기화 React.useEffect(() => { if (currentProject?.contracts.length) { setSelectedContractId(currentProject.contracts[0].contractId) @@ -177,38 +110,6 @@ export function VendorDataContainer({ } }, [currentProject]) - // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 - React.useEffect(() => { - const packageId = getTagIdFromPathname(pathname) - - if (packageId) { - setSelectedPackageId(packageId) - - // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 - loadFormsList(packageId, selectedMode); - } else if (currentContract?.packages?.length) { - const firstPackageId = currentContract.packages[0].itemId; - setSelectedPackageId(firstPackageId); - loadFormsList(firstPackageId, selectedMode); - } - }, [pathname, currentContract, selectedMode]) - - // 모드에 따른 폼 로드 함수 - const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { - if (!packageId) return; - - setIsLoadingForms(true); - try { - const result = await getFormsByContractItemId(packageId, mode); - setFormList(result.forms || []); - } catch (error) { - console.error(`폼 로딩 오류 (${mode} 모드):`, error); - setFormList([]); - } finally { - setIsLoadingForms(false); - } - }; - // 핸들러들 function handleSelectContract(projId: number, cId: number) { setSelectedProjectId(projId) @@ -217,67 +118,72 @@ export function VendorDataContainer({ function handleSelectPackage(itemId: number) { setSelectedPackageId(itemId) + + // partners와 동일하게: 패키지 선택 시 해당 페이지로 이동 + if (itemId && pathname) { + // 더 안전한 URL 생성 로직 + let baseSegments: string; + const vendorDataIndex = pathname.split("/").filter(Boolean).indexOf("vendor-data"); + + if (vendorDataIndex !== -1) { + baseSegments = pathname.split("/").filter(Boolean).slice(0, vendorDataIndex + 1).join("/"); + } else { + // vendor-data가 없으면 기본 경로 사용 + baseSegments = `${currentLng}/evcp/vendor-data`; + } + + const targetUrl = `/${baseSegments}/tag/${itemId}?mode=${selectedMode}`; + router.push(targetUrl); + } } function handleSelectForm(formName: string) { - const form = formList.find((f) => f.formName === formName) - if (form) { - setSelectedFormCode(form.formCode) + // partners와 동일하게: 폼 선택 시 해당 페이지로 이동 + if (selectedPackageId && pathname) { + // 더 안전한 URL 생성 로직 + let baseSegments: string; + const vendorDataIndex = pathname.split("/").filter(Boolean).indexOf("vendor-data"); + + if (vendorDataIndex !== -1) { + baseSegments = pathname.split("/").filter(Boolean).slice(0, vendorDataIndex + 1).join("/"); + } else { + // vendor-data가 없으면 기본 경로 사용 + baseSegments = `${currentLng}/evcp/vendor-data`; + } + + const targetUrl = `/${baseSegments}/form/${selectedPackageId}/${formName}/${selectedProjectId}/${selectedContractId}?mode=${selectedMode}`; + router.push(targetUrl); } } // 모드 변경 핸들러 -// 모드 변경 핸들러 -const handleModeChange = async (mode: "IM" | "ENG") => { - // ship 프로젝트인 경우 모드 변경 금지 - if (isShipProject && mode !== "ENG") return; - - setSelectedMode(mode); - - // 모드가 변경될 때 자동 네비게이션 - if (currentContract?.packages?.length) { - const firstPackageId = currentContract.packages[0].itemId; + const handleModeChange = async (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; - if (mode === "IM") { - // IM 모드: 첫 번째 패키지로 이동 - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); - router.push(`/${currentLng}/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); - } else { - // ENG 모드: 폼 목록을 먼저 로드 - setIsLoadingForms(true); - try { - const result = await getFormsByContractItemId(firstPackageId, mode); - setFormList(result.forms || []); + setSelectedMode(mode); + + // 모드가 변경될 때 자동 네비게이션 + if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + + if (pathname) { + // 더 안전한 URL 생성 로직 + let baseSegments: string; + const vendorDataIndex = pathname.split("/").filter(Boolean).indexOf("vendor-data"); - // 폼이 있으면 첫 번째 폼으로 이동 - if (result.forms && result.forms.length > 0) { - const firstForm = result.forms[0]; - setSelectedFormCode(firstForm.formCode); - - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); - router.push(`/${currentLng}/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + if (vendorDataIndex !== -1) { + baseSegments = pathname.split("/").filter(Boolean).slice(0, vendorDataIndex + 1).join("/"); } else { - // 폼이 없으면 모드만 변경 - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); - router.push(`/${currentLng}/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + // vendor-data가 없으면 기본 경로 사용 + baseSegments = `${currentLng}/evcp/vendor-data`; } - } catch (error) { - console.error(`폼 로딩 오류 (${mode} 모드):`, error); - // 오류 발생 시 모드만 변경 - const url = new URL(window.location.href); - url.searchParams.set('mode', mode); - router.replace(url.pathname + url.search); - } finally { - setIsLoadingForms(false); + + const targetUrl = `/${baseSegments}/tag/${firstPackageId}?mode=${mode}`; + router.push(targetUrl); } } - } else { - // 패키지가 없는 경우, 모드만 변경 - const url = new URL(window.location.href); - url.searchParams.set('mode', mode); - router.replace(url.pathname + url.search); } -}; return ( <TooltipProvider delayDuration={0}> @@ -318,14 +224,7 @@ const handleModeChange = async (mode: "IM" | "ENG") => { selectedProjectId={selectedProjectId} selectedContractId={selectedContractId} onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} mode="ENG" className="hidden lg:block" /> @@ -351,14 +250,7 @@ const handleModeChange = async (mode: "IM" | "ENG") => { selectedContractId={selectedContractId} selectedProjectId={selectedProjectId} onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} mode="IM" className="hidden lg:block" /> @@ -372,14 +264,7 @@ const handleModeChange = async (mode: "IM" | "ENG") => { selectedContractId={selectedContractId} selectedProjectId={selectedProjectId} onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} mode="ENG" className="hidden lg:block" /> @@ -418,14 +303,7 @@ const handleModeChange = async (mode: "IM" | "ENG") => { selectedProjectId={selectedProjectId} selectedContractId={selectedContractId} onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} mode={isShipProject ? "ENG" : selectedMode} className="hidden lg:block" /> @@ -441,7 +319,7 @@ const handleModeChange = async (mode: "IM" | "ENG") => { <h2 className="text-lg font-bold"> {isShipProject || selectedMode === "ENG" ? "Engineering Mode" - : `Package: ${currentPackageName}`} + : `Package: ${currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None"}`} </h2> </div> {children} diff --git a/lib/vendor-data/services.ts b/lib/vendor-data/services.ts index 0ec935b9..e8ecd01c 100644 --- a/lib/vendor-data/services.ts +++ b/lib/vendor-data/services.ts @@ -3,11 +3,10 @@ import db from "@/db/db" import { items } from "@/db/schema/items" import { projects } from "@/db/schema/projects" -import { Tag, tags } from "@/db/schema/vendorData" import { eq } from "drizzle-orm" -import { revalidateTag, unstable_noStore } from "next/cache"; -import { unstable_cache } from "@/lib/unstable-cache"; import { contractItems, contracts } from "@/db/schema/contract"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; export interface ProjectWithContracts { projectId: number @@ -20,16 +19,22 @@ export interface ProjectWithContracts { contractNo: string contractName: string packages: { - itemId: number + itemId: number // contract_items.id itemName: string }[] }[] } export async function getVendorProjectsAndContracts( - vendorId: number + vendorId?: number ): Promise<ProjectWithContracts[]> { - const rows = await db + // 세션에서 도메인 정보 가져오기 + const session = await getServerSession(authOptions) + + // EVCP 도메인일 때만 전체 조회 + const isEvcpDomain = session?.user?.domain === "evcp" + + const query = db .select({ projectId: projects.id, projectCode: projects.code, @@ -47,7 +52,12 @@ export async function getVendorProjectsAndContracts( .innerJoin(projects, eq(contracts.projectId, projects.id)) .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)) .innerJoin(items, eq(contractItems.itemId, items.id)) - .where(eq(contracts.vendorId, vendorId)) + + if (!isEvcpDomain && vendorId) { + query.where(eq(contracts.vendorId, vendorId)) + } + + const rows = await query const projectMap = new Map<number, ProjectWithContracts>() @@ -71,6 +81,7 @@ export async function getVendorProjectsAndContracts( (c) => c.contractId === row.contractId ) if (!contractEntry) { + // 새 계약 항목 contractEntry = { contractId: row.contractId, @@ -97,4 +108,5 @@ export async function getVendorProjectsAndContracts( } return Array.from(projectMap.values()) -}
\ No newline at end of file +} + diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index 742b8a8a..d4baf7fb 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -35,6 +35,7 @@ import { cn } from "@/lib/utils" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>> projectType: string + domain?: "evcp" | "partners" // 선택적 파라미터로 유지 } // 유틸리티 함수들 @@ -76,7 +77,7 @@ const getPriorityText = (priority: string) => { case 'HIGH': return 'High' case 'MEDIUM': return 'Medium' case 'LOW': return 'Low' - default: priority + default: return priority } } @@ -136,9 +137,11 @@ const DueDateInfo = ({ export function getDocumentStagesColumns({ setRowAction, - projectType + projectType, + domain = "partners", // 기본값 설정 }: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] { const isPlantProject = projectType === "plant" + const isEvcpDomain = domain === "evcp" const columns: ColumnDef<DocumentStagesOnlyView>[] = [ // 체크박스 선택 @@ -167,7 +170,11 @@ export function getDocumentStagesColumns({ enableSorting: false, enableHiding: false, }, - { + ] + + // EVCP 도메인일 때만 Project 컬럼 추가 (앞쪽으로 이동) + if (isEvcpDomain) { + columns.push({ accessorKey: "projectCode", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="Project" /> @@ -183,57 +190,101 @@ export function getDocumentStagesColumns({ meta: { excelHeader: "Project" }, - }, - - + }) + } - // 문서번호 - { - accessorKey: "docNumber", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Document Number" /> - ), - cell: ({ row }) => { - const doc = row.original - return ( - <span className="font-mono text-sm font-medium">{doc.docNumber}</span> - ) - }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "Document Number" + // EVCP 도메인일 때만 Vendor 정보 컬럼들 추가 (앞쪽으로 이동) + if (isEvcpDomain) { + columns.push( + // Vendor Code + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => { + const doc = row.original + return doc.vendorCode ? ( + <span className="font-mono text-sm font-medium text-blue-600">{doc.vendorCode}</span> + ) : ( + <span className="text-gray-400 text-sm">-</span> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "Vendor Code" + }, }, - }, + // Vendor Name + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => { + const doc = row.original + return doc.vendorName ? ( + <span className="text-sm font-medium">{doc.vendorName}</span> + ) : ( + <span className="text-gray-400 text-sm">-</span> + ) + }, + size: 150, + enableResizing: true, + meta: { + excelHeader: "Vendor Name" + }, + } + ) + } - // 문서명 (PIC 포함) - { - accessorKey: "title", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Document Name" /> - ), - cell: ({ row }) => { - const doc = row.original - return ( - <div className="min-w-0 flex-1"> - <div className="font-medium text-gray-900 truncate text-sm" title={doc.title}> - {doc.title} - </div> - {doc.pic && ( - <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded mt-1 inline-block"> - PIC: {doc.pic} - </span> - )} + // 문서번호 + columns.push({ + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Number" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Document Number" + }, + }) + + // 문서명 (PIC 포함) + columns.push({ + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Name" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="min-w-0 flex-1"> + <div className="font-medium text-gray-900 truncate text-sm" title={doc.title}> + {doc.title} </div> - ) - }, - size: 220, - enableResizing: true, - meta: { - excelHeader: "Document Name" - }, + {doc.pic && ( + <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded mt-1 inline-block"> + PIC: {doc.pic} + </span> + )} + </div> + ) }, - ] + size: 220, + enableResizing: true, + meta: { + excelHeader: "Document Name" + }, + }) // Plant 프로젝트용 추가 컬럼들 if (isPlantProject) { @@ -258,7 +309,6 @@ export function getDocumentStagesColumns({ excelHeader: "Vendor Doc No." }, }, - ) } diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index c6a891c8..57f17bae 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -30,6 +30,8 @@ import { unstable_noStore as noStore } from "next/cache" import { filterColumns } from "@/lib/filter-columns" import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-document-service" import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" interface UpdateDocumentData { documentId: number @@ -1013,7 +1015,6 @@ export async function getDocumentStagesOnly( ) { try { const offset = (input.page - 1) * input.perPage - // 고급 필터 처리 const advancedWhere = filterColumns({ table: stageDocumentsView, @@ -1034,21 +1035,39 @@ export async function getDocumentStagesOnly( ) } - // 최종 WHERE 조건 - const finalWhere = and( - advancedWhere, - globalWhere, - eq(stageDocumentsView.contractId, contractId) - ) - // 정렬 처리 + // 세션에서 도메인 정보 가져오기 + const session = await getServerSession(authOptions) + const isEvcpDomain = session?.user?.domain === "evcp" + + // 도메인별 WHERE 조건 설정 + let finalWhere + if (isEvcpDomain) { + // EVCP: 전체 계약 조회 (contractId 조건 제거) + finalWhere = and( + advancedWhere, + globalWhere + ) + } else { + // Partners: 특정 계약 조회 + finalWhere = and( + advancedWhere, + globalWhere, + eq(documentStagesOnlyView.contractId, contractId) + ) + } + + + + // 정렬 처리 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(stageDocumentsView.createdAt)] + : [desc(documentStagesOnlyView.createdAt)] + // 트랜잭션 실행 const { data, total } = await db.transaction(async (tx) => { diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index ccf35f4b..8bfae284 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -34,6 +34,7 @@ import { EditDocumentDialog } from "./document-stage-dialogs" import { EditStageDialog } from "./document-stage-dialogs" import { ExcelImportDialog } from "./document-stage-dialogs" import { DocumentsTableToolbarActions } from "./document-stage-toolbar" +import { useSession } from "next-auth/react" interface DocumentStagesTableProps { promises: Promise<[Awaited<ReturnType<typeof getDocumentStagesOnly>>]> @@ -46,13 +47,18 @@ export function DocumentStagesTable({ contractId, projectType, }: DocumentStagesTableProps) { - const [{ data, pageCount, total }] = React.use(promises) + const [{ data, pageCount }] = React.use(promises) + + const { data: session } = useSession() + // URL에서 언어 파라미터 가져오기 const params = useParams() const lng = (params?.lng as string) || 'ko' const { t } = useTranslation(lng, 'document') + // 세션에서 도메인을 가져오기 + const currentDomain = session?.user?.domain as "evcp" | "partners" // 상태 관리 const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null) @@ -100,27 +106,31 @@ export function DocumentStagesTable({ } } }, - projectType + projectType, + domain: currentDomain }), - [expandedRows, projectType] + [expandedRows, projectType, currentDomain] ) // 통계 계산 const stats = React.useMemo(() => { - const totalDocs = data.length - const overdue = data.filter(doc => doc.isOverdue).length - const dueSoon = data.filter(doc => + console.log('DocumentStagesTable - data:', data) + console.log('DocumentStagesTable - data length:', data?.length) + + const totalDocs = data?.length || 0 + const overdue = data?.filter(doc => doc.isOverdue)?.length || 0 + const dueSoon = data?.filter(doc => doc.daysUntilDue !== null && doc.daysUntilDue >= 0 && doc.daysUntilDue <= 3 - ).length - const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length - const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + )?.length || 0 + const inProgress = data?.filter(doc => doc.currentStageStatus === 'IN_PROGRESS')?.length || 0 + const highPriority = data?.filter(doc => doc.currentStagePriority === 'HIGH')?.length || 0 const avgProgress = totalDocs > 0 - ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + ? Math.round((data?.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) || 0) / totalDocs) : 0 - return { + const result = { total: totalDocs, overdue, dueSoon, @@ -128,6 +138,9 @@ export function DocumentStagesTable({ highPriority, avgProgress } + + console.log('DocumentStagesTable - stats:', result) + return result }, [data]) // 빠른 필터링 @@ -273,7 +286,7 @@ export function DocumentStagesTable({ <CardContent> <div className="text-2xl font-bold">{stats.total}</div> <p className="text-xs text-muted-foreground"> - {t('documentList.dashboard.totalDocumentCount', { total })} + {t('documentList.dashboard.totalDocumentCount', { total: stats.total })} </p> </CardContent> </Card> |
