summaryrefslogtreecommitdiff
path: root/lib/pq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pq')
-rw-r--r--lib/pq/helper.ts96
-rw-r--r--lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx69
-rw-r--r--lib/pq/pq-review-table-new/feature-flags-provider.tsx108
-rw-r--r--lib/pq/pq-review-table-new/pq-container.tsx151
-rw-r--r--lib/pq/pq-review-table-new/pq-filter-sheet.tsx651
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx331
-rw-r--r--lib/pq/pq-review-table-new/send-results-dialog.tsx69
-rw-r--r--lib/pq/pq-review-table-new/user-combobox.tsx122
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx640
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx351
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx308
-rw-r--r--lib/pq/service.ts1327
-rw-r--r--lib/pq/validations.ts40
13 files changed, 4187 insertions, 76 deletions
diff --git a/lib/pq/helper.ts b/lib/pq/helper.ts
new file mode 100644
index 00000000..16aed0e4
--- /dev/null
+++ b/lib/pq/helper.ts
@@ -0,0 +1,96 @@
+import {
+ vendorPQSubmissions,
+ vendors,
+ projects,
+ users,
+ vendorInvestigations
+} from "@/db/schema"
+import { CustomColumnMapping } from "../filter-columns"
+
+/**
+ * Helper function to create custom column mapping for PQ submissions
+ */
+export function createPQFilterMapping(): CustomColumnMapping {
+ return {
+ // PQ 제출 관련
+ pqNumber: { table: vendorPQSubmissions, column: "pqNumber" },
+ status: { table: vendorPQSubmissions, column: "status" },
+ type: { table: vendorPQSubmissions, column: "type" },
+ createdAt: { table: vendorPQSubmissions, column: "createdAt" },
+ updatedAt: { table: vendorPQSubmissions, column: "updatedAt" },
+ submittedAt: { table: vendorPQSubmissions, column: "submittedAt" },
+ approvedAt: { table: vendorPQSubmissions, column: "approvedAt" },
+ rejectedAt: { table: vendorPQSubmissions, column: "rejectedAt" },
+
+ // 협력업체 관련
+ vendorName: { table: vendors, column: "vendorName" },
+ vendorCode: { table: vendors, column: "vendorCode" },
+ taxId: { table: vendors, column: "taxId" },
+ vendorStatus: { table: vendors, column: "status" },
+
+ // 프로젝트 관련
+ projectName: { table: projects, column: "name" },
+ projectCode: { table: projects, column: "code" },
+
+ // 요청자 관련
+ requesterName: { table: users, column: "name" },
+ requesterEmail: { table: users, column: "email" },
+
+ // 실사 관련
+ evaluationResult: { table: vendorInvestigations, column: "evaluationResult" },
+ evaluationType: { table: vendorInvestigations, column: "evaluationType" },
+ investigationStatus: { table: vendorInvestigations, column: "investigationStatus" },
+ investigationAddress: { table: vendorInvestigations, column: "investigationAddress" },
+ qmManagerId: { table: vendorInvestigations, column: "qmManagerId" },
+ }
+}
+
+/**
+ * PQ 관련 조인 테이블들
+ */
+export function getPQJoinedTables() {
+ return {
+ vendors,
+ projects,
+ users,
+ vendorInvestigations,
+ }
+}
+
+/**
+ * 직접 컬럼 참조 방식의 매핑 (더 타입 안전)
+ */
+export function createPQDirectColumnMapping(): CustomColumnMapping {
+ return {
+ // PQ 제출 관련 - 직접 컬럼 참조
+ pqNumber: vendorPQSubmissions.pqNumber,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+
+ // 협력업체 관련
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
+ vendorStatus: vendors.status,
+
+ // 프로젝트 관련
+ projectName: projects.name,
+ projectCode: projects.code,
+
+ // 요청자 관련
+ requesterName: users.name,
+ requesterEmail: users.email,
+
+ // 실사 관련
+ evaluationResult: vendorInvestigations.evaluationResult,
+ evaluationType: vendorInvestigations.evaluationType,
+ investigationStatus: vendorInvestigations.investigationStatus,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ qmManagerId: vendorInvestigations.qmManagerId,
+ }
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
new file mode 100644
index 00000000..03045537
--- /dev/null
+++ b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import * as React from "react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+interface CancelInvestigationDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: () => Promise<void>
+ selectedCount: number
+}
+
+export function CancelInvestigationDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ selectedCount,
+}: CancelInvestigationDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+
+ async function handleConfirm() {
+ setIsPending(true)
+ try {
+ await onConfirm()
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>실사 의뢰 취소</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedCount}개 협력업체의 실사 의뢰를 취소하시겠습니까?
+ 계획 상태인 실사만 취소할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleConfirm}
+ disabled={isPending}
+ >
+ {isPending ? "처리 중..." : "실사 의뢰 취소"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/feature-flags-provider.tsx b/lib/pq/pq-review-table-new/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/pq/pq-review-table-new/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/pq/pq-review-table-new/pq-container.tsx b/lib/pq/pq-review-table-new/pq-container.tsx
new file mode 100644
index 00000000..ebe46809
--- /dev/null
+++ b/lib/pq/pq-review-table-new/pq-container.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { getPQSubmissions } from "../service"
+import { PQSubmissionsTable } from "./vendors-table"
+import { PQFilterSheet } from "./pq-filter-sheet"
+
+interface PQContainerProps {
+ // Promise.all로 감싼 promises를 받음
+ promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]>
+ // 컨테이너 클래스명 (옵션)
+ className?: string
+}
+
+export default function PQContainer({
+ promises,
+ className
+}: PQContainerProps) {
+ const searchParams = useSearchParams()
+
+ // Whether the filter panel is open
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false)
+
+ // Container wrapper의 위치를 측정하기 위한 ref
+ const containerRef = useRef<HTMLDivElement>(null)
+ const [containerTop, setContainerTop] = useState(0)
+
+ // Container 위치 측정 함수 - top만 측정
+ const updateContainerBounds = useCallback(() => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect()
+ setContainerTop(rect.top)
+ }
+ }, [])
+
+ // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트
+ useEffect(() => {
+ updateContainerBounds()
+
+ const handleResize = () => {
+ updateContainerBounds()
+ }
+
+ window.addEventListener('resize', handleResize)
+ window.addEventListener('scroll', updateContainerBounds)
+
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ window.removeEventListener('scroll', updateContainerBounds)
+ }
+ }, [updateContainerBounds])
+
+ // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달
+ const handleSearch = () => {
+ // Close the panel after search
+ setIsFilterPanelOpen(false)
+ }
+
+ // Get active filter count for UI display (서버 사이드 필터만 계산)
+ const getActiveFilterCount = () => {
+ try {
+ // 새로운 이름 우선, 기존 이름도 지원
+ const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch (e) {
+ return 0
+ }
+ }
+
+ // Filter panel width
+ const FILTER_PANEL_WIDTH = 400;
+
+ return (
+ <>
+ {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */}
+ <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)`
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <PQFilterSheet
+ 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">
+ {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */}
+ <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"/>
+ }
+ {getActiveFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* Table Content Area */}
+ <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
+ <div className="h-full w-full">
+ {/* Promise를 직접 전달 - Items와 동일한 패턴 */}
+ <PQSubmissionsTable promises={promises} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/pq-filter-sheet.tsx b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
new file mode 100644
index 00000000..979f25a2
--- /dev/null
+++ b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
@@ -0,0 +1,651 @@
+"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)
+
+// PQ 필터 스키마 정의
+const pqFilterSchema = z.object({
+ requesterName: z.string().optional(),
+ pqNumber: z.string().optional(),
+ vendorName: z.string().optional(),
+ status: z.string().optional(),
+ evaluationResult: z.string().optional(),
+ createdAtRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// PQ 상태 옵션 정의
+const pqStatusOptions = [
+ { value: "REQUESTED", label: "요청됨" },
+ { value: "IN_PROGRESS", label: "진행 중" },
+ { value: "SUBMITTED", label: "제출됨" },
+ { value: "APPROVED", label: "승인됨" },
+ { value: "REJECTED", label: "거부됨" },
+]
+
+// 평가 결과 옵션 정의
+const evaluationResultOptions = [
+ { value: "APPROVED", label: "승인" },
+ { value: "SUPPLEMENT", label: "보완" },
+ { value: "REJECTED", label: "불가" },
+]
+
+type PQFilterFormValues = z.infer<typeof pqFilterSchema>
+
+interface PQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+export function PQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: PQFilterSheetProps) {
+ 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 상태 관리 - 파라미터명을 'pqBasicFilters'로 변경
+ 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<PQFilterFormValues>({
+ resolver: zodResolver(pqFilterSchema),
+ defaultValues: {
+ requesterName: "",
+ pqNumber: "",
+ vendorName: "",
+ status: "",
+ evaluationResult: "",
+ createdAtRange: {
+ 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 === "createdAt" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.createdAtRange = {
+ 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])
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+// 폼 제출 핸들러 - 수동 URL 업데이트 버전
+async function onSubmit(data: PQFilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.requesterName?.trim()) {
+ newFilters.push({
+ id: "requesterName",
+ value: data.requesterName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.pqNumber?.trim()) {
+ newFilters.push({
+ id: "pqNumber",
+ value: data.pqNumber.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.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.evaluationResult?.trim()) {
+ newFilters.push({
+ id: "evaluationResult",
+ value: data.evaluationResult.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // 생성일 범위 추가
+ if (data.createdAtRange?.from) {
+ newFilters.push({
+ id: "createdAt",
+ value: [
+ data.createdAtRange.from.toISOString().split('T')[0],
+ data.createdAtRange.to ? data.createdAtRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean),
+ type: "date",
+ operator: "isBetween",
+ rowId: generateId()
+ })
+ }
+
+ // 수동으로 URL 업데이트 (nuqs 대신)
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 기존 필터 관련 파라미터 제거
+ params.delete('basicFilters');
+ params.delete('pqBasicFilters');
+ params.delete('basicJoinOperator');
+ params.delete('pqBasicJoinOperator');
+ 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);
+
+ // 페이지 완전 새로고침으로 서버 렌더링 강제
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ if (onSearch) {
+ console.log("Calling onSearch...");
+ onSearch();
+ }
+
+ console.log("=== PQ Filter Submit Complete ===");
+ } catch (error) {
+ console.error("PQ 필터 적용 오류:", error);
+ }
+ })
+}
+
+ // 필터 초기화 핸들러
+ // 필터 초기화 핸들러
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ requesterName: "",
+ pqNumber: "",
+ vendorName: "",
+ status: "",
+ evaluationResult: "",
+ createdAtRange: { from: undefined, to: undefined },
+ });
+
+ console.log("=== PQ 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('pqBasicFilters');
+ params.delete('basicJoinOperator');
+ params.delete('pqBasicJoinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ console.log("Reset URL:", newUrl);
+
+ // 페이지 완전 새로고침
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("PQ 필터 초기화 완료");
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("PQ 필터 초기화 오류:", 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">PQ 검색 필터</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="requesterName"
+ 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("requesterName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PQ 번호 */}
+ <FormField
+ control={form.control}
+ name="pqNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 번호</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="PQ 번호 입력"
+ {...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("pqNumber", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </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")}
+ 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("vendorName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PQ 상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 상태</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="PQ 상태 선택" />
+ {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>
+ {pqStatusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 결과 */}
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 결과</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="평가 결과 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("evaluationResult", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {evaluationResultOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PQ 생성일 */}
+ <FormField
+ control={form.control}
+ name="createdAtRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 생성일</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder="PQ 생성일 범위를 선택하세요"
+ 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("createdAtRange", { 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"
+ >
+ 초기화
+ </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/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
new file mode 100644
index 00000000..d5588be4
--- /dev/null
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -0,0 +1,331 @@
+"use client"
+
+import * as React from "react"
+import { CalendarIcon } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { format } from "date-fns"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} 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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { UserCombobox } from "./user-combobox"
+import { getQMManagers } from "@/lib/pq/service"
+
+// QM 사용자 타입
+interface QMUser {
+ id: number
+ name: string
+ email: string
+ department?: string
+}
+
+const requestInvestigationFormSchema = z.object({
+ evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"], {
+ required_error: "평가 유형을 선택해주세요.",
+ }),
+ qmManagerId: z.number({
+ required_error: "QM 담당자를 선택해주세요.",
+ }),
+ forecastedAt: z.date({
+ required_error: "실사 예정일을 선택해주세요.",
+ }),
+ investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."),
+ investigationMethod: z.string().optional(),
+ investigationNotes: z.string().optional(),
+})
+
+type RequestInvestigationFormValues = z.infer<typeof requestInvestigationFormSchema>
+
+interface RequestInvestigationDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: {
+ evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationMethod?: string,
+ investigationNotes?: string
+ }) => Promise<void>
+ selectedCount: number
+ // 선택된 행에서 가져온 초기값
+ initialData?: {
+ evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT",
+ qmManagerId?: number,
+ forecastedAt?: Date,
+ investigationAddress?: string,
+ investigationMethod?: string,
+ investigationNotes?: string
+ }
+}
+
+export function RequestInvestigationDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ selectedCount,
+ initialData,
+}: RequestInvestigationDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [qmManagers, setQMManagers] = React.useState<QMUser[]>([])
+ const [isLoadingManagers, setIsLoadingManagers] = React.useState(false)
+
+ // form 객체 생성 시 initialData 활용
+ const form = useForm<RequestInvestigationFormValues>({
+ resolver: zodResolver(requestInvestigationFormSchema),
+ defaultValues: {
+ evaluationType: initialData?.evaluationType || "SITE_AUDIT",
+ qmManagerId: initialData?.qmManagerId || undefined,
+ forecastedAt: initialData?.forecastedAt || undefined,
+ investigationAddress: initialData?.investigationAddress || "",
+ investigationMethod: initialData?.investigationMethod || "",
+ investigationNotes: initialData?.investigationNotes || "",
+ },
+ })
+
+ // Dialog가 열릴 때마다 초기값으로 폼 재설정
+ React.useEffect(() => {
+ if (isOpen) {
+ form.reset({
+ evaluationType: initialData?.evaluationType || "SITE_AUDIT",
+ qmManagerId: initialData?.qmManagerId || undefined,
+ forecastedAt: initialData?.forecastedAt || undefined,
+ investigationAddress: initialData?.investigationAddress || "",
+ investigationMethod: initialData?.investigationMethod || "",
+ investigationNotes: initialData?.investigationNotes || "",
+ });
+ }
+ }, [isOpen, initialData, form]);
+
+ // Dialog가 열릴 때 QM 담당자 목록 로드
+ React.useEffect(() => {
+ if (isOpen && qmManagers.length === 0) {
+ const loadQMManagers = async () => {
+ setIsLoadingManagers(true)
+ try {
+ const result = await getQMManagers()
+ if (result.success && result.data) {
+ setQMManagers(result.data)
+ }
+ } catch (error) {
+ console.error("QM 담당자 로드 오류:", error)
+ } finally {
+ setIsLoadingManagers(false)
+ }
+ }
+
+ loadQMManagers()
+ }
+ }, [isOpen, qmManagers.length])
+
+ async function handleSubmit(data: RequestInvestigationFormValues) {
+ setIsPending(true)
+ try {
+ await onSubmit(data)
+ } finally {
+ setIsPending(false)
+ form.reset()
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>실사 의뢰</DialogTitle>
+ <DialogDescription>
+ {selectedCount}개 협력업체에 대한 실사를 의뢰합니다. 실사 관련 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="evaluationType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 유형</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isPending}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 유형을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem>
+ <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="qmManagerId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 담당자</FormLabel>
+ <FormControl>
+ <UserCombobox
+ users={qmManagers}
+ value={field.value}
+ onChange={field.onChange}
+ placeholder={isLoadingManagers ? "담당자 로딩 중..." : "담당자 선택..."}
+ disabled={isPending || isLoadingManagers}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="forecastedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 예정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ disabled={isPending}
+ >
+ {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()}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="investigationAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 장소</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사가 진행될 주소를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="investigationMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 방법 (선택사항)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="실사 방법을 입력하세요"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>특이사항 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사 관련 특이사항을 입력하세요"
+ className="resize-none min-h-[60px]"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || isLoadingManagers}>
+ {isPending ? "처리 중..." : "실사 의뢰"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/send-results-dialog.tsx b/lib/pq/pq-review-table-new/send-results-dialog.tsx
new file mode 100644
index 00000000..0a423f7f
--- /dev/null
+++ b/lib/pq/pq-review-table-new/send-results-dialog.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import * as React from "react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+interface SendResultsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: () => Promise<void>
+ selectedCount: number
+}
+
+export function SendResultsDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ selectedCount,
+}: SendResultsDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+
+ async function handleConfirm() {
+ setIsPending(true)
+ try {
+ await onConfirm()
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>실사 결과 발송</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedCount}개 협력업체의 실사 결과를 발송하시겠습니까?
+ 완료된 실사만 결과를 발송할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={handleConfirm}
+ disabled={isPending}
+ >
+ {isPending ? "처리 중..." : "결과 발송"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/user-combobox.tsx b/lib/pq/pq-review-table-new/user-combobox.tsx
new file mode 100644
index 00000000..0fb0e4c8
--- /dev/null
+++ b/lib/pq/pq-review-table-new/user-combobox.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import { Check, ChevronsUpDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+interface User {
+ id: number
+ name: string
+ email: string
+ department?: string
+}
+
+interface UserComboboxProps {
+ users: User[]
+ value: number | null
+ onChange: (value: number) => void
+ placeholder?: string
+ disabled?: boolean
+}
+
+export function UserCombobox({
+ users,
+ value,
+ onChange,
+ placeholder = "담당자 선택...",
+ disabled = false
+}: UserComboboxProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState("")
+
+ const selectedUser = React.useMemo(() => {
+ return users.find(user => user.id === value)
+ }, [users, value])
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className={cn(
+ "w-full justify-between",
+ !value && "text-muted-foreground"
+ )}
+ disabled={disabled}
+ >
+ {selectedUser ? (
+ <span className="flex items-center">
+ <span className="font-medium">{selectedUser.name}</span>
+ {selectedUser.department && (
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({selectedUser.department})
+ </span>
+ )}
+ </span>
+ ) : (
+ placeholder
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="담당자 검색..."
+ value={inputValue}
+ onValueChange={setInputValue}
+ />
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup className="max-h-[200px] overflow-y-auto">
+ {users.map((user) => (
+ <CommandItem
+ key={user.id}
+ value={user.email} // 이메일을 value로 사용
+ onSelect={() => {
+ onChange(user.id)
+ setOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ value === user.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col truncate">
+ <div className="flex items-center">
+ <span className="font-medium">{user.name}</span>
+ {user.department && (
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({user.department})
+ </span>
+ )}
+ </div>
+ <span className="text-xs text-muted-foreground truncate">
+ {user.email}
+ </span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
new file mode 100644
index 00000000..0491f1dc
--- /dev/null
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -0,0 +1,640 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Eye, PaperclipIcon, 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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { useRouter } from "next/navigation"
+
+// PQ 제출 타입 정의
+export interface PQSubmission {
+ id: number
+ pqNumber: string
+ type: string
+ status: string
+ requesterName: string | null // 요청자 이름
+ createdAt: Date
+ updatedAt: Date
+ submittedAt: Date | null
+ approvedAt: Date | null
+ rejectedAt: Date | null
+ rejectReason: string | null
+ vendorId: number
+ vendorName: string
+ vendorCode: string
+ taxId: string
+ vendorStatus: string
+ projectId: number | null
+ projectName: string | null
+ projectCode: string | null
+ answerCount: number
+ attachmentCount: number
+ pqStatus: string
+ pqTypeLabel: string
+ investigation: {
+ id: number
+ investigationStatus: string
+ requesterName: string | null // 실사 요청자 이름
+ evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT" | null
+ qmManagerId: number | null
+ qmManagerName: string | null // QM 담당자 이름
+ qmManagerEmail: string | null // QM 담당자 이메일
+ investigationAddress: string | null
+ investigationMethod: string | null
+ scheduledStartAt: Date | null
+ scheduledEndAt: Date | null
+ requestedAt: Date | null
+ confirmedAt: Date | null
+ completedAt: Date | null
+ forecastedAt: Date | null
+ evaluationScore: number | null
+ evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | null
+ investigationNotes: string | null
+ } | null
+ // 통합 상태를 위한 새 필드
+ combinedStatus: {
+ status: string
+ label: string
+ variant: "default" | "outline" | "secondary" | "destructive" | "success"
+ }
+}
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQSubmission> | null>>;
+ router: NextRouter;
+}
+
+// 상태에 따른 Badge 변형 결정 함수
+function getStatusBadge(status: string) {
+ switch (status) {
+ case "REQUESTED":
+ return <Badge variant="outline">요청됨</Badge>
+ case "IN_PROGRESS":
+ return <Badge variant="secondary">진행 중</Badge>
+ case "SUBMITTED":
+ return <Badge>제출됨</Badge>
+ case "APPROVED":
+ return <Badge variant="success">승인됨</Badge>
+ case "REJECTED":
+ return <Badge variant="destructive">거부됨</Badge>
+ default:
+ return <Badge variant="outline">{status}</Badge>
+ }
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<PQSubmission>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<PQSubmission> = {
+ 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) 일반 컬럼들
+ // --------------------------
+ // --------------------------------------
+
+ const pqNoColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "pqNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="PQ No." />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.getValue("pqNumber")}</span>
+ </div>
+ ),
+ }
+
+ // 협력업체 컬럼
+ const vendorColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="협력업체" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.getValue("vendorName")}</span>
+ <span className="text-xs text-muted-foreground">{row.original.vendorCode ? row.original.vendorCode : "-"}/{row.original.taxId}</span>
+ </div>
+ ),
+ }
+
+ // PQ 유형 컬럼
+ const typeColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "type",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="PQ 유형" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex items-center">
+ <Badge variant={row.original.type === "PROJECT" ? "default" : "outline"}>
+ {row.original.pqTypeLabel}
+ </Badge>
+ </div>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ }
+
+ // 프로젝트 컬럼
+ const projectColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "projectName",
+ header: ({ column }) => (
+ <DataTableColumnHeader 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>{projectName}</span>
+ {projectCode && (
+ <span className="text-xs text-muted-foreground">{projectCode}</span>
+ )}
+ </div>
+ )
+ },
+ }
+
+ // 상태 컬럼
+ const statusColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "combinedStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="진행현황" />
+ ),
+ cell: ({ row }) => {
+ const combinedStatus = getCombinedStatus(row.original);
+ return <Badge variant={combinedStatus.variant}>{combinedStatus.label}</Badge>;
+ },
+ filterFn: (row, id, value) => {
+ const combinedStatus = getCombinedStatus(row.original);
+ return value.includes(combinedStatus.status);
+ },
+ };
+
+ // PQ 상태와 실사 상태를 결합하는 헬퍼 함수
+ function getCombinedStatus(submission: PQSubmission) {
+ // PQ가 승인되지 않은 경우, PQ 상태를 우선 표시
+ if (submission.status !== "APPROVED") {
+ switch (submission.status) {
+ case "REQUESTED":
+ return { status: "PQ_REQUESTED", label: "PQ 요청됨", variant: "outline" as const };
+ case "IN_PROGRESS":
+ return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const };
+ case "SUBMITTED":
+ return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const };
+ case "REJECTED":
+ return { status: "PQ_REJECTED", label: "PQ 거부됨", variant: "destructive" as const };
+ default:
+ return { status: submission.status, label: submission.status, variant: "outline" as const };
+ }
+ }
+
+ // PQ가 승인되었지만 실사가 없는 경우
+ if (!submission.investigation) {
+ return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
+ }
+
+ // PQ가 승인되고 실사가 있는 경우
+ switch (submission.investigation.investigationStatus) {
+ case "PLANNED":
+ return { status: "INVESTIGATION_PLANNED", label: "실사 계획됨", variant: "outline" as const };
+ case "IN_PROGRESS":
+ return { status: "INVESTIGATION_IN_PROGRESS", label: "실사 진행 중", variant: "secondary" as const };
+ case "COMPLETED":
+ // 실사 완료 후 평가 결과에 따라 다른 상태 표시
+ if (submission.investigation.evaluationResult) {
+ switch (submission.investigation.evaluationResult) {
+ case "APPROVED":
+ return { status: "INVESTIGATION_APPROVED", label: "실사 승인", variant: "success" as const };
+ case "SUPPLEMENT":
+ return { status: "INVESTIGATION_SUPPLEMENT", label: "실사 보완필요", variant: "secondary" as const };
+ case "REJECTED":
+ return { status: "INVESTIGATION_REJECTED", label: "실사 불가", variant: "destructive" as const };
+ default:
+ return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
+ }
+ }
+ return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
+ case "CANCELED":
+ return { status: "INVESTIGATION_CANCELED", label: "실사 취소됨", variant: "destructive" as const };
+ default:
+ return {
+ status: `INVESTIGATION_${submission.investigation.investigationStatus}`,
+ label: `실사 ${submission.investigation.investigationStatus}`,
+ variant: "outline" as const
+ };
+ }
+ }
+
+ const evaluationTypeColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "evaluationType",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="평가 유형" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.evaluationType) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ switch (investigation.evaluationType) {
+ case "SITE_AUDIT":
+ return <Badge variant="outline">실사의뢰평가</Badge>;
+ case "QM_SELF_AUDIT":
+ return <Badge variant="secondary">QM자체평가</Badge>;
+ default:
+ return <span>{investigation.evaluationType}</span>;
+ }
+ },
+ filterFn: (row, id, value) => {
+ const investigation = row.original.investigation;
+ if (!investigation || !investigation.evaluationType) return value.includes("null");
+ return value.includes(investigation.evaluationType);
+ },
+ };
+
+
+ const evaluationResultColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "evaluationResult",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="평가 결과" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.evaluationResult) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ switch (investigation.evaluationResult) {
+ case "APPROVED":
+ return <Badge variant="success">승인</Badge>;
+ case "SUPPLEMENT":
+ return <Badge variant="secondary">보완</Badge>;
+ case "REJECTED":
+ return <Badge variant="destructive">불가</Badge>;
+ default:
+ return <span>{investigation.evaluationResult}</span>;
+ }
+ },
+ filterFn: (row, id, value) => {
+ const investigation = row.original.investigation;
+ if (!investigation || !investigation.evaluationResult) return value.includes("null");
+ return value.includes(investigation.evaluationResult);
+ },
+ };
+
+ // 답변 수 컬럼
+ const answerCountColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "answerCount",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="답변 수" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex items-center gap-2">
+ <span>{row.original.answerCount}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationAddressColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationAddress",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="실사 주소" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.evaluationType) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{investigation.investigationAddress}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationNotesColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationNotes",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="QM 의견" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.investigationNotes) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{investigation.investigationNotes}</span>
+ </div>
+ )
+ },
+ }
+
+
+ const investigationRequestedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationRequestedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="실사 의뢰일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.requestedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.requestedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+
+ const investigationForecastedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationForecastedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="실사 예정일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.forecastedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.forecastedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationConfirmedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationConfirmedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="실사 확정일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.confirmedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.confirmedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationCompletedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationCompletedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="실제 실사일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.completedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.completedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+ // 제출일 컬럼
+ const createdAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="PQ 전송일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.original.createdAt as Date
+ return formatDate(dateVal, 'KR')
+ },
+ }
+
+ // 제출일 컬럼
+ const submittedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="PQ 회신일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.original.submittedAt as Date
+ return dateVal ? formatDate(dateVal, 'KR') : "-"
+ },
+ }
+
+ // 승인/거부일 컬럼
+ const approvalDateColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "approvedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="PQ 승인/거부일" />
+ ),
+ cell: ({ row }) => {
+ if (row.original.approvedAt) {
+ return <span className="text-green-600">{formatDate(row.original.approvedAt)}</span>
+ }
+ if (row.original.rejectedAt) {
+ return <span className="text-red-600">{formatDate(row.original.rejectedAt)}</span>
+ }
+ return "-"
+ },
+ }
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<PQSubmission> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const pq = row.original
+ const isSubmitted = pq.status === "SUBMITTED"
+ const reviewUrl = `/evcp/pq_new/${pq.vendorId}/${pq.id}`
+
+ 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={() => {
+ router.push(reviewUrl);
+ }}
+ >
+ {isSubmitted ? (
+ <>
+ <FileEdit className="mr-2 h-4 w-4" />
+ 검토
+ </>
+ ) : (
+ <>
+ <Eye className="mr-2 h-4 w-4" />
+ 보기
+ </>
+ )}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // 요청자 컬럼 추가
+const requesterColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "requesterName",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="PQ/실사 요청자" />
+ ),
+ cell: ({ row }) => {
+ // PQ 요청자와 실사 요청자를 모두 표시
+ const pqRequesterName = row.original.requesterName;
+ const investigationRequesterName = row.original.investigation?.requesterName;
+
+ // 상태에 따라 적절한 요청자 표시
+ const status = getCombinedStatus(row.original).status;
+
+ if (status.startsWith('INVESTIGATION_') && investigationRequesterName) {
+ return <span>{investigationRequesterName}</span>;
+ }
+
+ return pqRequesterName
+ ? <span>{pqRequesterName}</span>
+ : <span className="text-muted-foreground">-</span>;
+ },
+};
+const qmManagerColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "qmManager",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="QM 담당자" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.qmManagerName) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex flex-col">
+ <span>{investigation.qmManagerName}</span>
+ {investigation.qmManagerEmail && (
+ <span className="text-xs text-muted-foreground">{investigation.qmManagerEmail}</span>
+ )}
+ </div>
+ );
+ },
+};
+
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ statusColumn, // 통합된 진행현황 컬럼
+ pqNoColumn,
+ vendorColumn,
+ investigationAddressColumn,
+ typeColumn,
+ projectColumn,
+ createdAtColumn,
+ submittedAtColumn,
+ approvalDateColumn,
+ answerCountColumn,
+ evaluationTypeColumn, // 평가 유형 컬럼
+ investigationForecastedAtColumn,
+ investigationRequestedAtColumn,
+ investigationConfirmedAtColumn,
+ investigationCompletedAtColumn,
+ evaluationResultColumn, // 평가 결과 컬럼
+ requesterColumn,
+ qmManagerColumn,
+ investigationNotesColumn,
+ actionsColumn,
+ ];
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..abba72d1
--- /dev/null
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,351 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, ClipboardCheck, X, Send } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { PQSubmission } from "./vendors-table-columns"
+import {
+ requestInvestigationAction,
+ cancelInvestigationAction,
+ sendInvestigationResultsAction,
+ getFactoryLocationAnswer
+} from "@/lib/pq/service"
+import { RequestInvestigationDialog } from "./request-investigation-dialog"
+import { CancelInvestigationDialog } from "./cancel-investigation-dialog"
+import { SendResultsDialog } from "./send-results-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<PQSubmission>
+}
+
+interface InvestigationInitialData {
+ evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT";
+ qmManagerId?: number;
+ forecastedAt?: Date;
+ createdAt?: Date;
+ investigationAddress?: string;
+ investigationNotes?: string;
+}
+
+export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // Dialog 상태 관리
+ const [isRequestDialogOpen, setIsRequestDialogOpen] = React.useState(false)
+ const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false)
+ const [isSendResultsDialogOpen, setIsSendResultsDialogOpen] = React.useState(false)
+
+ // 초기 데이터 상태
+ const [dialogInitialData, setDialogInitialData] = React.useState<InvestigationInitialData | undefined>(undefined)
+
+ // 실사 의뢰 대화상자 열기 핸들러
+// 실사 의뢰 대화상자 열기 핸들러
+const handleOpenRequestDialog = async () => {
+ setIsLoading(true);
+ const initialData: InvestigationInitialData = {};
+
+ try {
+ // 선택된 행이 정확히 1개인 경우에만 초기값 설정
+ if (selectedRows.length === 1) {
+ const row = selectedRows[0].original;
+
+ // 승인된 PQ이고 아직 실사가 없는 경우
+ if (row.status === "APPROVED" && !row.investigation) {
+ // Factory Location 정보 가져오기
+ const locationResponse = await getFactoryLocationAnswer(
+ row.vendorId,
+ row.projectId
+ );
+
+ // 기본 주소 설정 - Factory Location 응답 또는 fallback
+ let defaultAddress = "";
+ if (locationResponse.success && locationResponse.factoryLocation) {
+ defaultAddress = locationResponse.factoryLocation;
+ } else {
+ // Factory Location을 찾지 못한 경우 fallback
+ defaultAddress = row.taxId ?
+ `${row.vendorName} 사업장 (${row.taxId})` :
+ `${row.vendorName} 사업장`;
+ }
+
+ // 이미 같은 회사에 대한 다른 실사가 있는지 확인
+ const existingInvestigations = table.getFilteredRowModel().rows
+ .map(r => r.original)
+ .filter(r =>
+ r.vendorId === row.vendorId &&
+ r.investigation !== null
+ );
+
+ // 같은 업체의 이전 실사 기록이 있다면 참고하되, 주소는 Factory Location 사용
+ if (existingInvestigations.length > 0) {
+ // 날짜 기준으로 정렬하여 가장 최근 것을 가져옴
+ const latestInvestigation = existingInvestigations.sort((a, b) => {
+ const dateA = a.investigation?.createdAt || new Date(0);
+ const dateB = b.investigation?.createdAt || new Date(0);
+ return (dateB as Date).getTime() - (dateA as Date).getTime();
+ })[0].investigation;
+
+ if (latestInvestigation) {
+ initialData.evaluationType = latestInvestigation.evaluationType || "SITE_AUDIT";
+ initialData.qmManagerId = latestInvestigation.qmManagerId || undefined;
+ initialData.investigationAddress = defaultAddress; // Factory Location 사용
+
+ // 날짜는 미래로 설정
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후
+ initialData.forecastedAt = futureDate;
+ }
+ } else {
+ // 기본값 설정
+ initialData.evaluationType = "SITE_AUDIT";
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후
+ initialData.forecastedAt = futureDate;
+ initialData.investigationAddress = defaultAddress; // Factory Location 사용
+ }
+ }
+ // 실사가 이미 있고 수정하는 경우
+ else if (row.investigation) {
+ initialData.evaluationType = row.investigation.evaluationType || "SITE_AUDIT";
+ initialData.qmManagerId = row.investigation.qmManagerId !== null ?
+ row.investigation.qmManagerId : undefined;
+ initialData.forecastedAt = row.investigation.forecastedAt || new Date();
+ initialData.investigationAddress = row.investigation.investigationAddress || "";
+ initialData.investigationNotes = row.investigation.investigationNotes || "";
+ }
+ }
+ } catch (error) {
+ console.error("초기 데이터 로드 중 오류:", error);
+ toast.error("초기 데이터 로드 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+
+ // 초기 데이터 설정 및 대화상자 열기
+ setDialogInitialData(Object.keys(initialData).length > 0 ? initialData : undefined);
+ setIsRequestDialogOpen(true);
+ }
+};
+ // 실사 의뢰 요청 처리
+ const handleRequestInvestigation = async (formData: {
+ evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationNotes?: string
+ }) => {
+ setIsLoading(true)
+ try {
+ // 승인된 PQ 제출만 필터링
+ const approvedPQs = selectedRows.filter(row =>
+ row.original.status === "APPROVED" && !row.original.investigation
+ )
+
+ if (approvedPQs.length === 0) {
+ toast.error("실사를 의뢰할 수 있는 업체가 없습니다. 승인된 PQ 제출만 실사 의뢰가 가능합니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await requestInvestigationAction(
+ approvedPQs.map(row => row.original.id),
+ formData
+ )
+
+ if (result.success) {
+ toast.success(`${result.count}개 업체에 대한 ${formData.evaluationType === "SITE_AUDIT" ? "실사의뢰평가" : "QM자체평가"}가 의뢰되었습니다.`)
+ window.location.reload()
+ } else {
+ toast.error(result.error || "실사 의뢰 처리 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 의뢰 중 오류 발생:", error)
+ toast.error("실사 의뢰 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ setIsRequestDialogOpen(false)
+ setDialogInitialData(undefined); // 초기 데이터 초기화
+ }
+ }
+
+ const handleCloseRequestDialog = () => {
+ setIsRequestDialogOpen(false);
+ setDialogInitialData(undefined);
+ };
+
+
+ // 실사 의뢰 취소 처리
+ const handleCancelInvestigation = async () => {
+ setIsLoading(true)
+ try {
+ // 실사가 계획됨 상태인 PQ만 필터링
+ const plannedInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "PLANNED"
+ )
+
+ if (plannedInvestigations.length === 0) {
+ toast.error("취소할 수 있는 실사 의뢰가 없습니다. 계획 상태의 실사만 취소할 수 있습니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await cancelInvestigationAction(
+ plannedInvestigations.map(row => row.original.investigation!.id)
+ )
+
+ if (result.success) {
+ toast.success(`${result.count}개 업체에 대한 실사 의뢰가 취소되었습니다.`)
+ window.location.reload()
+ } else {
+ toast.error(result.error || "실사 취소 처리 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 의뢰 취소 중 오류 발생:", error)
+ toast.error("실사 의뢰 취소 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ setIsCancelDialogOpen(false)
+ }
+ }
+
+ // 실사 결과 발송 처리
+ const handleSendInvestigationResults = async () => {
+ setIsLoading(true)
+ try {
+ // 완료된 실사만 필터링
+ const completedInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED"
+ )
+
+ if (completedInvestigations.length === 0) {
+ toast.error("발송할 실사 결과가 없습니다. 완료된 실사만 결과를 발송할 수 있습니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await sendInvestigationResultsAction(
+ completedInvestigations.map(row => row.original.investigation!.id)
+ )
+
+ if (result.success) {
+ toast.success(`${result.count}개 업체에 대한 실사 결과가 발송되었습니다.`)
+ window.location.reload()
+ } else {
+ toast.error(result.error || "실사 결과 발송 처리 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 결과 발송 중 오류 발생:", error)
+ toast.error("실사 결과 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ setIsSendResultsDialogOpen(false)
+ }
+ }
+
+ // 승인된 업체 수 확인
+ const approvedPQsCount = selectedRows.filter(row =>
+ row.original.status === "APPROVED" && !row.original.investigation
+ ).length
+
+ // 계획 상태 실사 수 확인
+ const plannedInvestigationsCount = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "PLANNED"
+ ).length
+
+ // 완료된 실사 수 확인
+ const completedInvestigationsCount = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED"
+ ).length
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 실사 의뢰 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용
+ disabled={isLoading || selectedRows.length === 0}
+ className="gap-2"
+ >
+ <ClipboardCheck className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">실사 의뢰</span>
+ </Button>
+
+ {/* 실사 의뢰 취소 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelDialogOpen(true)}
+ disabled={isLoading || selectedRows.length === 0}
+ className="gap-2"
+ >
+ <X className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">실사 취소</span>
+ </Button>
+
+ {/* 실사 결과 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsSendResultsDialogOpen(true)}
+ disabled={isLoading || selectedRows.length === 0}
+ className="gap-2"
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">결과 발송</span>
+ </Button>
+
+ {/** Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors-pq-submissions",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+
+ {/* 실사 의뢰 Dialog */}
+ <RequestInvestigationDialog
+ isOpen={isRequestDialogOpen}
+ onClose={handleCloseRequestDialog} // 새로운 핸들러로 변경
+ onSubmit={handleRequestInvestigation}
+ selectedCount={approvedPQsCount}
+ initialData={dialogInitialData} // 초기 데이터 전달
+ />
+
+
+ {/* 실사 취소 Dialog */}
+ <CancelInvestigationDialog
+ isOpen={isCancelDialogOpen}
+ onClose={() => setIsCancelDialogOpen(false)}
+ onConfirm={handleCancelInvestigation}
+ selectedCount={plannedInvestigationsCount}
+ />
+
+ {/* 결과 발송 Dialog */}
+ <SendResultsDialog
+ isOpen={isSendResultsDialogOpen}
+ onClose={() => setIsSendResultsDialogOpen(false)}
+ onConfirm={handleSendInvestigationResults}
+ selectedCount={completedInvestigationsCount}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/vendors-table.tsx b/lib/pq/pq-review-table-new/vendors-table.tsx
new file mode 100644
index 00000000..e1c4cefe
--- /dev/null
+++ b/lib/pq/pq-review-table-new/vendors-table.tsx
@@ -0,0 +1,308 @@
+"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 { getPQSubmissions } from "../service"
+import { getColumns, PQSubmission } from "./vendors-table-columns"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { PQFilterSheet } from "./pq-filter-sheet"
+import { cn } from "@/lib/utils"
+// TablePresetManager 관련 import 추가
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { TablePresetManager } from "@/components/data-table/data-table-preset"
+import { useMemo } from "react"
+
+interface PQSubmissionsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]>
+ className?: string
+}
+
+export function PQSubmissionsTable({ promises, className }: PQSubmissionsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQSubmission> | null>(null)
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ // Container wrapper의 위치를 측정하기 위한 ref
+ const containerRef = React.useRef<HTMLDivElement>(null)
+ const [containerTop, setContainerTop] = React.useState(0)
+
+ // Container 위치 측정 함수 - top만 측정
+ 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])
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ // 디버깅용 로그
+ console.log("PQ Table Data:", {
+ dataLength: tableData.data?.length,
+ pageCount: tableData.pageCount,
+ sampleData: tableData.data?.[0]
+ })
+
+ // 초기 설정 정의 (RFQ와 동일한 패턴)
+ 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') || searchParams.get('pqBasicFilters') ?
+ JSON.parse(searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')!) : [],
+ 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: ["actions"] }, // PQ는 actions를 오른쪽에 고정
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ updateClientState,
+ getCurrentSettings,
+ } = useTablePresets<PQSubmission>('pq-submissions-table', initialSettings)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ // PQ 제출 필터링을 위한 필드 정의
+ const filterFields: DataTableFilterField<PQSubmission>[] = [
+ { id: "vendorName", label: "협력업체" },
+ { id: "projectName", label: "프로젝트" },
+ { id: "status", label: "상태" },
+ ]
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<PQSubmission>[] = [
+ { id: "requesterName", label: "요청자명", type: "text" },
+ { id: "pqNumber", label: "PQ 번호", type: "text" },
+ { id: "vendorName", label: "협력업체명", type: "text" },
+ { id: "vendorCode", label: "협력업체 코드", type: "text" },
+ { id: "type", label: "PQ 유형", type: "select", options: [
+ { label: "일반 PQ", value: "GENERAL" },
+ { label: "프로젝트 PQ", value: "PROJECT" },
+ ]},
+ { id: "projectName", label: "프로젝트명", type: "text" },
+ { id: "status", label: "PQ 상태", type: "select", options: [
+ { label: "요청됨", value: "REQUESTED" },
+ { label: "진행 중", value: "IN_PROGRESS" },
+ { label: "제출됨", value: "SUBMITTED" },
+ { label: "승인됨", value: "APPROVED" },
+ { label: "거부됨", value: "REJECTED" },
+ ]},
+ { id: "evaluationResult", label: "평가 결과", type: "select", options: [
+ { label: "승인", value: "APPROVED" },
+ { label: "보완", value: "SUPPLEMENT" },
+ { label: "불가", value: "REJECTED" },
+ ]},
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "submittedAt", label: "제출일", type: "date" },
+ { id: "approvedAt", label: "승인일", type: "date" },
+ { id: "rejectedAt", label: "거부일", type: "date" },
+ ]
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정 (RFQ와 동일한 패턴)
+ 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, // total 추가
+ filterFields, // RFQ와 달리 빈 배열이 아닌 실제 필터 필드 사용
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달
+ const handleSearch = () => {
+ // Close the panel after search
+ setIsFilterPanelOpen(false)
+ }
+
+ // Get active basic filter count
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch (e) {
+ return 0
+ }
+ }
+
+ // Filter panel width
+ const FILTER_PANEL_WIDTH = 400;
+
+ return (
+ <>
+ {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */}
+ <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)`
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <PQFilterSheet
+ 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">
+ {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */}
+ <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>
+
+ {/* Right side info */}
+ <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">
+ {/* DB 기반 테이블 프리셋 매니저 추가 */}
+ <TablePresetManager<PQSubmission>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ {/* 기존 툴바 액션들 */}
+ <VendorsTableToolbarActions table={table} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 6159a307..18d1a5d3 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -1,14 +1,14 @@
"use server"
import db from "@/db/db"
-import { GetPQSchema } from "./validations"
+import { GetPQSchema, GetPQSubmissionsSchema } from "./validations"
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import { getErrorMessage } from "@/lib/handle-error";
import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL} from "drizzle-orm";
import { z } from "zod"
import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
-import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq"
+import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorInvestigations, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq"
import { countPqs, selectPqs } from "./repository";
import { sendEmail } from "../mail/sendEmail";
import { vendorAttachments, vendors } from "@/db/schema/vendors";
@@ -18,8 +18,12 @@ import { randomUUID } from 'crypto';
import { writeFile, mkdir } from 'fs/promises';
import { GetVendorsSchema } from "../vendors/validations";
import { countVendors, selectVendors } from "../vendors/repository";
-import { projects } from "@/db/schema";
+import { projects, users } from "@/db/schema";
import { headers } from 'next/headers';
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { alias } from 'drizzle-orm/pg-core';
+import { createPQFilterMapping, getPQJoinedTables } from "./helper";
/**
* PQ 목록 조회
@@ -374,19 +378,19 @@ export interface ProjectPQ {
export async function getPQProjectsByVendorId(vendorId: number): Promise<ProjectPQ[]> {
const result = await db
.select({
- id: vendorProjectPQs.id,
- projectId: vendorProjectPQs.projectId,
- status: vendorProjectPQs.status,
- submittedAt: vendorProjectPQs.submittedAt,
+ id: vendorPQSubmissions.id,
+ projectId: vendorPQSubmissions.projectId,
+ status: vendorPQSubmissions.status,
+ submittedAt: vendorPQSubmissions.submittedAt,
projectCode: projects.code,
projectName: projects.name,
})
- .from(vendorProjectPQs)
+ .from(vendorPQSubmissions)
.innerJoin(
projects,
- eq(vendorProjectPQs.projectId, projects.id)
+ eq(vendorPQSubmissions.projectId, projects.id)
)
- .where(eq(vendorProjectPQs.vendorId, vendorId))
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
.orderBy(projects.code);
return result;
@@ -659,10 +663,12 @@ export async function savePQAnswersAction(input: SavePQInput) {
*/
export async function submitPQAction({
vendorId,
- projectId
+ projectId,
+ pqSubmissionId
}: {
vendorId: number;
projectId?: number;
+ pqSubmissionId?: number; // 특정 PQ 제출 ID가 있는 경우 사용
}) {
unstable_noStore();
@@ -671,21 +677,21 @@ export async function submitPQAction({
const host = headersList.get('host') || 'localhost:3000';
// 1. 모든 PQ 항목에 대한 응답이 있는지 검증
- const queryConditions = [
+ const answerQueryConditions = [
eq(vendorPqCriteriaAnswers.vendorId, vendorId)
];
// Add projectId condition when it exists
if (projectId !== undefined) {
- queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ answerQueryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
} else {
- queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
+ answerQueryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
}
const pqCriteriaCount = await db
.select({ count: count() })
.from(vendorPqCriteriaAnswers)
- .where(and(...queryConditions));
+ .where(and(...answerQueryConditions));
const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0;
@@ -724,86 +730,116 @@ export async function submitPQAction({
projectName = projectData?.projectName || 'Unknown Project';
}
- // 3. 상태 업데이트
+ // 3. 현재 PQ 제출 상태 확인 및 업데이트
const currentDate = new Date();
+ let existingSubmission;
- if (projectId) {
- // 프로젝트별 PQ인 경우 vendorProjectPQs 테이블 업데이트
- const existingProjectPQ = await db
- .select({ id: vendorProjectPQs.id, status: vendorProjectPQs.status })
- .from(vendorProjectPQs)
+ // 특정 PQ Submission ID가 있는 경우
+ if (pqSubmissionId) {
+ existingSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type
+ })
+ .from(vendorPQSubmissions)
.where(
and(
- eq(vendorProjectPQs.vendorId, vendorId),
- eq(vendorProjectPQs.projectId, projectId)
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
)
)
.then(rows => rows[0]);
- if (existingProjectPQ) {
- // 프로젝트 PQ 상태가 제출 가능한 상태인지 확인
- const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"];
-
- if (!allowedStatuses.includes(existingProjectPQ.status)) {
- return {
- ok: false,
- error: `Cannot submit Project PQ in current status: ${existingProjectPQ.status}`
- };
- }
-
- // Update existing project PQ status
- await db
- .update(vendorProjectPQs)
- .set({
- status: "SUBMITTED",
- submittedAt: currentDate,
- updatedAt: currentDate,
- })
- .where(eq(vendorProjectPQs.id, existingProjectPQ.id));
+ if (!existingSubmission) {
+ return { ok: false, error: "PQ submission not found or access denied" };
+ }
+ }
+ // ID가 없는 경우 vendorId와 projectId로 조회
+ else {
+ const pqType = projectId ? "PROJECT" : "GENERAL";
+
+ const submissionQueryConditions = [
+ eq(vendorPQSubmissions.vendorId, vendorId),
+ eq(vendorPQSubmissions.type, pqType)
+ ];
+
+ if (projectId) {
+ submissionQueryConditions.push(eq(vendorPQSubmissions.projectId, projectId));
} else {
- // Project PQ entry doesn't exist, create one
- await db
- .insert(vendorProjectPQs)
- .values({
- vendorId,
- projectId,
- status: "SUBMITTED",
- submittedAt: currentDate,
- createdAt: currentDate,
- updatedAt: currentDate,
- });
+ submissionQueryConditions.push(isNull(vendorPQSubmissions.projectId));
}
- } else {
- // 일반 PQ인 경우 협력업체 상태 검증 및 업데이트
- const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
- if (!allowedStatuses.includes(vendor.status)) {
+ existingSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type
+ })
+ .from(vendorPQSubmissions)
+ .where(and(...submissionQueryConditions))
+ .then(rows => rows[0]);
+ }
+
+ // 제출 가능한 상태 확인
+ const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"];
+
+ if (existingSubmission) {
+ if (!allowedStatuses.includes(existingSubmission.status)) {
return {
ok: false,
- error: `Cannot submit PQ in current status: ${vendor.status}`
+ error: `Cannot submit PQ in current status: ${existingSubmission.status}`
};
}
- // Update vendor status
+ // 기존 제출 상태 업데이트
await db
- .update(vendors)
+ .update(vendorPQSubmissions)
.set({
- status: "PQ_SUBMITTED",
+ status: "SUBMITTED",
+ submittedAt: currentDate,
updatedAt: currentDate,
})
- .where(eq(vendors.id, vendorId));
+ .where(eq(vendorPQSubmissions.id, existingSubmission.id));
+ } else {
+ // PQ Submission ID가 없고 기존 submission도 없는 경우 새로운 제출 생성
+ const pqType = projectId ? "PROJECT" : "GENERAL";
+ await db
+ .insert(vendorPQSubmissions)
+ .values({
+ vendorId,
+ projectId: projectId || null,
+ type: pqType,
+ status: "SUBMITTED",
+ submittedAt: currentDate,
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ });
}
+
+ // 4. 일반 PQ인 경우 벤더 상태도 업데이트
+ if (!projectId) {
+ const allowedVendorStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
- // 4. 관리자에게 이메일 알림 발송
+ if (allowedVendorStatuses.includes(vendor.status)) {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_SUBMITTED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+ }
+
+ // 5. 관리자에게 이메일 알림 발송
if (process.env.ADMIN_EMAIL) {
try {
const emailSubject = projectId
? `[eVCP] Project PQ Submitted: ${vendor.vendorName} for ${projectName}`
- : `[eVCP] PQ Submitted: ${vendor.vendorName}`;
+ : `[eVCP] General PQ Submitted: ${vendor.vendorName}`;
- const adminUrl = projectId
- ? `http://${host}/evcp/pq/${vendorId}?projectId=${projectId}`
- : `http://${host}/evcp/pq/${vendorId}`;
+ const adminUrl = `http://${host}/evcp/pq/${vendorId}/${existingSubmission?.id || ''}`;
await sendEmail({
to: process.env.ADMIN_EMAIL,
@@ -821,18 +857,17 @@ export async function submitPQAction({
});
} catch (emailError) {
console.error("Failed to send admin notification:", emailError);
- // 이메일 실패는 전체 프로세스를 중단하지 않음
}
}
- // 5. 벤더에게 확인 이메일 발송
+ // 6. 벤더에게 확인 이메일 발송
if (vendor.email) {
try {
const emailSubject = projectId
? `[eVCP] Project PQ Submission Confirmation for ${projectName}`
- : "[eVCP] PQ Submission Confirmation";
+ : "[eVCP] General PQ Submission Confirmation";
- const portalUrl = `${host}/dashboard`;
+ const portalUrl = `${host}/partners/pq`;
await sendEmail({
to: vendor.email,
@@ -849,16 +884,16 @@ export async function submitPQAction({
});
} catch (emailError) {
console.error("Failed to send vendor confirmation:", emailError);
- // 이메일 실패는 전체 프로세스를 중단하지 않음
}
}
- // 6. 캐시 무효화
+ // 7. 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
if (projectId) {
- revalidateTag(`vendor-project-pqs-${vendorId}`);
+ revalidateTag(`project-pq-submissions-${projectId}`);
revalidateTag(`project-vendors-${projectId}`);
revalidateTag(`project-pq-${projectId}`);
}
@@ -1702,4 +1737,1146 @@ export async function loadProjectPQAction(vendorId: number, projectId?: number):
throw new Error("Project ID is required for loading project PQ data");
}
return getPQDataByVendorId(vendorId, projectId);
+}
+
+
+
+export async function getAllPQsByVendorId(vendorId: number) {
+ try {
+ const pqList = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ projectId: vendorPQSubmissions.projectId,
+ projectName: projects.name,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+ rejectReason: vendorPQSubmissions.rejectReason,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(desc(vendorPQSubmissions.createdAt));
+
+ return pqList;
+ } catch (error) {
+ console.error("Error fetching PQ list:", error);
+ return [];
+ }
+}
+
+// 특정 PQ의 상세 정보 조회 (개별 PQ 페이지용)
+export async function getPQById(pqSubmissionId: number, vendorId: number) {
+ try {
+ const pq = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ createdAt: vendorPQSubmissions.createdAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+ rejectReason: vendorPQSubmissions.rejectReason,
+
+ // 벤더 정보 (추가)
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorStatus: vendors.status,
+
+ // 프로젝트 정보 (조인)
+ projectName: projects.name,
+ projectCode: projects.code,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!pq) {
+ throw new Error("PQ not found or access denied");
+ }
+
+ return pq;
+ } catch (error) {
+ console.error("Error fetching PQ by ID:", error);
+ throw error;
+ }
+}
+
+export async function getPQStatusCounts(vendorId: number) {
+ try {
+ // 모든 PQ 상태 조회 (일반 PQ + 프로젝트 PQ)
+ const pqStatuses = await db
+ .select({
+ status: vendorPQSubmissions.status,
+ count: count(),
+ })
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .groupBy(vendorPQSubmissions.status);
+
+ // 상태별 개수를 객체로 변환
+ const statusCounts = {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+
+ // 조회된 결과를 statusCounts 객체에 매핑
+ pqStatuses.forEach((item) => {
+ if (item.status in statusCounts) {
+ statusCounts[item.status as keyof typeof statusCounts] = item.count;
+ }
+ });
+
+ return statusCounts;
+ } catch (error) {
+ console.error("Error fetching PQ status counts:", error);
+ return {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+ }
+}
+
+// 상태 레이블 함수
+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;
+ }
+}
+
+export async function getPQSubmissions(input: GetPQSubmissionsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const pqFilterMapping = createPQFilterMapping();
+ const joinedTables = getPQJoinedTables();
+
+ console.log(input, "input")
+
+ // 1) 고급 필터 조건 (DataTableAdvancedToolbar에서)
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: vendorPQSubmissions,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ joinedTables,
+ customColumnMapping: pqFilterMapping,
+ });
+ console.log("advancedWhere:", advancedWhere);
+ }
+
+ // 2) 기본 필터 조건 (PQFilterSheet에서)
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: vendorPQSubmissions,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ joinedTables,
+ customColumnMapping: pqFilterMapping,
+ });
+ console.log("basicWhere:", basicWhere);
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ // 기존 검색 조건들
+ const nameCondition = ilike(vendors.vendorName, s);
+ if (nameCondition) validSearchConditions.push(nameCondition);
+
+ const codeCondition = ilike(vendors.vendorCode, s);
+ if (codeCondition) validSearchConditions.push(codeCondition);
+
+ const projectNameCondition = ilike(projects.name, s);
+ if (projectNameCondition) validSearchConditions.push(projectNameCondition);
+
+ const projectCodeCondition = ilike(projects.code, s);
+ if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
+
+ // 새로 추가된 검색 조건들
+ const pqNumberCondition = ilike(vendorPQSubmissions.pqNumber, s);
+ if (pqNumberCondition) validSearchConditions.push(pqNumberCondition);
+
+ const requesterCondition = ilike(users.name, s);
+ if (requesterCondition) validSearchConditions.push(requesterCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // 4) 날짜 조건
+ let fromDateWhere: SQL<unknown> | undefined = undefined;
+ let toDateWhere: SQL<unknown> | undefined = undefined;
+
+ if (input.submittedDateFrom) {
+ const fromDate = new Date(input.submittedDateFrom);
+ const condition = gte(vendorPQSubmissions.submittedAt, fromDate);
+ if (condition) fromDateWhere = condition;
+ }
+
+ if (input.submittedDateTo) {
+ const toDate = new Date(input.submittedDateTo);
+ const condition = lte(vendorPQSubmissions.submittedAt, toDate);
+ if (condition) toDateWhere = condition;
+ }
+
+ // 5) 최종 WHERE 조건 생성 - 각 그룹을 AND로 연결
+ const whereConditions: SQL<unknown>[] = [];
+
+ // 고급 필터 조건 추가
+ if (advancedWhere) whereConditions.push(advancedWhere);
+
+ // 기본 필터 조건 추가
+ if (basicWhere) whereConditions.push(basicWhere);
+
+ // 기타 조건들 추가
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (fromDateWhere) whereConditions.push(fromDateWhere);
+ if (toDateWhere) whereConditions.push(toDateWhere);
+
+ // 모든 조건을 AND로 연결
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ console.log("Final WHERE conditions:", {
+ advancedWhere: !!advancedWhere,
+ basicWhere: !!basicWhere,
+ globalWhere: !!globalWhere,
+ dateConditions: !!(fromDateWhere || toDateWhere),
+ totalConditions: whereConditions.length
+ });
+
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(vendorPQSubmissions)
+ .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id))
+ .leftJoin(vendorInvestigations, eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id))
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof vendorPQSubmissions.$inferSelect;
+ return sort.desc ? desc(vendorPQSubmissions[column]) : asc(vendorPQSubmissions[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(vendorPQSubmissions.updatedAt));
+ }
+
+ const pqSubmissions = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ type: vendorPQSubmissions.type,
+ pqNumber: vendorPQSubmissions.pqNumber,
+ requesterId: vendorPQSubmissions.requesterId,
+ requesterName: users.name,
+ status: vendorPQSubmissions.status,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+ rejectReason: vendorPQSubmissions.rejectReason,
+ // Vendor 정보
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
+ vendorStatus: vendors.status,
+ // Project 정보 (프로젝트 PQ인 경우)
+ projectId: projects.id,
+ projectName: projects.name,
+ projectCode: projects.code,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id))
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 8) 각 PQ 제출에 대한 추가 정보 조회 (기존과 동일)
+ const pqSubmissionsWithDetails = await Promise.all(
+ pqSubmissions.map(async (submission) => {
+ // 기본 반환 객체
+ const baseResult = {
+ ...submission,
+ answerCount: 0,
+ attachmentCount: 0,
+ pqStatus: getStatusLabel(submission.status),
+ pqTypeLabel: submission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ",
+ };
+
+ // vendorId가 null이면 기본 정보만 반환
+ if (submission.vendorId === null) {
+ return baseResult;
+ }
+
+ try {
+ // 답변 수 조회
+ const vendorId = submission.vendorId;
+
+ const answerWhereConditions: SQL<unknown>[] = [];
+
+ const vendorCondition = eq(vendorPqCriteriaAnswers.vendorId, vendorId);
+ if (vendorCondition) answerWhereConditions.push(vendorCondition);
+
+ let projectCondition: SQL<unknown> | undefined;
+ if (submission.projectId !== null) {
+ projectCondition = eq(vendorPqCriteriaAnswers.projectId, submission.projectId);
+ } else {
+ projectCondition = isNull(vendorPqCriteriaAnswers.projectId);
+ }
+
+ if (projectCondition) answerWhereConditions.push(projectCondition);
+
+ const answerWhere = and(...answerWhereConditions);
+
+ const answersResult = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(answerWhere);
+
+ const answerCount = answersResult[0]?.count || 0;
+
+ // 첨부 파일 수 조회
+ const attachmentsResult = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, vendorPqCriteriaAnswers.id)
+ )
+ .where(answerWhere);
+
+ const attachmentCount = attachmentsResult[0]?.count || 0;
+
+ const requesters = alias(users, 'requesters');
+ const qmManagers = alias(users, 'qmManagers');
+
+ const investigationResult = await db
+ .select({
+ id: vendorInvestigations.id,
+ investigationStatus: vendorInvestigations.investigationStatus,
+ evaluationType: vendorInvestigations.evaluationType,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ investigationMethod: vendorInvestigations.investigationMethod,
+ scheduledStartAt: vendorInvestigations.scheduledStartAt,
+ scheduledEndAt: vendorInvestigations.scheduledEndAt,
+ requestedAt: vendorInvestigations.requestedAt,
+ confirmedAt: vendorInvestigations.confirmedAt,
+ completedAt: vendorInvestigations.completedAt,
+ forecastedAt: vendorInvestigations.forecastedAt,
+ evaluationScore: vendorInvestigations.evaluationScore,
+ evaluationResult: vendorInvestigations.evaluationResult,
+ investigationNotes: vendorInvestigations.investigationNotes,
+ requesterId: vendorInvestigations.requesterId,
+ requesterName: requesters.name,
+ qmManagerId: vendorInvestigations.qmManagerId,
+ qmManagerName: qmManagers.name,
+ qmManagerEmail: qmManagers.email,
+ })
+ .from(vendorInvestigations)
+ .leftJoin(requesters, eq(vendorInvestigations.requesterId, requesters.id))
+ .leftJoin(qmManagers, eq(vendorInvestigations.qmManagerId, qmManagers.id))
+ .where(and(
+ eq(vendorInvestigations.vendorId, submission.vendorId),
+ eq(vendorInvestigations.pqSubmissionId, submission.id)
+ ))
+ .orderBy(desc(vendorInvestigations.createdAt))
+ .limit(1);
+
+ const investigation = investigationResult[0] || null;
+
+ return {
+ ...baseResult,
+ answerCount,
+ attachmentCount,
+ investigation
+ };
+ } catch (error) {
+ console.error("Error fetching PQ details:", error);
+ return baseResult;
+ }
+ })
+ );
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: pqSubmissionsWithDetails, pageCount };
+ } catch (err) {
+ console.error("Error in getPQSubmissions:", err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["pq-submissions"], // revalidateTag 호출 시 무효화
+ }
+ )();
+}
+
+export async function getPQStatusCountsAll() {
+ try {
+ // 모든 PQ 상태별 개수 조회 (벤더 제한 없음)
+ const pqStatuses = await db
+ .select({
+ status: vendorPQSubmissions.status,
+ count: count(),
+ })
+ .from(vendorPQSubmissions)
+ .groupBy(vendorPQSubmissions.status);
+
+ // 상태별 개수를 객체로 변환
+ const statusCounts = {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+
+ // 조회된 결과를 statusCounts 객체에 매핑
+ pqStatuses.forEach((item) => {
+ if (item.status in statusCounts) {
+ statusCounts[item.status as keyof typeof statusCounts] = item.count;
+ }
+ });
+
+ return statusCounts;
+ } catch (error) {
+ console.error("Error fetching PQ status counts:", error);
+ return {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+ }
+}
+
+// PQ 타입별, 상태별 개수 집계 함수 (추가 옵션)
+export async function getPQDetailedStatusCounts() {
+ try {
+ // 타입별, 상태별 개수 조회
+ const pqStatuses = await db
+ .select({
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ count: count(),
+ })
+ .from(vendorPQSubmissions)
+ .groupBy(vendorPQSubmissions.type, vendorPQSubmissions.status);
+
+ // 결과를 저장할 객체 초기화
+ const result = {
+ GENERAL: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ PROJECT: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ total: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ }
+ };
+
+ // 결과 매핑
+ pqStatuses.forEach((item) => {
+ if (item.type && item.status) {
+ const type = item.type as keyof typeof result;
+ const status = item.status as keyof typeof result.GENERAL;
+
+ if (type in result && status in result[type]) {
+ // 타입별 상태 카운트 업데이트
+ result[type][status] = item.count;
+
+ // 타입별 합계 업데이트
+ result[type].total += item.count;
+
+ // 전체 상태별 카운트 업데이트
+ result.total[status] += item.count;
+
+ // 전체 합계 업데이트
+ result.total.total += item.count;
+ }
+ }
+ });
+
+ return result;
+ } catch (error) {
+ console.error("Error fetching detailed PQ status counts:", error);
+ return {
+ GENERAL: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ PROJECT: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ total: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ }
+ };
+ }
+}
+
+// PQ 승인 액션
+export async function approvePQAction({
+ pqSubmissionId,
+ vendorId,
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const currentDate = new Date();
+
+ // 1. PQ 제출 정보 조회
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ // 2. 상태 확인 (SUBMITTED 상태만 승인 가능)
+ if (pqSubmission.status !== "SUBMITTED") {
+ return {
+ ok: false,
+ error: `Cannot approve PQ in current status: ${pqSubmission.status}`
+ };
+ }
+
+ // 3. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
+ let projectName = '';
+ if (pqSubmission.projectId) {
+ const projectData = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, pqSubmission.projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.name || 'Unknown Project';
+ }
+
+ // 5. PQ 상태 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "APPROVED",
+ approvedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_APPROVED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 7. 벤더에게 이메일 알림 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = pqSubmission.projectId
+ ? `[eVCP] Project PQ Approved for ${projectName}`
+ : "[eVCP] General PQ Approved";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-approved-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: pqSubmission.projectId,
+ projectName: projectName,
+ isProjectPQ: !!pqSubmission.projectId,
+ approvedDate: currentDate.toLocaleString(),
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor notification:", emailError);
+ // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ // 8. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("pq-submissions");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+
+ if (pqSubmission.projectId) {
+ revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
+ revalidateTag(`project-vendors-${pqSubmission.projectId}`);
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error("PQ approve error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+// PQ 거부 액션
+export async function rejectPQAction({
+ pqSubmissionId,
+ vendorId,
+ rejectReason
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+ rejectReason: string;
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const currentDate = new Date();
+
+ // 1. PQ 제출 정보 조회
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ // 2. 상태 확인 (SUBMITTED 상태만 거부 가능)
+ if (pqSubmission.status !== "SUBMITTED") {
+ return {
+ ok: false,
+ error: `Cannot reject PQ in current status: ${pqSubmission.status}`
+ };
+ }
+
+ // 3. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
+ let projectName = '';
+ if (pqSubmission.projectId) {
+ const projectData = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, pqSubmission.projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.name || 'Unknown Project';
+ }
+
+ // 5. PQ 상태 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "REJECTED",
+ rejectedAt: currentDate,
+ rejectReason: rejectReason,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_FAILED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 7. 벤더에게 이메일 알림 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = pqSubmission.projectId
+ ? `[eVCP] Project PQ Rejected for ${projectName}`
+ : "[eVCP] General PQ Rejected";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-rejected-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: pqSubmission.projectId,
+ projectName: projectName,
+ isProjectPQ: !!pqSubmission.projectId,
+ rejectedDate: currentDate.toLocaleString(),
+ rejectReason: rejectReason,
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor notification:", emailError);
+ // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ // 8. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("pq-submissions");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+
+ if (pqSubmission.projectId) {
+ revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
+ revalidateTag(`project-vendors-${pqSubmission.projectId}`);
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error("PQ reject error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+
+// 실사 의뢰 생성 서버 액션
+export async function requestInvestigationAction(
+ pqSubmissionIds: number[],
+ data: {
+ evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationNotes?: string
+ }
+) {
+ try {
+ // 세션에서 요청자 정보 가져오기
+ const session = await getServerSession(authOptions);
+ const requesterId = session?.user?.id ? Number(session.user.id) : null;
+
+ if (!requesterId) {
+ return { success: false, error: "인증된 사용자만 실사를 의뢰할 수 있습니다." };
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // PQ 제출 정보 조회
+ const pqSubmissions = await tx
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ inArray(vendorPQSubmissions.id, pqSubmissionIds),
+ eq(vendorPQSubmissions.status, "APPROVED")
+ )
+ );
+
+ if (pqSubmissions.length === 0) {
+ throw new Error("승인된 PQ 제출 항목이 없습니다.");
+ }
+
+ const now = new Date();
+
+ // 각 PQ에 대한 실사 요청 생성 - 타입이 정확히 맞는지 확인
+ const investigations = pqSubmissions.map((pq) => {
+ return {
+ vendorId: pq.vendorId,
+ pqSubmissionId: pq.id,
+ investigationStatus: "PLANNED" as const, // enum 타입으로 명시적 지정
+ evaluationType: data.evaluationType,
+ qmManagerId: data.qmManagerId,
+ forecastedAt: data.forecastedAt,
+ investigationAddress: data.investigationAddress,
+ investigationNotes: data.investigationNotes || null,
+ requesterId: requesterId,
+ requestedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ };
+ });
+
+ // 실사 요청 저장
+ const created = await tx
+ .insert(vendorInvestigations)
+ .values(investigations)
+ .returning();
+
+ return created;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("pq-submissions");
+
+ return {
+ success: true,
+ count: result.length,
+ data: result
+ };
+ } catch (err) {
+ console.error("실사 의뢰 중 오류 발생:", err);
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
+ };
+ }
+}
+
+// 실사 의뢰 취소 서버 액션
+export async function cancelInvestigationAction(investigationIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+ const userId = session?.user?.id ? Number(session.user.id) : null
+
+ if (!userId) {
+ return { success: false, error: "인증된 사용자만 실사를 취소할 수 있습니다." }
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // PLANNED 상태인 실사만 취소 가능
+ const updatedInvestigations = await tx
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "CANCELED",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ inArray(vendorInvestigations.id, investigationIds),
+ eq(vendorInvestigations.investigationStatus, "PLANNED")
+ )
+ )
+ .returning()
+
+ return updatedInvestigations
+ })
+
+ // 캐시 무효화
+ revalidateTag("vendor-investigations")
+ revalidateTag("pq-submissions")
+
+ return {
+ success: true,
+ count: result.length,
+ data: result
+ }
+ } catch (err) {
+ console.error("실사 취소 중 오류 발생:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
+ }
+ }
+}
+
+// 실사 결과 발송 서버 액션
+export async function sendInvestigationResultsAction(investigationIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+ const userId = session?.user?.id ? Number(session.user.id) : null
+
+ if (!userId) {
+ return { success: false, error: "인증된 사용자만 실사 결과를 발송할 수 있습니다." }
+ }
+
+ // 여기서는 실사 상태를 업데이트하고, 필요하다면 이메일도 발송할 수 있습니다
+ // 이메일 발송 로직은 서버 액션 내에서 구현할 수 있습니다
+ const result = await db.transaction(async (tx) => {
+ // 완료된 실사만 결과 발송 가능
+ const investigations = await tx
+ .select()
+ .from(vendorInvestigations)
+ .where(
+ and(
+ inArray(vendorInvestigations.id, investigationIds),
+ eq(vendorInvestigations.investigationStatus, "COMPLETED")
+ )
+ )
+
+ if (investigations.length === 0) {
+ throw new Error("발송할 수 있는 완료된 실사가 없습니다.")
+ }
+
+ // 여기에 이메일 발송 로직 추가
+ // 예: await sendInvestigationResultEmails(investigations)
+
+ // 필요하다면 상태 업데이트 (예: 결과 발송됨 상태 추가)
+ const updatedInvestigations = await tx
+ .update(vendorInvestigations)
+ .set({
+ // 예시: 결과 발송 표시를 위한 필드 업데이트
+ // resultSent: true,
+ // resultSentAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ inArray(vendorInvestigations.id, investigationIds)
+ )
+ .returning()
+
+ return updatedInvestigations
+ })
+
+ // 캐시 무효화
+ revalidateTag("vendor-investigations")
+ revalidateTag("pq-submissions")
+
+ return {
+ success: true,
+ count: result.length,
+ data: result
+ }
+ } catch (err) {
+ console.error("실사 결과 발송 중 오류 발생:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
+ }
+ }
+}
+
+
+export async function getQMManagers() {
+ try {
+ // QM 부서 사용자만 필터링 (department 필드가 있다고 가정)
+ // 또는 QM 역할을 가진 사용자만 필터링 (role 필드가 있다고 가정)
+ const qmUsers = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ })
+ .from(users)
+ // .where(
+ // // 필요에 따라 조건 조정 (예: QM 부서 또는 특정 역할만)
+ // // eq(users.department, "QM") 또는
+ // // eq(users.role, "QM_MANAGER")
+ // // 테스트를 위해 모든 사용자 반환도 가능
+ // eq(users.active, true)
+ // )
+ .orderBy(users.name)
+
+ return {
+ data: qmUsers,
+ success: true
+ }
+ } catch (error) {
+ console.error("QM 담당자 목록 조회 오류:", error)
+ return {
+ data: [],
+ success: false,
+ error: error instanceof Error ? error.message : "QM 담당자 목록을 가져오는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+export async function getFactoryLocationAnswer(vendorId: number, projectId: number | null = null) {
+ try {
+ // 1. "Location of Factory" 체크포인트를 가진 criteria 찾기
+ const criteria = await db
+ .select({
+ id: pqCriterias.id
+ })
+ .from(pqCriterias)
+ .where(ilike(pqCriterias.checkPoint, "%Location of Factory%"))
+ .limit(1);
+
+ if (!criteria.length) {
+ return { success: false, message: "Factory Location 질문을 찾을 수 없습니다." };
+ }
+
+ const criteriaId = criteria[0].id;
+
+ // 2. 해당 criteria에 대한 벤더의 응답 조회
+ const answerQuery = db
+ .select({
+ answer: vendorPqCriteriaAnswers.answer
+ })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.criteriaId, criteriaId)
+ )
+ );
+
+ // 프로젝트 ID가 있으면 추가 조건
+ if (projectId !== null) {
+ answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else {
+ answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, null));
+ }
+
+ const answers = await answerQuery.limit(1);
+
+ if (!answers.length || !answers[0].answer) {
+ return { success: false, message: "공장 위치 정보를 찾을 수 없습니다." };
+ }
+
+ return {
+ success: true,
+ factoryLocation: answers[0].answer
+ };
+ } catch (error) {
+ console.error("Factory location 조회 오류:", error);
+ return { success: false, message: "오류가 발생했습니다." };
+ }
} \ No newline at end of file
diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts
index 27e065ba..cf512d63 100644
--- a/lib/pq/validations.ts
+++ b/lib/pq/validations.ts
@@ -8,7 +8,7 @@ import {
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { PqCriterias } from "@/db/schema/pq"
+import { PqCriterias, vendorPQSubmissions } from "@/db/schema/pq"
export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
@@ -34,3 +34,41 @@ export const searchParamsCache = createSearchParamsCache({
export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
+
+export const searchParamsPQReviewCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<typeof vendorPQSubmissions.$inferSelect>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (새로 추가)
+ pqBasicFilters: getFiltersStateParser().withDefault([]),
+ pqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // PQ 특화 필터 (기존 유지)
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ type: parseAsStringEnum(["GENERAL", "PROJECT"]),
+ status: parseAsStringEnum(["REQUESTED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED"]),
+ submittedDateFrom: parseAsString.withDefault(""),
+ submittedDateTo: parseAsString.withDefault(""),
+});
+
+export type GetPQSubmissionsSchema = Awaited<ReturnType<typeof searchParamsPQReviewCache.parse>> \ No newline at end of file