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/pq-review-table-new | |
| parent | e1344a5da1aeef8fbf0f33e1dfd553078c064ccc (diff) | |
(대표님) lib 파트 커밋
Diffstat (limited to 'lib/pq/pq-review-table-new')
| -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 |
10 files changed, 2800 insertions, 0 deletions
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 |
