diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
| commit | 20800b214145ee6056f94ca18fa1054f145eb977 (patch) | |
| tree | b5c8b27febe5b126e6d9ece115ea05eace33a020 /lib/pq | |
| parent | e1344a5da1aeef8fbf0f33e1dfd553078c064ccc (diff) | |
(대표님) lib 파트 커밋
Diffstat (limited to 'lib/pq')
| -rw-r--r-- | lib/pq/helper.ts | 96 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx | 69 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/pq-container.tsx | 151 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/pq-filter-sheet.tsx | 651 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/request-investigation-dialog.tsx | 331 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/send-results-dialog.tsx | 69 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/user-combobox.tsx | 122 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-columns.tsx | 640 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx | 351 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table.tsx | 308 | ||||
| -rw-r--r-- | lib/pq/service.ts | 1327 | ||||
| -rw-r--r-- | lib/pq/validations.ts | 40 |
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 |
