diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
| commit | 53ad72732f781e6c6d5ddb3776ea47aec010af8e (patch) | |
| tree | e676287827f8634be767a674b8ad08b6ed7eb3e6 /lib/pq/pq-review-table-new | |
| parent | 3e4d15271322397764601dee09441af8a5b3adf5 (diff) | |
(최겸) PQ/실사 수정 및 개발
Diffstat (limited to 'lib/pq/pq-review-table-new')
| -rw-r--r-- | lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx | 136 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/edit-investigation-dialog.tsx | 217 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/feature-flags-provider.tsx | 216 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/pq-container.tsx | 300 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/pq-filter-sheet.tsx | 1300 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/request-investigation-dialog.tsx | 667 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/send-results-dialog.tsx | 279 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/site-visit-dialog.tsx | 711 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/user-combobox.tsx | 242 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-columns.tsx | 1425 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx | 756 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table.tsx | 772 |
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 |
