summaryrefslogtreecommitdiff
path: root/lib/pq/pq-review-table-new
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-04 09:39:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-04 09:39:21 +0000
commit53ad72732f781e6c6d5ddb3776ea47aec010af8e (patch)
treee676287827f8634be767a674b8ad08b6ed7eb3e6 /lib/pq/pq-review-table-new
parent3e4d15271322397764601dee09441af8a5b3adf5 (diff)
(최겸) PQ/실사 수정 및 개발
Diffstat (limited to 'lib/pq/pq-review-table-new')
-rw-r--r--lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx136
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx217
-rw-r--r--lib/pq/pq-review-table-new/feature-flags-provider.tsx216
-rw-r--r--lib/pq/pq-review-table-new/pq-container.tsx300
-rw-r--r--lib/pq/pq-review-table-new/pq-filter-sheet.tsx1300
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx667
-rw-r--r--lib/pq/pq-review-table-new/send-results-dialog.tsx279
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx711
-rw-r--r--lib/pq/pq-review-table-new/user-combobox.tsx242
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx1425
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx756
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx772
12 files changed, 4230 insertions, 2791 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
index 03045537..94b33ab4 100644
--- a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
@@ -1,69 +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>
- )
+"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/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
new file mode 100644
index 00000000..4df7a7ec
--- /dev/null
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -0,0 +1,217 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { CalendarIcon, Loader } from "lucide-react"
+import { format } from "date-fns"
+import { toast } from "sonner"
+
+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 { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { z } from "zod"
+
+// Validation schema for editing investigation
+const editInvestigationSchema = z.object({
+ confirmedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+ evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(),
+ investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
+})
+
+type EditInvestigationSchema = z.infer<typeof editInvestigationSchema>
+
+interface EditInvestigationDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ investigation: {
+ id: number
+ confirmedAt?: Date | null
+ evaluationResult?: string | null
+ investigationNotes?: string | null
+ } | null
+ onSubmit: (data: EditInvestigationSchema) => Promise<void>
+}
+
+export function EditInvestigationDialog({
+ isOpen,
+ onClose,
+ investigation,
+ onSubmit,
+}: EditInvestigationDialogProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<EditInvestigationSchema>({
+ resolver: zodResolver(editInvestigationSchema),
+ defaultValues: {
+ confirmedAt: investigation?.confirmedAt || undefined,
+ evaluationResult: investigation?.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
+ investigationNotes: investigation?.investigationNotes || "",
+ },
+ })
+
+ // Reset form when investigation changes
+ React.useEffect(() => {
+ if (investigation) {
+ form.reset({
+ confirmedAt: investigation.confirmedAt || undefined,
+ evaluationResult: investigation.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
+ investigationNotes: investigation.investigationNotes || "",
+ })
+ }
+ }, [investigation, form])
+
+ const handleSubmit = async (values: EditInvestigationSchema) => {
+ startTransition(async () => {
+ try {
+ await onSubmit(values)
+ toast.success("실사 정보가 업데이트되었습니다!")
+ onClose()
+ } catch (error) {
+ console.error("실사 정보 업데이트 오류:", error)
+ toast.error("실사 정보 업데이트 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>실사 정보 수정</DialogTitle>
+ <DialogDescription>
+ 구매자체평가 실사 정보를 수정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ {/* 실사 확정일 */}
+ <FormField
+ control={form.control}
+ name="confirmedAt"
+ 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"}`}
+ >
+ {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}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 결과 */}
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 결과</FormLabel>
+ <FormControl>
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 결과를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="APPROVED">승인</SelectItem>
+ <SelectItem value="SUPPLEMENT">보완</SelectItem>
+ <SelectItem value="REJECTED">불가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* QM 의견 */}
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사에 대한 QM 의견을 입력하세요..."
+ {...field}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={onClose} disabled={isPending}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ 저장
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </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
index 81131894..615377d6 100644
--- a/lib/pq/pq-review-table-new/feature-flags-provider.tsx
+++ b/lib/pq/pq-review-table-new/feature-flags-provider.tsx
@@ -1,108 +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>
- )
-}
+"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
index ebe46809..01b7aab1 100644
--- a/lib/pq/pq-review-table-new/pq-container.tsx
+++ b/lib/pq/pq-review-table-new/pq-container.tsx
@@ -1,151 +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>
- </>
- )
+"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
index 979f25a2..ff1b890b 100644
--- a/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
+++ b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
@@ -1,651 +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>
- )
+"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
index d5588be4..6cbb885f 100644
--- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -1,331 +1,338 @@
-"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>
- )
+"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([
+ "PURCHASE_SELF_EVAL", // 구매자체평가
+ "DOCUMENT_EVAL", // 서류평가
+ // "PRODUCT_INSPECTION", // 제품검사평가
+ // "SITE_VISIT_EVAL" // 방문실사평가
+ ], {
+ 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: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationMethod?: string,
+ investigationNotes?: string
+ }) => Promise<void>
+ selectedCount: number
+ // 선택된 행에서 가져온 초기값
+ initialData?: {
+ evaluationType?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ 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 || "PURCHASE_SELF_EVAL",
+ 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 || "PURCHASE_SELF_EVAL",
+ 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="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
+ <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
+ {/* <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> */}
+ {/* <SelectItem value="SITE_VISIT_EVAL">방문실사평가</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
index 0a423f7f..3c8614cc 100644
--- a/lib/pq/pq-review-table-new/send-results-dialog.tsx
+++ b/lib/pq/pq-review-table-new/send-results-dialog.tsx
@@ -1,69 +1,212 @@
-"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>
- )
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+
+
+import { 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 { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+
+// 실사 결과 발송을 위한 스키마
+const sendResultsSchema = z.object({
+ purchaseComment: z.string().optional(),
+})
+
+type SendResultsFormValues = z.infer<typeof sendResultsSchema>
+
+interface AuditResult {
+ id: number
+ vendorCode: string
+ vendorName: string
+ vendorEmail: string
+ vendorContactPerson: string
+ pqNumber: string
+ auditItem: string
+ auditFactoryAddress: string
+ auditMethod: string
+ auditResult: string
+ additionalNotes?: string
+ investigationNotes?: string
+}
+
+interface SendResultsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: (data: SendResultsFormValues) => Promise<void>
+ selectedCount: number
+ auditResults: AuditResult[]
+}
+
+export function SendResultsDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ selectedCount,
+ auditResults,
+}: SendResultsDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+
+ const form = useForm<SendResultsFormValues>({
+ resolver: zodResolver(sendResultsSchema),
+ defaultValues: {
+ purchaseComment: "",
+ },
+ })
+
+ async function handleSubmit(data: SendResultsFormValues) {
+ setIsPending(true)
+ try {
+ await onConfirm(data)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const getResultBadgeVariant = (result: string) => {
+ if (result.includes("Pass")) return "default"
+ if (result.includes("Fail")) return "destructive"
+ return "secondary"
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>실사 결과 발송</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedCount}개 협력업체의 실사 결과를 발송하시겠습니까?
+ 완료된 실사만 결과를 발송할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 실사 결과 미리보기 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">실사 결과 미리보기</h3>
+ <div className="space-y-4">
+ {auditResults.map((result) => (
+ <Card key={result.id}>
+ <CardHeader>
+ <CardTitle className="flex items-center justify-between">
+ <span>{result.vendorName} ({result.vendorCode})</span>
+ <Badge variant={getResultBadgeVariant(result.auditResult)}>
+ {result.auditResult}
+ </Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3 text-sm">
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">PQ No.</div>
+ <div className="col-span-2">{result.pqNumber}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">Vendor</div>
+ <div className="col-span-2">{result.vendorCode} | {result.vendorName}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">수신자</div>
+ <div className="col-span-2">{result.vendorContactPerson} ({result.vendorEmail})</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사품목</div>
+ <div className="col-span-2">{result.auditItem}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사공장주소</div>
+ <div className="col-span-2">{result.auditFactoryAddress}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">QM 실사방법</div>
+ <div className="col-span-2">{result.auditMethod}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사결과</div>
+ <div className="col-span-2">
+ <Badge variant={getResultBadgeVariant(result.auditResult)}>
+ {result.auditResult}
+ </Badge>
+ </div>
+ </div>
+ {result.investigationNotes && (
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사합격조건</div>
+ <div className="col-span-2">{result.investigationNotes}</div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 추가 Comment 입력 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="purchaseComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-base font-medium">
+ 추가 Comment (선택사항)
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="구매 담당자가 협력업체에 추가 설명/Comment 하고자 할 때 활용합니다. 입력하지 않으면 메일 본문에서 생략됩니다."
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isPending}
+ >
+ {isPending ? "처리 중..." : "결과 발송"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
new file mode 100644
index 00000000..63390cb1
--- /dev/null
+++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
@@ -0,0 +1,711 @@
+"use client"
+
+import * as React from "react"
+import { CalendarIcon, X } 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,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import { getSiteVisitRequestAction } from "@/lib/site-visit/service"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+
+// 방문실사 요청 폼 스키마
+const siteVisitRequestSchema = z.object({
+ // 실사 기간
+ inspectionDuration: z.number().min(0.5, "실사 기간을 입력해주세요."),
+
+ // 실사 요청일
+ requestedStartDate: z.date({
+ required_error: "실사 시작일을 선택해주세요.",
+ }),
+ requestedEndDate: z.date({
+ required_error: "실사 종료일을 선택해주세요.",
+ }),
+
+ // SHI 실사참석 예정부문
+ shiAttendees: z.object({
+ technicalSales: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ design: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ procurement: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ quality: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ production: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ commissioning: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ other: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ }),
+
+ // SHI 참석자 정보 (JSON 형태로 저장) - 기존 필드 유지
+ shiAttendeeDetails: z.string().optional(),
+
+ // 협력업체 요청정보 및 자료
+ vendorRequests: z.object({
+ availableDates: z.boolean().default(false),
+ factoryName: z.boolean().default(false),
+ factoryLocation: z.boolean().default(false),
+ factoryAddress: z.boolean().default(false),
+ factoryPicName: z.boolean().default(false),
+ factoryPicPhone: z.boolean().default(false),
+ factoryPicEmail: z.boolean().default(false),
+ factoryDirections: z.boolean().default(false),
+ accessProcedure: z.boolean().default(false),
+ other: z.boolean().default(false),
+ }),
+
+ // 기타 요청사항
+ otherVendorRequests: z.string().optional(),
+
+ // 추가 요청사항
+ additionalRequests: z.string().optional(),
+})
+
+type SiteVisitRequestFormValues = z.infer<typeof siteVisitRequestSchema>
+
+interface SiteVisitDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: SiteVisitRequestFormValues, attachments?: File[]) => Promise<void>
+ investigation: {
+ id: number
+ evaluationType: "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL"
+ investigationMethod?: string
+ investigationAddress?: string
+ vendorName: string
+ vendorCode: string
+ projectName?: string
+ projectCode?: string
+ pqItems?: string | null
+ }
+}
+
+export function SiteVisitDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ investigation,
+}: SiteVisitDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+
+ const form = useForm<SiteVisitRequestFormValues>({
+ resolver: zodResolver(siteVisitRequestSchema),
+ defaultValues: {
+ inspectionDuration: 1.0,
+ requestedStartDate: undefined,
+ requestedEndDate: undefined,
+ shiAttendees: {
+ technicalSales: { checked: false, count: 0, details: "" },
+ design: { checked: false, count: 0, details: "" },
+ procurement: { checked: false, count: 0, details: "" },
+ quality: { checked: false, count: 0, details: "" },
+ production: { checked: false, count: 0, details: "" },
+ commissioning: { checked: false, count: 0, details: "" },
+ other: { checked: false, count: 0, details: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ },
+ })
+
+ // Dialog가 열릴 때마다 폼 재설정 및 기존 요청 확인
+ React.useEffect(() => {
+ if (isOpen) {
+ // 기존 방문실사 요청이 있는지 확인
+ const checkExistingRequest = async () => {
+ try {
+ const existingRequest = await getSiteVisitRequestAction(investigation.id)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ onClose()
+ return
+ }
+ } catch (error) {
+ console.error("방문실사 요청 상태 확인 중 오류:", error)
+ toast.error("방문실사 요청 상태 확인 중 오류가 발생했습니다.")
+ onClose()
+ return
+ }
+ }
+
+ checkExistingRequest()
+
+ form.reset({
+ inspectionDuration: 1.0,
+ requestedStartDate: undefined,
+ requestedEndDate: undefined,
+ shiAttendees: {
+ technicalSales: { checked: false, count: 0, details: "" },
+ design: { checked: false, count: 0, details: "" },
+ procurement: { checked: false, count: 0, details: "" },
+ quality: { checked: false, count: 0, details: "" },
+ production: { checked: false, count: 0, details: "" },
+ commissioning: { checked: false, count: 0, details: "" },
+ other: { checked: false, count: 0, details: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ })
+ setSelectedFiles([])
+ }
+ }, [isOpen, form, investigation.id, onClose])
+
+ async function handleSubmit(data: SiteVisitRequestFormValues) {
+ setIsPending(true)
+ try {
+ // 제출 전에 한 번 더 기존 요청이 있는지 확인
+ const existingRequest = await getSiteVisitRequestAction(investigation.id)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ onClose()
+ return
+ }
+
+ await onSubmit(data, selectedFiles)
+ toast.success("방문실사 요청이 성공적으로 발송되었습니다.")
+ } catch (error) {
+ toast.error("방문실사 요청 발송 중 오류가 발생했습니다.")
+ console.error("방문실사 요청 오류:", error)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const handleDropAccepted = (files: File[]) => {
+ setSelectedFiles(prev => [...prev, ...files])
+ toast.success(`${files.length}개 파일이 추가되었습니다.`)
+ }
+
+ const handleDropRejected = (files: unknown[]) => {
+ toast.error(`${files.length}개 파일이 거부되었습니다. 파일 크기나 형식을 확인해주세요.`)
+ }
+
+ const removeFile = (index: number) => {
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ const getEvaluationTypeLabel = (type: string) => {
+ switch (type) {
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return type
+ }
+ }
+
+ const getInvestigationMethodLabel = (method: string) => {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>방문실사 요청 생성</DialogTitle>
+ <DialogDescription>
+ 협력업체에 방문실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 대상업체 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel className="text-sm font-medium">대상업체</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <div className="font-medium">{investigation.vendorName}</div>
+ <div className="text-sm text-muted-foreground">({investigation.vendorCode})</div>
+ </div>
+ </div>
+
+ <div>
+ <FormLabel className="text-sm font-medium">대상품목</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <div className="font-medium">{investigation.pqItems || "-"}</div>
+ </div>
+ </div>
+ </div>
+
+
+
+ {/* 실사방법 */}
+ <div>
+ <FormLabel className="text-sm font-medium">실사방법</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <Badge variant="outline">
+ {getEvaluationTypeLabel(investigation.evaluationType)}
+ </Badge>
+ {investigation.investigationMethod && (
+ <div className="mt-2 text-sm text-muted-foreground">
+ {getInvestigationMethodLabel(investigation.investigationMethod)}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 실사기간 */}
+ <FormField
+ control={form.control}
+ name="inspectionDuration"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사기간 (W/D 기준)</FormLabel>
+ <div className="flex items-center gap-2">
+ <FormControl>
+ <Input
+ type="number"
+ step="0.5"
+ min="0.5"
+ placeholder="1.5"
+ {...field}
+ onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-24"
+ />
+ </FormControl>
+ <span className="text-sm text-muted-foreground">일</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사요청일 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="requestedStartDate"
+ 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="requestedEndDate"
+ 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>
+ )}
+ />
+ </div>
+
+ {/* SHI 실사참석 예정부문 */}
+ <div>
+ <FormLabel className="text-sm font-medium">SHI 실사참석 예정부문 ※ 필수값</FormLabel>
+ <div className="text-sm text-muted-foreground mb-4">
+ 삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
+ </div>
+
+ <div className="space-y-4">
+ {[
+ { key: "technicalSales", label: "기술영업" },
+ { key: "design", label: "설계" },
+ { key: "procurement", label: "구매" },
+ { key: "quality", label: "품질" },
+ { key: "production", label: "생산" },
+ { key: "commissioning", label: "시운전" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <div key={item.key} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center space-x-3">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.checked` as `shiAttendees.${typeof item.key}.checked`}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center space-x-2 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-medium">{item.label}</FormLabel>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.count` as `shiAttendees.${typeof item.key}.count`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm">참석 인원</FormLabel>
+ <div className="flex items-center space-x-2">
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-20"
+ />
+ </FormControl>
+ <span className="text-sm text-muted-foreground">명</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.details` as `shiAttendees.${typeof item.key}.details`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm">참석자 정보</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="부서 및 이름 등"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 전체 참석자 상세정보 */}
+ <FormField
+ control={form.control}
+ name="shiAttendeeDetails"
+ render={({ field }) => (
+ <FormItem className="mt-4">
+ <FormLabel>전체 참석자 상세정보 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="전체 참석 예정인력의 상세 정보를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 협력업체 요청정보 및 자료 */}
+ <div>
+ <FormLabel className="text-sm font-medium">협력업체 요청정보 및 자료</FormLabel>
+ <div className="text-sm text-muted-foreground mb-2">
+ 협력업체에게 요청할 정보를 선택하세요. 선택된 항목들은 협력업체 정보 입력 폼에 포함됩니다.
+ </div>
+ <div className="mt-2 space-y-2">
+ {[
+ { key: "factoryName", label: "공장명" },
+ { key: "factoryLocation", label: "공장위치" },
+ { key: "factoryAddress", label: "공장주소" },
+ { key: "factoryPicName", label: "공장 PIC 이름" },
+ { key: "factoryPicPhone", label: "공장 PIC 전화번호" },
+ { key: "factoryPicEmail", label: "공장 PIC 이메일" },
+ { key: "factoryDirections", label: "공장 가는 방법" },
+ { key: "accessProcedure", label: "공장 출입절차" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <FormField
+ key={item.key}
+ control={form.control}
+ name={`vendorRequests.${item.key}` as `vendorRequests.${typeof item.key}`}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={!!field.value}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-normal">{item.label}</FormLabel>
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+ <FormField
+ control={form.control}
+ name="otherVendorRequests"
+ render={({ field }) => (
+ <FormItem className="mt-4">
+ <FormLabel>기타 요청사항</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="기타 요청사항을 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 추가 요청사항 */}
+ <FormField
+ control={form.control}
+ name="additionalRequests"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>추가 요청사항 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 요청사항을 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 첨부파일 */}
+ <div>
+ <FormLabel className="text-sm font-medium">첨부파일 (선택사항)</FormLabel>
+ <div className="mt-2">
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {() => (
+ <FormItem>
+ <DropzoneZone className="flex justify-center h-24">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: 600MB
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>
+ 또는 클릭하여 파일을 선택하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ </Dropzone>
+ {selectedFiles.length > 0 && (
+ <div className="mt-2 space-y-1">
+ {selectedFiles.map((file, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
+ <span className="text-sm">{file.name}</span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ disabled={isPending}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? "처리 중..." : "방문실사 요청 생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </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
index 0fb0e4c8..560f675a 100644
--- a/lib/pq/pq-review-table-new/user-combobox.tsx
+++ b/lib/pq/pq-review-table-new/user-combobox.tsx
@@ -1,122 +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>
- )
+"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
index 6bfa8c7f..d99f201e 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -1,640 +1,787 @@
-"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, "KR")}</span>
- }
- if (row.original.rejectedAt) {
- return <span className="text-red-600">{formatDate(row.original.rejectedAt, "KR")}</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,
- ];
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Eye, FileEdit, Trash2, Building2, FileText, Edit } 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 { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { useRouter } from "next/navigation"
+import { PQDeleteDialog } from "@/components/pq-input/pq-delete-dialog"
+
+// PQ 제출 타입 정의
+export interface PQSubmission {
+ // PQ 제출 정보
+ 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
+ email: string
+ // 프로젝트 정보
+ projectId: number | null
+ projectName: string | null
+ projectCode: string | null
+
+ // 답변 정보
+ answerCount: number
+ attachmentCount: number
+
+ // PQ 상태
+ pqStatus: string
+ pqTypeLabel: string
+
+ // PQ 대상품목
+ pqItems: string | null
+
+ // 방문실사 요청 정보
+ siteVisitRequestId: number | null // 방문실사 요청 ID
+
+ // 실사 정보
+ investigation: {
+ id: number
+ investigationStatus: string
+ requesterName: string | null // 실사 요청자 이름
+ evaluationType: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL" | 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" | "RESULT_SENT" | 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 유형" />
+ ),
+ cell: ({ row }) => {
+ const { type, pqTypeLabel } = row.original;
+ let label = pqTypeLabel;
+ if (type === "NON_INSPECTION") {
+ label = "미실사 PQ";
+ }
+ return (
+ <div className="flex items-center">
+ <Badge variant={type === "PROJECT" ? "default" : "outline"}>
+ {label}
+ </Badge>
+ </div>
+ );
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ }
+
+ // 프로젝트 컬럼
+ const projectColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "projectName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => {
+ const projectName = row.original.projectName
+ const projectCode = row.original.projectCode
+
+ if (!projectName) {
+ return <span className="text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="flex flex-col">
+ <span>{projectName}</span>
+ {projectCode && (
+ <span className="text-xs text-muted-foreground">{projectCode}</span>
+ )}
+ </div>
+ )
+ },
+ }
+
+ // 상태 컬럼
+ const statusColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "combinedStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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 };
+ case "RESULT_SENT":
+ return { status: "INVESTIGATION_RESULT_SENT", label: "실사 결과 발송", variant: "success" 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 }) => (
+ <DataTableColumnHeaderSimple 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 "PURCHASE_SELF_EVAL":
+ return <Badge variant="outline">구매자체평가</Badge>;
+ case "DOCUMENT_EVAL":
+ return <Badge variant="secondary">서류평가</Badge>;
+ case "PRODUCT_INSPECTION":
+ return <Badge variant="default">제품검사평가</Badge>;
+ case "SITE_VISIT_EVAL":
+ return <Badge variant="destructive">방문실사평가</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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 investigationMethodColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationMethod",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="QM실사방법" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+ if (!investigation || !investigation.investigationMethod) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ switch (investigation.investigationMethod) {
+ case "PURCHASE_SELF_EVAL":
+ return <Badge variant="outline">구매자체평가</Badge>;
+ case "DOCUMENT_EVAL":
+ return <Badge variant="secondary">서류평가</Badge>;
+ case "PRODUCT_INSPECTION":
+ return <Badge variant="default">제품검사평가</Badge>;
+ case "SITE_VISIT_EVAL":
+ return <Badge variant="destructive">방문실사평가</Badge>;
+ default:
+ return <span>{investigation.investigationMethod}</span>;
+ }
+ },
+ }
+
+ // 실사품목 컬럼
+ const pqItemsColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "pqItems",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실사품목" />
+ ),
+ cell: ({ row }) => {
+ const pqItems = row.original.pqItems;
+
+ if (!pqItems) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <span className="text-sm">{pqItems}</span>
+ </div>
+ )
+ },
+ }
+
+
+ const investigationRequestedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationRequestedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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>
+
+ {/* 방문실사 버튼 - 제품검사평가 또는 방문실사평가인 경우에만 표시 */}
+ {pq.investigation &&
+ (pq.investigation.investigationMethod === "PRODUCT_INSPECTION" ||
+ pq.investigation.investigationMethod === "SITE_VISIT_EVAL") && (
+ <>
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ // 방문실사 다이얼로그 열기 로직
+ setRowAction({
+ type: "site-visit",
+ row: row.original
+ });
+ }}
+ >
+ <Building2 className="mr-2 h-4 w-4" />
+ 방문실사
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ // 협력업체 정보 조회 다이얼로그 열기 로직
+ setRowAction({
+ type: "vendor-info-view",
+ row: row.original
+ });
+ }}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 협력업체 정보 조회
+ </DropdownMenuItem>
+ </>
+ )}
+
+ {/* 실사 정보 수정 버튼 - 구매자체평가인 경우에만 표시 */}
+ {pq.investigation &&
+ pq.investigation.investigationMethod === "PURCHASE_SELF_EVAL" && (
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ // 실사 정보 수정 다이얼로그 열기 로직
+ setRowAction({
+ type: "edit-investigation",
+ row: row.original
+ });
+ }}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 실사 정보 수정
+ </DropdownMenuItem>
+ )}
+
+ {/* 삭제 메뉴 - REQUESTED 상태일 때만 표시 */}
+ {pq.status === "REQUESTED" && (
+ <PQDeleteDialog
+ pqSubmissionId={pq.id}
+ status={pq.status}
+ >
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </PQDeleteDialog>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // 요청자 컬럼 추가
+const requesterColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "requesterName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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,
+ pqItemsColumn, // 실사품목 컬럼
+ createdAtColumn,
+ submittedAtColumn,
+ approvalDateColumn,
+ answerCountColumn,
+ evaluationTypeColumn, // 평가 유형 컬럼
+ investigationMethodColumn,
+ 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
index abba72d1..48aeb552 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -1,351 +1,407 @@
-"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}
- />
- </>
- )
+"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?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL";
+ 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_VISIT_EVAL";
+ 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_VISIT_EVAL";
+ 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_VISIT_EVAL";
+ 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: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationMethod?: 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) {
+ const evaluationTypeLabels = {
+ "PURCHASE_SELF_EVAL": "구매자체평가",
+ "DOCUMENT_EVAL": "서류평가",
+ "PRODUCT_INSPECTION": "제품검사평가",
+ "SITE_VISIT_EVAL": "방문실사평가"
+ };
+ toast.success(`${result.count}개 업체에 대한 ${evaluationTypeLabels[formData.evaluationType]}가 의뢰되었습니다.`)
+ 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 (data: { purchaseComment?: string }) => {
+ try {
+ setIsLoading(true)
+
+ // 완료된 실사 중 승인된 결과만 필터링
+ const approvedInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED" &&
+ row.original.investigation.evaluationResult === "APPROVED"
+ )
+
+ if (approvedInvestigations.length === 0) {
+ toast.error("발송할 실사 결과가 없습니다. 완료되고 승인된 실사만 결과를 발송할 수 있습니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await sendInvestigationResultsAction({
+ investigationIds: approvedInvestigations.map(row => row.original.investigation!.id),
+ purchaseComment: data.purchaseComment,
+ })
+
+ if (result.success) {
+ toast.success(result.message || `${result.data?.successCount || 0}개 업체에 대한 실사 결과가 발송되었습니다.`)
+ 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" &&
+ row.original.investigation.evaluationResult === "APPROVED"
+ ).length
+
+ // 실사 방법 라벨 변환 함수
+ const getInvestigationMethodLabel = (method: string): string => {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method
+ }
+ }
+
+ // 실사 결과 발송용 데이터 준비
+ const auditResults = selectedRows
+ .filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED" &&
+ row.original.investigation.evaluationResult === "APPROVED"
+ )
+ .map(row => {
+ const investigation = row.original.investigation!
+ const pqSubmission = row.original
+
+ return {
+ id: investigation.id,
+ vendorCode: row.original.vendorCode || "N/A",
+ vendorName: row.original.vendorName || "N/A",
+ vendorEmail: row.original.email || "N/A",
+ pqNumber: pqSubmission.pqNumber || "N/A",
+ auditItem: pqSubmission.pqItems || pqSubmission.projectName || "N/A",
+ auditFactoryAddress: investigation.investigationAddress || "N/A",
+ auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
+ auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
+ investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" :
+ investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A",
+ additionalNotes: investigation.investigationNotes,
+ investigationNotes: investigation.investigationNotes,
+ }
+ })
+
+ 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}
+ auditResults={auditResults}
+ />
+ </>
+ )
} \ 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
index e1c4cefe..c2712611 100644
--- a/lib/pq/pq-review-table-new/vendors-table.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table.tsx
@@ -1,308 +1,466 @@
-"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>
- </>
- )
+"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 { toast } from "sonner"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { updateInvestigationDetailsAction } from "../service"
+import { createSiteVisitRequestAction, getSiteVisitRequestAction } from "@/lib/site-visit/service"
+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 { SiteVisitDialog } from "./site-visit-dialog"
+import { VendorInfoViewDialog } from "@/lib/site-visit/vendor-info-view-dialog"
+import { EditInvestigationDialog } from "./edit-investigation-dialog"
+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 [isSiteVisitDialogOpen, setIsSiteVisitDialogOpen] = React.useState(false)
+ const [selectedInvestigation, setSelectedInvestigation] = React.useState<PQSubmission | null>(null)
+ const [isVendorInfoViewDialogOpen, setIsVendorInfoViewDialogOpen] = React.useState(false)
+ const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState<number | null>(null)
+
+ // 실사 정보 수정 다이얼로그 상태
+ const [isEditInvestigationDialogOpen, setIsEditInvestigationDialogOpen] = React.useState(false)
+ const [selectedInvestigationForEdit, setSelectedInvestigationForEdit] = React.useState<PQSubmission | null>(null)
+
+ 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]
+ })
+
+ // 방문실사 다이얼로그 핸들러
+ const handleSiteVisitRequest = async (data: {
+ inspectionDuration: number
+ requestedStartDate: Date
+ requestedEndDate: Date
+ shiAttendees: Record<string, boolean>
+ shiAttendeeDetails?: string
+ vendorRequests: Record<string, boolean>
+ otherVendorRequests?: string
+ additionalRequests?: string
+ }, attachments?: File[]) => {
+ try {
+ const result = await createSiteVisitRequestAction({
+ investigationId: selectedInvestigation?.investigation?.id || 0,
+ ...data,
+ attachments
+ })
+
+ if (result.success) {
+ toast.success(result.message || "방문실사 요청이 성공적으로 발송되었습니다.")
+ handleCloseSiteVisitDialog()
+ } else {
+ toast.error(result.error || "방문실사 요청 발송 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("방문실사 요청 오류:", error)
+ toast.error("방문실사 요청 발송 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 방문실사 다이얼로그 열기
+ const handleOpenSiteVisitDialog = async (investigation: PQSubmission) => {
+ try {
+ // 기존 방문실사 요청이 있는지 확인
+ const existingRequest = await getSiteVisitRequestAction(investigation.investigation?.id || 0)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ return
+ }
+
+ setSelectedInvestigation(investigation)
+ setIsSiteVisitDialogOpen(true)
+ } catch (error) {
+ console.error("방문실사 요청 상태 확인 중 오류:", error)
+ toast.error("방문실사 요청 상태 확인 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 방문실사 다이얼로그 닫기
+ const handleCloseSiteVisitDialog = () => {
+ setIsSiteVisitDialogOpen(false)
+ setSelectedInvestigation(null)
+ }
+
+ // 실사 정보 수정 핸들러
+ const handleEditInvestigation = async (data: {
+ confirmedAt?: Date
+ evaluationResult?: "APPROVED" | "SUPPLEMENT" | "REJECTED"
+ investigationNotes?: string
+ }) => {
+ if (!selectedInvestigationForEdit?.investigation?.id) {
+ toast.error("실사 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ try {
+ const result = await updateInvestigationDetailsAction({
+ investigationId: selectedInvestigationForEdit.investigation.id,
+ ...data
+ })
+
+ if (result.success) {
+ toast.success(result.message || "실사 정보가 성공적으로 업데이트되었습니다.")
+ setIsEditInvestigationDialogOpen(false)
+ setSelectedInvestigationForEdit(null)
+ } else {
+ toast.error(result.error || "실사 정보 업데이트 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 정보 업데이트 오류:", error)
+ toast.error("실사 정보 업데이트 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 실사 정보 수정 다이얼로그 닫기
+ const handleCloseEditInvestigationDialog = () => {
+ setIsEditInvestigationDialogOpen(false)
+ setSelectedInvestigationForEdit(null)
+ }
+
+ // rowAction 핸들러
+ React.useEffect(() => {
+ if (rowAction?.type === "site-visit") {
+ // 방문실사 다이얼로그 열기
+ handleOpenSiteVisitDialog(rowAction.row)
+ setRowAction(null)
+ } else if (rowAction?.type === "vendor-info-view") {
+ // 협력업체 정보 조회 다이얼로그 열기
+ setSelectedSiteVisitRequestId(rowAction.row.siteVisitRequestId || null)
+ setIsVendorInfoViewDialogOpen(true)
+ setRowAction(null)
+ } else if (rowAction?.type === "edit-investigation") {
+ // 실사 정보 수정 다이얼로그 열기
+ setSelectedInvestigationForEdit(rowAction.row)
+ setIsEditInvestigationDialogOpen(true)
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ // 초기 설정 정의 (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,
+ 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: "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
+ }),
+ 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 {
+ 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>
+
+ {/* 방문실사 다이얼로그 */}
+ {selectedInvestigation && (
+ <SiteVisitDialog
+ isOpen={isSiteVisitDialogOpen}
+ onClose={handleCloseSiteVisitDialog}
+ onSubmit={handleSiteVisitRequest}
+ investigation={{
+ id: selectedInvestigation.investigation?.id || 0,
+ evaluationType: selectedInvestigation.investigation?.evaluationType as "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ investigationMethod: selectedInvestigation.investigation?.investigationMethod,
+ investigationAddress: selectedInvestigation.investigation?.investigationAddress,
+ vendorName: selectedInvestigation.vendorName,
+ vendorCode: selectedInvestigation.vendorCode,
+ projectName: selectedInvestigation.projectName || undefined,
+ projectCode: selectedInvestigation.projectCode || undefined,
+ pqItems: selectedInvestigation.pqItems,
+ }}
+ />
+ )}
+
+ {/* 협력업체 정보 조회 다이얼로그 */}
+ <VendorInfoViewDialog
+ isOpen={isVendorInfoViewDialogOpen}
+ onClose={() => {
+ setIsVendorInfoViewDialogOpen(false)
+ setSelectedSiteVisitRequestId(null)
+ }}
+ siteVisitRequestId={selectedSiteVisitRequestId}
+ />
+
+ {/* 실사 정보 수정 다이얼로그 */}
+ <EditInvestigationDialog
+ isOpen={isEditInvestigationDialogOpen}
+ onClose={handleCloseEditInvestigationDialog}
+ investigation={selectedInvestigationForEdit?.investigation || null}
+ onSubmit={handleEditInvestigation}
+ />
+ </>
+ )
} \ No newline at end of file