diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-15 01:19:49 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-15 01:19:49 +0000 |
| commit | 9eb8e80f4f736c4edffa650c685d1f170ca51aa1 (patch) | |
| tree | cae02173015c806cd5ea92be86938fe3bf14decd /components | |
| parent | 71f4dd76b57e77676d8886ac0a8b0bd0a7f24e62 (diff) | |
(대표님) 구매 요청사항 반영한 통합 rfq / 필터 개인화 / po-rfq
Diffstat (limited to 'components')
| -rw-r--r-- | components/data-table/data-table-preset.tsx | 373 | ||||
| -rw-r--r-- | components/data-table/use-table-presets.tsx | 338 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/add-vendor-dialog.tsx | 512 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/delete-vendor-dialog.tsx | 150 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/rfq-detail-column.tsx | 369 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/rfq-detail-table.tsx | 519 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/update-vendor-sheet.tsx | 449 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/vendor-communication-drawer.tsx | 518 | ||||
| -rw-r--r-- | components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx | 665 | ||||
| -rw-r--r-- | components/po-rfq/po-rfq-container.tsx | 261 | ||||
| -rw-r--r-- | components/po-rfq/rfq-filter-sheet.tsx | 643 |
11 files changed, 4797 insertions, 0 deletions
diff --git a/components/data-table/data-table-preset.tsx b/components/data-table/data-table-preset.tsx new file mode 100644 index 00000000..007bde2f --- /dev/null +++ b/components/data-table/data-table-preset.tsx @@ -0,0 +1,373 @@ +// components/data-table/table-preset-manager.tsx +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@/components/ui/dropdown-menu' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Settings, + Save, + Star, + Edit, + Trash2, + Plus, + Check, + Loader2, + ChevronDown, + Bookmark +} from 'lucide-react' +import { TableSettings } from '@/db/schema' + +interface TablePreset { + id: string + name: string + isDefault: boolean + isActive: boolean + settings: TableSettings +} + +interface TablePresetManagerProps<TData = any> { + presets: TablePreset[] + activePresetId: string | null + currentSettings: TableSettings<TData> + hasUnsavedChanges: boolean + isLoading: boolean + onCreatePreset: (name: string, settings: TableSettings<TData>, isDefault: boolean) => Promise<boolean> + onUpdatePreset: (presetId: string, settings: TableSettings<TData>) => Promise<void> + onDeletePreset: (presetId: string) => Promise<boolean> + onApplyPreset: (presetId: string) => Promise<boolean> + onSetDefaultPreset: (presetId: string) => Promise<void> + onRenamePreset: (presetId: string, newName: string) => Promise<boolean> +} + +export function TablePresetManager<TData = any>({ + presets, + activePresetId, + currentSettings, + hasUnsavedChanges, + isLoading, + onCreatePreset, + onUpdatePreset, + onDeletePreset, + onApplyPreset, + onSetDefaultPreset, + onRenamePreset, +}: TablePresetManagerProps<TData>) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) + const [newPresetName, setNewPresetName] = useState('') + const [isDefaultPreset, setIsDefaultPreset] = useState(false) + const [renamingPresetId, setRenamingPresetId] = useState<string | null>(null) + const [processingPresetId, setProcessingPresetId] = useState<string | null>(null) + + const activePreset = presets.find(p => p.id === activePresetId) + + const handleCreatePreset = async () => { + if (!newPresetName.trim()) return + + const success = await onCreatePreset(newPresetName, currentSettings, isDefaultPreset) + if (success) { + setIsCreateDialogOpen(false) + setNewPresetName('') + setIsDefaultPreset(false) + } + } + + const handleRenamePreset = async () => { + if (renamingPresetId && newPresetName.trim()) { + const success = await onRenamePreset(renamingPresetId, newPresetName) + if (success) { + setIsRenameDialogOpen(false) + setNewPresetName('') + setRenamingPresetId(null) + } + } + } + + const openRenameDialog = (preset: TablePreset) => { + setRenamingPresetId(preset.id) + setNewPresetName(preset.name) + setIsRenameDialogOpen(true) + } + + const handleUpdateCurrentPreset = async () => { + if (activePresetId) { + await onUpdatePreset(activePresetId, currentSettings) + } + } + + const handleApplyPreset = async (presetId: string) => { + setProcessingPresetId(presetId) + await onApplyPreset(presetId) + setProcessingPresetId(null) + } + + if (isLoading) { + return ( + <Button variant="outline" size="sm" className="h-8" disabled> + <Loader2 size={16} className="mr-2 animate-spin" /> + 로딩 중... + </Button> + ) + } + + return ( + <> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="h-8"> + <Settings size={16} className="mr-2" /> + {/* <span className="hidden sm:inline">맞춤 설정</span> */} + {activePreset ? ( + <div className="flex items-center ml-2"> + <span className="font-medium text-sm">{activePreset.name}</span> + {hasUnsavedChanges && <span className="ml-1 text-amber-500">*</span>} + {activePreset.isDefault && <Star size={12} className="ml-1 text-yellow-500" />} + </div> + ) : null} + <ChevronDown size={14} className="ml-1" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-72"> + <DropdownMenuLabel className="flex items-center"> + <Bookmark size={16} className="mr-2" /> + 테이블 맞춤 설정 + </DropdownMenuLabel> + <DropdownMenuSeparator /> + + {/* 활성 맞춤 설정 표시 */} + {activePreset && ( + <> + <div className="px-2 py-2 bg-muted/50 rounded-sm mx-1"> + <div className="text-xs text-muted-foreground">현재 활성</div> + <div className="flex items-center justify-between"> + <span className="font-medium text-sm">{activePreset.name}</span> + <div className="flex items-center gap-1"> + {activePreset.isDefault && <Star size={12} className="text-yellow-500" />} + {hasUnsavedChanges && <span className="text-amber-500 text-xs">수정됨</span>} + </div> + </div> + </div> + <DropdownMenuSeparator /> + </> + )} + + {/* 빠른 액션 */} + <DropdownMenuItem onClick={() => setIsCreateDialogOpen(true)}> + <Plus size={14} className="mr-2" /> + 현재 설정으로 새 맞춤 설정 만들기 + </DropdownMenuItem> + + {activePresetId && ( + <DropdownMenuItem onClick={handleUpdateCurrentPreset}> + <Save size={14} className="mr-2" /> + {hasUnsavedChanges ? '변경 내용 저장' : '현재 설정 업데이트'} + </DropdownMenuItem> + )} + + <DropdownMenuSeparator /> + + {/* 맞춤 설정 목록 */} + <DropdownMenuLabel className="text-xs text-muted-foreground"> + 저장된 맞춤 설정 + </DropdownMenuLabel> + + {presets.length === 0 ? ( + <div className="px-2 py-2 text-center text-sm text-muted-foreground"> + 저장된 프리셋이 없습니다 + </div> + ) : ( + presets.map((preset) => ( + <DropdownMenuSub key={preset.id}> + <DropdownMenuSubTrigger> + <div className="flex items-center w-full"> + {preset.id === activePresetId ? ( + <Check size={14} className="mr-2" /> + ) : ( + <div className="w-[14px] mr-2" /> + )} + <span className={preset.id === activePresetId ? "font-medium" : ""}> + {preset.name} + </span> + {preset.isDefault && <Star size={12} className="ml-1 text-yellow-500" />} + </div> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-48"> + <DropdownMenuItem onClick={() => handleApplyPreset(preset.id)}> + {processingPresetId === preset.id ? ( + <Loader2 size={14} className="mr-2 animate-spin" /> + ) : ( + <Check size={14} className="mr-2" /> + )} + 적용 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => openRenameDialog(preset)}> + <Edit size={14} className="mr-2" /> + 이름 변경 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onSetDefaultPreset(preset.id)}> + <Star size={14} className="mr-2" /> + {preset.isDefault ? '기본 해제' : '기본으로 설정'} + </DropdownMenuItem> + <DropdownMenuSeparator /> + {presets.length > 1 && ( + <DropdownMenuItem + onClick={() => onDeletePreset(preset.id)} + className="text-red-600 focus:text-red-600" + > + <Trash2 size={14} className="mr-2" /> + 삭제 + </DropdownMenuItem> + )} + </DropdownMenuSubContent> + </DropdownMenuSub> + )) + )} + </DropdownMenuContent> + </DropdownMenu> + + {/* Create Dialog */} + <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>새 맞춤 설정 저장</DialogTitle> + <DialogDescription> + 현재 테이블 설정을 새로운 프리셋으로 저장합니다. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="presetName" className="text-right"> + 이름 + </Label> + <div className="col-span-3"> + <Input + id="presetName" + value={newPresetName} + onChange={(e) => setNewPresetName(e.target.value)} + placeholder="맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreatePreset() + } + }} + /> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label className="text-right">옵션</Label> + <div className="col-span-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id="isDefault" + checked={isDefaultPreset} + onCheckedChange={(checked) => setIsDefaultPreset(checked as boolean)} + /> + <Label htmlFor="isDefault" className="text-sm font-normal"> + 기본 프리셋으로 설정 + </Label> + </div> + <p className="text-xs text-muted-foreground mt-1"> + 기본 프리셋은 테이블 로드 시 자동으로 적용됩니다. + </p> + </div> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setIsCreateDialogOpen(false) + setNewPresetName('') + setIsDefaultPreset(false) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={handleCreatePreset} + disabled={!newPresetName.trim()} + > + <Save size={14} className="mr-2" /> + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Rename Dialog */} + <Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>맞춤 설정 이름 변경</DialogTitle> + <DialogDescription> + '{presets.find(p => p.id === renamingPresetId)?.name}' 프리셋의 이름을 변경합니다. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="renamePresetName" className="text-right"> + 새 이름 + </Label> + <div className="col-span-3"> + <Input + id="renamePresetName" + value={newPresetName} + onChange={(e) => setNewPresetName(e.target.value)} + placeholder="새 맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenamePreset() + } + }} + /> + </div> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setIsRenameDialogOpen(false) + setNewPresetName('') + setRenamingPresetId(null) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={handleRenamePreset} + disabled={!newPresetName.trim()} + > + <Edit size={14} className="mr-2" /> + 변경 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/components/data-table/use-table-presets.tsx b/components/data-table/use-table-presets.tsx new file mode 100644 index 00000000..5e641762 --- /dev/null +++ b/components/data-table/use-table-presets.tsx @@ -0,0 +1,338 @@ +// hooks/use-table-presets.ts +import { TableSettings } from "@/db/schema" +import { useSession } from "next-auth/react" +import { useCallback, useEffect, useState } from "react" +import { toast } from "sonner" +import useSWR from "swr" +import { useSearchParams } from "next/navigation" + +interface TablePreset { + id: string + userId: string + tableId: string + name: string + settings: TableSettings + isDefault: boolean + isActive: boolean + isShared: boolean + createdAt: Date + updatedAt: Date +} + +export function useTablePresets<TData>( + tableId: string, + initialSettings: TableSettings<TData> +) { + const { data: session } = useSession() + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [isClient, setIsClient] = useState(false) + const searchParams = useSearchParams() + + // 클라이언트 마운트 확인 + useEffect(() => { + setIsClient(true) + }, []) + + // SWR을 사용한 데이터 페칭 + const { + data: presets, + error, + mutate, + isLoading + } = useSWR<TablePreset[]>( + session?.user ? `/api/table-presets?tableId=${tableId}` : null, + (url: string) => fetch(url).then(res => res.json()), + { + revalidateOnFocus: false, + revalidateOnReconnect: true, + } + ) + + const activePreset = presets?.find(p => p.isActive) + + // 현재 설정 가져오기 (URL + 활성 프리셋의 클라이언트 상태) + const getCurrentSettings = useCallback((): TableSettings<TData> => { + // 서버 렌더링 중이면 initialSettings 사용 + if (!isClient || typeof window === 'undefined') { + return initialSettings + } + + // 클라이언트에서만 URL 파라미터 읽기 + const urlSettings = { + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: JSON.parse(searchParams.get('sort') || JSON.stringify(initialSettings.sort)), + filters: JSON.parse(searchParams.get('filters') || '[]'), + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: JSON.parse(searchParams.get('basicFilters') || '[]'), + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + from: searchParams.get('from') || undefined, + to: searchParams.get('to') || undefined, + } + + // 활성 프리셋의 클라이언트 설정 병합 + if (activePreset) { + return { + ...urlSettings, + columnVisibility: activePreset.settings.columnVisibility || {}, + columnOrder: activePreset.settings.columnOrder || [], + pinnedColumns: activePreset.settings.pinnedColumns || { left: [], right: [] }, + groupBy: activePreset.settings.groupBy || [], + expandedRows: activePreset.settings.expandedRows || [] + } + } + + return urlSettings + }, [activePreset, initialSettings, isClient, searchParams]) + + // 프리셋 생성 + const createPreset = useCallback(async ( + name: string, + settings: TableSettings<TData>, + isDefault = false + ) => { + try { + const response = await fetch('/api/table-presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tableId, + name, + settings, + isDefault + }) + }) + + if (!response.ok) { + throw new Error('Failed to create preset') + } + + await mutate() + toast.success(`프리셋 '${name}'이 저장되었습니다`) + return true + } catch (error) { + console.error('Error creating preset:', error) + toast.error('프리셋 저장 중 오류가 발생했습니다') + return false + } + }, [tableId, mutate]) + + // 프리셋 적용 + const applyPreset = useCallback(async (presetId: string) => { + try { + // 이전 활성 프리셋 비활성화 (있는 경우) + if (activePreset && activePreset.id !== presetId) { + await fetch(`/api/table-presets/${activePreset.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: false }) + }) + } + + // 새 프리셋 활성화 + await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: true }) + }) + + const preset = presets?.find(p => p.id === presetId) + if (!preset) return false + + // 클라이언트에서만 URL 업데이트 + if (isClient && typeof window !== 'undefined') { + const params = new URLSearchParams() + if (preset.settings.page !== 1) params.set('page', preset.settings.page.toString()) + if (preset.settings.perPage !== 10) params.set('perPage', preset.settings.perPage.toString()) + if (preset.settings.sort && preset.settings.sort.length > 0) params.set('sort', JSON.stringify(preset.settings.sort)) + if (preset.settings.filters && preset.settings.filters.length > 0) params.set('filters', JSON.stringify(preset.settings.filters)) + if (preset.settings.joinOperator !== 'and') params.set('joinOperator', preset.settings.joinOperator) + if (preset.settings.basicFilters && preset.settings.basicFilters.length > 0) params.set('basicFilters', JSON.stringify(preset.settings.basicFilters)) + if (preset.settings.basicJoinOperator !== 'and') params.set('basicJoinOperator', preset.settings.basicJoinOperator) + if (preset.settings.search) params.set('search', preset.settings.search) + if (preset.settings.from) params.set('from', preset.settings.from) + if (preset.settings.to) params.set('to', preset.settings.to) + + const url = window.location.pathname + '?' + params.toString() + window.history.pushState({}, '', url) + } + + await mutate() + + // Next.js App Router의 경우 router.push나 window.location.reload 사용 + if (isClient && typeof window !== 'undefined') { + window.location.reload() + } + + return true + } catch (error) { + console.error('Error applying preset:', error) + toast.error('프리셋 적용 중 오류가 발생했습니다') + return false + } + }, [activePreset, presets, mutate, isClient]) + + // 프리셋 업데이트 + const updatePreset = useCallback(async ( + presetId: string, + settings: TableSettings<TData> + ) => { + try { + const response = await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }) + }) + + if (!response.ok) { + throw new Error('Failed to update preset') + } + + await mutate() + const preset = presets?.find(p => p.id === presetId) + toast.success(`${preset?.name || '프리셋'}이 업데이트되었습니다`) + } catch (error) { + console.error('Error updating preset:', error) + toast.error('프리셋 업데이트 중 오류가 발생했습니다') + } + }, [presets, mutate]) + + // 프리셋 삭제 + const deletePreset = useCallback(async (presetId: string) => { + try { + const response = await fetch(`/api/table-presets/${presetId}`, { + method: 'DELETE' + }) + + if (!response.ok) { + throw new Error('Failed to delete preset') + } + + // 삭제할 프리셋이 활성 프리셋인 경우 다른 프리셋을 활성화 + if (activePreset?.id === presetId && presets && presets.length > 1) { + const defaultPreset = presets.find(p => p.isDefault && p.id !== presetId) || + presets.find(p => p.id !== presetId) + if (defaultPreset) { + await applyPreset(defaultPreset.id) + return true + } + } + + await mutate() + const preset = presets?.find(p => p.id === presetId) + toast.success(`프리셋 '${preset?.name}'이 삭제되었습니다`) + return true + } catch (error) { + console.error('Error deleting preset:', error) + toast.error('프리셋 삭제 중 오류가 발생했습니다') + return false + } + }, [activePreset, presets, mutate, applyPreset]) + + // 기본 프리셋 설정 + const setDefaultPreset = useCallback(async (presetId: string) => { + try { + // 기존 기본 프리셋 해제 + const defaultPreset = presets?.find(p => p.isDefault) + if (defaultPreset && defaultPreset.id !== presetId) { + await fetch(`/api/table-presets/${defaultPreset.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isDefault: false }) + }) + } + + // 새 기본 프리셋 설정 + await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isDefault: true }) + }) + + await mutate() + toast.success('기본 프리셋이 변경되었습니다') + } catch (error) { + console.error('Error setting default preset:', error) + toast.error('기본 프리셋 설정 중 오류가 발생했습니다') + } + }, [presets, mutate]) + + // 프리셋 이름 변경 + const renamePreset = useCallback(async (presetId: string, newName: string) => { + try { + if (!newName.trim()) { + toast.error('프리셋 이름을 입력해주세요') + return false + } + + const response = await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName.trim() }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || 'Failed to rename preset') + } + + await mutate() + toast.success('프리셋 이름이 변경되었습니다') + return true + } catch (error) { + console.error('Error renaming preset:', error) + toast.error('프리셋 이름 변경 중 오류가 발생했습니다') + return false + } + }, [mutate]) + + // 클라이언트 상태 업데이트 (컬럼 가시성, 핀 등) + const updateClientState = useCallback(async (newClientState: Partial<TableSettings<TData>>) => { + if (!activePreset) return + + const updatedSettings = { + ...activePreset.settings, + ...newClientState + } + + await updatePreset(activePreset.id, updatedSettings) + }, [activePreset, updatePreset]) + + // URL 변경 감지 및 미저장 변경사항 체크 + useEffect(() => { + if (!isClient || !presets || !activePreset) return + + const currentSettings = getCurrentSettings() + + // 현재 URL 설정과 활성 프리셋 설정 비교 + const isSettingsChanged = + currentSettings.perPage !== activePreset.settings.perPage || + JSON.stringify(currentSettings.sort) !== JSON.stringify(activePreset.settings.sort) || + JSON.stringify(currentSettings.filters) !== JSON.stringify(activePreset.settings.filters) || + currentSettings.joinOperator !== activePreset.settings.joinOperator || + JSON.stringify(currentSettings.basicFilters) !== JSON.stringify(activePreset.settings.basicFilters) || + currentSettings.basicJoinOperator !== activePreset.settings.basicJoinOperator || + currentSettings.search !== activePreset.settings.search + + setHasUnsavedChanges(isSettingsChanged) + }, [isClient, presets, activePreset, getCurrentSettings]) + + return { + // 상태 + presets: presets || [], + activePresetId: activePreset?.id || null, + isLoading, + hasUnsavedChanges, + + // 액션 + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/add-vendor-dialog.tsx b/components/po-rfq/detail-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..5e83ad8f --- /dev/null +++ b/components/po-rfq/detail-table/add-vendor-dialog.tsx @@ -0,0 +1,512 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react" + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { ProcurementRfqsView } from "@/db/schema" +import { addVendorToRfq } from "@/lib/procurement-rfqs/services" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 필수 필드를 위한 커스텀 레이블 컴포넌트 +const RequiredLabel = ({ children }: { children: React.ReactNode }) => ( + <FormLabel> + {children} <span className="text-red-500">*</span> + </FormLabel> +); + +// 폼 유효성 검증 스키마 +const vendorFormSchema = z.object({ + vendorId: z.string().min(1, "벤더를 선택해주세요"), + currency: z.string().min(1, "통화를 선택해주세요"), + paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), + incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), + incotermsDetail: z.string().optional(), + deliveryDate: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + materialPriceRelatedYn: z.boolean().default(false), +}) + +type VendorFormValues = z.infer<typeof vendorFormSchema> + +interface AddVendorDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: ProcurementRfqsView | null + // 벤더 및 기타 옵션 데이터를 prop으로 받음 + vendors?: { id: number; vendorName: string; vendorCode: string }[] + currencies?: { code: string; name: string }[] + paymentTerms?: { code: string; description: string }[] + incoterms?: { code: string; description: string }[] + onSuccess?: () => void + existingVendorIds?: number[] + +} + +export function AddVendorDialog({ + open, + onOpenChange, + selectedRfq, + vendors = [], + currencies = [], + paymentTerms = [], + incoterms = [], + onSuccess, + existingVendorIds = [], // 기본값 빈 배열 +}: AddVendorDialogProps) { + + + const availableVendors = React.useMemo(() => { + return vendors.filter(vendor => !existingVendorIds.includes(vendor.id)); + }, [vendors, existingVendorIds]); + + + // 파일 업로드 상태 관리 + const [attachments, setAttachments] = useState<File[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + // 벤더 선택을 위한 팝오버 상태 + const [vendorOpen, setVendorOpen] = useState(false) + + const form = useForm<VendorFormValues>({ + resolver: zodResolver(vendorFormSchema), + defaultValues: { + vendorId: "", + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + deliveryDate: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + }, + }) + + // 폼 제출 핸들러 + async function onSubmit(values: VendorFormValues) { + if (!selectedRfq) { + toast.error("선택된 RFQ가 없습니다") + return + } + + try { + setIsSubmitting(true) + + // FormData 생성 + const formData = new FormData() + formData.append("rfqId", selectedRfq.id.toString()) + + // 폼 데이터 추가 + Object.entries(values).forEach(([key, value]) => { + formData.append(key, value.toString()) + }) + + // 첨부파일 추가 + attachments.forEach((file, index) => { + formData.append(`attachment-${index}`, file) + }) + + // 서버 액션 호출 + const result = await addVendorToRfq(formData) + + if (result.success) { + toast.success("벤더가 성공적으로 추가되었습니다") + onOpenChange(false) + form.reset() + setAttachments([]) + onSuccess?.() + } else { + toast.error(result.message || "벤더 추가 중 오류가 발생했습니다") + } + } catch (error) { + console.error("벤더 추가 오류:", error) + toast.error("벤더 추가 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // 파일 업로드 핸들러 + const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.files && event.target.files.length > 0) { + const newFiles = Array.from(event.target.files) + setAttachments((prev) => [...prev, ...newFiles]) + } + } + + // 파일 삭제 핸들러 + const handleRemoveFile = (index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} + <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden"> + {/* 고정 헤더 */} + <div className="p-6 border-b"> + <DialogHeader> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + {selectedRfq ? ( + <> + <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. + </> + ) : ( + "RFQ에 벤더를 추가합니다." + )} + </DialogDescription> + </DialogHeader> + </div> + + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto p-6"> + <Form {...form}> + <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 검색 가능한 벤더 선택 필드 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <RequiredLabel>벤더</RequiredLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? vendors.find((vendor) => String(vendor.id) === field.value) + ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` + : "벤더를 선택하세요" + : "벤더를 선택하세요"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="벤더 검색..." /> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandList> + <ScrollArea className="h-60"> + <CommandGroup> + {availableVendors.length > 0 ? ( + availableVendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + String(vendor.id) === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {vendor.vendorName} ({vendor.vendorCode}) + </CommandItem> + )) + ) : ( + <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem> + )} + </CommandGroup> + </ScrollArea> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem> + <RequiredLabel>통화</RequiredLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {currencies.map((currency) => ( + <SelectItem key={currency.code} value={currency.code}> + {currency.name} ({currency.code}) + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem> + <RequiredLabel>지불 조건</RequiredLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="지불 조건 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {paymentTerms.map((term) => ( + <SelectItem key={term.code} value={term.code}> + {term.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <RequiredLabel>인코텀즈</RequiredLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {incoterms.map((incoterm) => ( + <SelectItem key={incoterm.code} value={incoterm.code}> + {incoterm.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 나머지 필드들은 동일하게 유지 */} + <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormLabel>인코텀즈 세부사항</FormLabel> + <FormControl> + <Input {...field} placeholder="인코텀즈 세부사항" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>납품 예정일</FormLabel> + <FormControl> + <Input {...field} type="date" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem> + <FormLabel>세금 코드</FormLabel> + <FormControl> + <Input {...field} placeholder="세금 코드" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem> + <FormLabel>선적지</FormLabel> + <FormControl> + <Input {...field} placeholder="선적지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem> + <FormLabel>도착지</FormLabel> + <FormControl> + <Input {...field} placeholder="도착지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <input + type="checkbox" + checked={field.value} + onChange={field.onChange} + className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>자재 가격 관련 여부</FormLabel> + </div> + </FormItem> + )} + /> + + {/* 파일 업로드 섹션 */} + <div className="space-y-2"> + <Label>첨부 파일</Label> + <div className="border rounded-md p-4"> + <div className="flex items-center justify-center w-full"> + <label + htmlFor="file-upload" + className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" + > + <div className="flex flex-col items-center justify-center pt-5 pb-6"> + <Upload className="w-8 h-8 mb-2 text-gray-500" /> + <p className="mb-2 text-sm text-gray-500"> + <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요 + </p> + <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p> + </div> + <input + id="file-upload" + type="file" + className="hidden" + multiple + onChange={handleFileUpload} + /> + </label> + </div> + + {/* 업로드된 파일 목록 */} + {attachments.length > 0 && ( + <div className="mt-4 space-y-2"> + <h4 className="text-sm font-medium">업로드된 파일</h4> + <ul className="space-y-2"> + {attachments.map((file, index) => ( + <li + key={index} + className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md" + > + <div className="flex items-center space-x-2"> + <File className="w-4 h-4 text-gray-500" /> + <span className="truncate max-w-[250px]">{file.name}</span> + <span className="text-gray-500 text-xs"> + ({(file.size / 1024).toFixed(1)} KB) + </span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleRemoveFile(index)} + > + <X className="w-4 h-4 text-gray-500" /> + </Button> + </li> + ))} + </ul> + </div> + )} + </div> + </div> + </form> + </Form> + </div> + + {/* 고정 푸터 */} + <div className="p-6 border-t"> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + form="vendor-form" + disabled={isSubmitting} + > + {isSubmitting ? "처리 중..." : "벤더 추가"} + </Button> + </DialogFooter> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/delete-vendor-dialog.tsx b/components/po-rfq/detail-table/delete-vendor-dialog.tsx new file mode 100644 index 00000000..49d982e1 --- /dev/null +++ b/components/po-rfq/detail-table/delete-vendor-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type RfqDetailView } from "./rfq-detail-column" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { deleteRfqDetail } from "@/lib/procurement-rfqs/services" + + +interface DeleteRfqDetailDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + detail: RfqDetailView | null + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteRfqDetailDialog({ + detail, + showTrigger = true, + onSuccess, + ...props +}: DeleteRfqDetailDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + if (!detail) return + + startDeleteTransition(async () => { + try { + const result = await deleteRfqDetail(detail.detailId) + + if (!result.success) { + toast.error(result.message || "삭제 중 오류가 발생했습니다") + return + } + + props.onOpenChange?.(false) + toast.success("RFQ 벤더 정보가 삭제되었습니다") + onSuccess?.() + } catch (error) { + console.error("RFQ 벤더 삭제 오류:", error) + toast.error("삭제 중 오류가 발생했습니다") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="destructive" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택한 RFQ 벤더 정보 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="destructive" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택한 RFQ 벤더 정보 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/rfq-detail-column.tsx b/components/po-rfq/detail-table/rfq-detail-column.tsx new file mode 100644 index 00000000..31f251ce --- /dev/null +++ b/components/po-rfq/detail-table/rfq-detail-column.tsx @@ -0,0 +1,369 @@ +"use client" + +import * as React from "react" +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { formatDate, formatDateTime } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Ellipsis, MessageCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export interface DataTableRowAction<TData> { + row: Row<TData>; + type: "delete" | "update" | "communicate"; // communicate 타입 추가 +} + +// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요) +export interface RfqDetailView { + detailId: number + rfqId: number + rfqCode: string + vendorId?: number | null // 벤더 ID 필드 추가 + projectCode: string | null + projectName: string | null + vendorCountry: string | null + itemCode: string | null + itemName: string | null + vendorName: string | null + vendorCode: string | null + currency: string | null + paymentTermsCode: string | null + paymentTermsDescription: string | null + incotermsCode: string | null + incotermsDescription: string | null + incotermsDetail: string | null + deliveryDate: Date | null + taxCode: string | null + placeOfShipping: string | null + placeOfDestination: string | null + materialPriceRelatedYn: boolean | null + hasQuotation: boolean | null + updatedByUserName: string | null + quotationStatus: string | null + updatedAt: Date | null + prItemsCount: number + majorItemsCount: number + quotationVersion:number | null + // 커뮤니케이션 관련 필드 추가 + commentCount?: number // 전체 코멘트 수 + unreadCount?: number // 읽지 않은 코멘트 수 + lastCommentDate?: Date // 마지막 코멘트 날짜 +} + +interface GetColumnsProps<TData> { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TData> | null> + >; + unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수 +} + +export function getRfqDetailColumns({ + setRowAction, + unreadMessages = {}, +}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { + return [ + { + accessorKey: "quotationStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 상태" /> + ), + cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>, + meta: { + excelHeader: "견적 상태" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "quotationVersion", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 버전" /> + ), + cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>, + meta: { + excelHeader: "견적 버전" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>, + meta: { + excelHeader: "벤더 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더명" /> + ), + cell: ({ row }) => <div>{row.getValue("vendorName")}</div>, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "vendorType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="내외자" /> + ), + cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>, + meta: { + excelHeader: "내외자" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="통화" /> + ), + cell: ({ row }) => <div>{row.getValue("currency")}</div>, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>, + meta: { + excelHeader: "지불 조건 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "paymentTermsDescription", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="지불 조건" /> + ), + cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>, + meta: { + excelHeader: "지불 조건" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>, + meta: { + excelHeader: "인코텀스 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "incotermsDescription", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인코텀스" /> + ), + cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>, + meta: { + excelHeader: "인코텀스" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsDetail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" /> + ), + cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>, + meta: { + excelHeader: "인코텀스 상세" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="납품일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "납품일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "taxCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="세금 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("taxCode")}</div>, + meta: { + excelHeader: "세금 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="선적지" /> + ), + cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>, + meta: { + excelHeader: "선적지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="도착지" /> + ), + cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>, + meta: { + excelHeader: "도착지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "materialPriceRelatedYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="원자재 가격 연동" /> + ), + cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>, + meta: { + excelHeader: "원자재 가격 연동" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정자" /> + ), + cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>, + meta: { + excelHeader: "수정자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일시" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDateTime(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "수정일시" + }, + enableResizing: true, + size: 140, + }, + // 커뮤니케이션 컬럼 추가 + { + id: "communication", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" /> + ), + cell: ({ row }) => { + const vendorId = row.original.vendorId || 0; + const unreadCount = unreadMessages[vendorId] || 0; + + return ( + <Button + variant="ghost" + size="sm" + className="relative p-0 h-8 w-8 flex items-center justify-center" + onClick={() => setRowAction({ row, type: "communicate" })} + > + <MessageCircle className="h-4 w-4" /> + {unreadCount > 0 && ( + <Badge + variant="destructive" + className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs" + > + {unreadCount} + </Badge> + )} + </Button> + ); + }, + enableResizing: false, + size: 80, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/rfq-detail-table.tsx b/components/po-rfq/detail-table/rfq-detail-table.tsx new file mode 100644 index 00000000..787b7c3b --- /dev/null +++ b/components/po-rfq/detail-table/rfq-detail-table.tsx @@ -0,0 +1,519 @@ +"use client" + +import * as React from "react" +import { useEffect, useState } from "react" +import { + DataTableRowAction, + getRfqDetailColumns, + RfqDetailView +} from "./rfq-detail-column" +import { toast } from "sonner" + +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { ProcurementRfqsView } from "@/db/schema" +import { + fetchCurrencies, + fetchIncoterms, + fetchPaymentTerms, + fetchRfqDetails, + fetchVendors, + fetchUnreadMessages +} from "@/lib/procurement-rfqs/services" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { AddVendorDialog } from "./add-vendor-dialog" +import { Button } from "@/components/ui/button" +import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가 +import { DeleteRfqDetailDialog } from "./delete-vendor-dialog" +import { UpdateRfqDetailSheet } from "./update-vendor-sheet" +import { VendorCommunicationDrawer } from "./vendor-communication-drawer" +import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트 + +// 프로퍼티 정의 +interface RfqDetailTablesProps { + selectedRfq: ProcurementRfqsView | null +} + +// 데이터 타입 정의 +interface Vendor { + id: number; + vendorName: string; + vendorCode: string | null; // Update this to allow null + // 기타 필요한 벤더 속성들 +} + +interface Currency { + code: string; + name: string; +} + +interface PaymentTerm { + code: string; + description: string; +} + +interface Incoterm { + code: string; + description: string; +} + +export function RfqDetailTables({ selectedRfq }: RfqDetailTablesProps) { + + console.log("selectedRfq", selectedRfq) + // 상태 관리 + const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [details, setDetails] = useState<RfqDetailView[]>([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null) + + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [currencies, setCurrencies] = React.useState<Currency[]>([]) + const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([]) + const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) + const [isUnreadLoading, setIsUnreadLoading] = useState(false) + + // 견적 비교 다이얼로그 상태 관리 (추가) + const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) + + const existingVendorIds = React.useMemo(() => { + return details.map(detail => Number(detail.vendorId)).filter(Boolean); + }, [details]); + + const handleAddVendor = async () => { + try { + setIsAdddialogLoading(true) + + // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) + const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + fetchVendors(), + fetchCurrencies(), + fetchPaymentTerms(), + fetchIncoterms() + ]) + + setVendors(vendorsData.data || []) + setCurrencies(currenciesData.data || []) + setPaymentTerms(paymentTermsData.data || []) + setIncoterms(incotermsData.data || []) + + setVendorDialogOpen(true) + } catch (error) { + console.error("데이터 로드 오류:", error) + toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsAdddialogLoading(false) + } + } + + // 견적 비교 다이얼로그 열기 핸들러 (추가) + const handleOpenComparisonDialog = () => { + // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 + const hasSubmittedQuotations = details.some(detail => + detail.hasQuotation && detail.quotationStatus === "Submitted" + ); + + if (!hasSubmittedQuotations) { + toast.warning("제출된 견적이 없습니다."); + return; + } + + setComparisonDialogOpen(true); + } + + // 읽지 않은 메시지 로드 + const loadUnreadMessages = async () => { + if (!selectedRfq || !selectedRfq.id) return; + + try { + setIsUnreadLoading(true); + + // 읽지 않은 메시지 수 가져오기 + const unreadData = await fetchUnreadMessages(selectedRfq.id); + setUnreadMessages(unreadData); + } catch (error) { + console.error("읽지 않은 메시지 로드 오류:", error); + // 조용히 실패 - 사용자에게 알림 표시하지 않음 + } finally { + setIsUnreadLoading(false); + } + }; + + // 칼럼 정의 - unreadMessages 상태 전달 + const columns = React.useMemo(() => + getRfqDetailColumns({ + setRowAction, + unreadMessages + }), [unreadMessages]) + + // 필터 필드 정의 (필터 사용 시) + const advancedFilterFields = React.useMemo( + () => [ + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "currency", + label: "통화", + type: "text", + }, + ], + [] + ) + + // RFQ ID가 변경될 때 데이터 로드 + useEffect(() => { + async function loadRfqDetails() { + if (!selectedRfq || !selectedRfq.id) { + setDetails([]) + return + } + + try { + setIsLoading(true) + const transformRfqDetails = (data: any[]): RfqDetailView[] => { + return data.map(item => ({ + ...item, + // Convert vendorId from string|null to number|undefined + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + // Transform any other fields that need type conversion + })); + }; + + // Then in your useEffect: + const result = await fetchRfqDetails(selectedRfq.id); + setDetails(transformRfqDetails(result.data)); + + // 읽지 않은 메시지 개수 로드 + await loadUnreadMessages(); + } catch (error) { + console.error("RFQ 디테일 로드 오류:", error) + setDetails([]) + toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + loadRfqDetails() + }, [selectedRfq]) + + // 주기적으로 읽지 않은 메시지 갱신 (60초마다) + useEffect(() => { + if (!selectedRfq || !selectedRfq.id) return; + + const intervalId = setInterval(() => { + loadUnreadMessages(); + }, 60000); // 60초마다 갱신 + + return () => clearInterval(intervalId); + }, [selectedRfq]); + + // rowAction 처리 + useEffect(() => { + if (!rowAction) return + + const handleRowAction = async () => { + try { + // 통신 액션인 경우 드로어 열기 + if (rowAction.type === "communicate") { + setSelectedVendor(rowAction.row.original); + setCommunicationDrawerOpen(true); + + // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) + const vendorId = rowAction.row.original.vendorId; + if (vendorId) { + setUnreadMessages(prev => ({ + ...prev, + [vendorId]: 0 + })); + } + + // rowAction 초기화 + setRowAction(null); + return; + } + + // 다른 액션들은 기존과 동일하게 처리 + setIsAdddialogLoading(true); + + // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) + const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + fetchVendors(), + fetchCurrencies(), + fetchPaymentTerms(), + fetchIncoterms() + ]); + + setVendors(vendorsData.data || []); + setCurrencies(currenciesData.data || []); + setPaymentTerms(paymentTermsData.data || []); + setIncoterms(incotermsData.data || []); + + // 이제 데이터가 로드되었으므로 필요한 작업 수행 + if (rowAction.type === "update") { + setSelectedDetail(rowAction.row.original); + setUpdateSheetOpen(true); + } else if (rowAction.type === "delete") { + setSelectedDetail(rowAction.row.original); + setDeleteDialogOpen(true); + } + } catch (error) { + console.error("데이터 로드 오류:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다"); + } finally { + // communicate 타입이 아닌 경우에만 로딩 상태 변경 + if (rowAction && rowAction.type !== "communicate") { + setIsAdddialogLoading(false); + } + } + }; + + handleRowAction(); + }, [rowAction]) + + // RFQ가 선택되지 않은 경우 + if (!selectedRfq) { + return ( + <div className="flex h-full items-center justify-center text-muted-foreground"> + RFQ를 선택하세요 + </div> + ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( + <div className="p-4 space-y-4"> + <Skeleton className="h-8 w-1/2" /> + <Skeleton className="h-24 w-full" /> + <Skeleton className="h-48 w-full" /> + </div> + ) + } + + const handleRefreshData = async () => { + if (!selectedRfq || !selectedRfq.id) return + + try { + setIsRefreshing(true) + + const transformRfqDetails = (data: any[]): RfqDetailView[] => { + return data.map(item => ({ + ...item, + // Convert vendorId from string|null to number|undefined + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + // Transform any other fields that need type conversion + })); + }; + + // Then in your useEffect: + const result = await fetchRfqDetails(selectedRfq.id); + setDetails(transformRfqDetails(result.data)); + + // 읽지 않은 메시지 개수 업데이트 + await loadUnreadMessages(); + + toast.success("데이터가 새로고침되었습니다") + } catch (error) { + console.error("RFQ 디테일 로드 오류:", error) + toast.error("데이터 새로고침 중 오류가 발생했습니다") + } finally { + setIsRefreshing(false) + } + } + + // 전체 읽지 않은 메시지 수 계산 + const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0); + + // 견적이 있는 벤더 수 계산 + const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length; + + return ( + <div className="p-4 h-full overflow-auto"> + + {/* 메시지 및 새로고침 영역 */} + + + {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + + <ClientDataTable + columns={columns} + data={details} + advancedFilterFields={advancedFilterFields} + > + + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2 mr-2"> + {totalUnreadMessages > 0 && ( + <Badge variant="destructive" className="h-6"> + 읽지 않은 메시지: {totalUnreadMessages}건 + </Badge> + )} + {vendorsWithQuotations > 0 && ( + <Badge variant="outline" className="h-6"> + 견적 제출: {vendorsWithQuotations}개 벤더 + </Badge> + )} + </div> + <div className="flex gap-2"> + {/* 견적 비교 버튼 추가 */} + <Button + variant="outline" + size="sm" + onClick={handleOpenComparisonDialog} + className="gap-2" + disabled={ + !selectedRfq || + details.length === 0 || + (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate)) + } + > + <BarChart2 className="size-4" aria-hidden="true" /> + <span>견적 비교</span> + </Button> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isRefreshing} + > + {isRefreshing ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 새로고침 중... + </> + ) : ( + '새로고침' + )} + </Button> + </div> + </div> + <Button + variant="outline" + size="sm" + onClick={handleAddVendor} + className="gap-2" + disabled={!selectedRfq || isAdddialogLoading} + > + {isAdddialogLoading ? ( + <> + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + <span>로딩 중...</span> + </> + ) : ( + <> + <UserPlus className="size-4" aria-hidden="true" /> + <span>벤더 추가</span> + </> + )} + </Button> + </ClientDataTable> + + ) : ( + <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md"> + <div className="flex flex-col items-center gap-4"> + <p>RFQ에 대한 세부 정보가 없습니다</p> + <Button + variant="outline" + size="sm" + onClick={handleAddVendor} + className="gap-2" + disabled={!selectedRfq || isAdddialogLoading} + > + {isAdddialogLoading ? ( + <> + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + <span>로딩 중...</span> + </> + ) : ( + <> + <UserPlus className="size-4" aria-hidden="true" /> + <span>벤더 추가</span> + </> + )} + </Button> + </div> + </div> + )} + + {/* 벤더 추가 다이얼로그 */} + <AddVendorDialog + open={vendorDialogOpen} + onOpenChange={(open) => { + setVendorDialogOpen(open); + if (!open) setIsAdddialogLoading(false); + }} + selectedRfq={selectedRfq} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + existingVendorIds={existingVendorIds} + /> + + {/* 벤더 정보 수정 시트 */} + <UpdateRfqDetailSheet + open={updateSheetOpen} + onOpenChange={setUpdateSheetOpen} + detail={selectedDetail} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + /> + + {/* 벤더 정보 삭제 다이얼로그 */} + <DeleteRfqDetailDialog + open={deleteDialogOpen} + onOpenChange={setDeleteDialogOpen} + detail={selectedDetail} + showTrigger={false} + onSuccess={handleRefreshData} + /> + + {/* 벤더 커뮤니케이션 드로어 */} + <VendorCommunicationDrawer + open={communicationDrawerOpen} + onOpenChange={(open) => { + setCommunicationDrawerOpen(open); + // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 + if (!open) loadUnreadMessages(); + }} + selectedRfq={selectedRfq} + selectedVendor={selectedVendor} + onSuccess={handleRefreshData} + /> + + {/* 견적 비교 다이얼로그 추가 */} + <VendorQuotationComparisonDialog + open={comparisonDialogOpen} + onOpenChange={setComparisonDialogOpen} + selectedRfq={selectedRfq} + /> + </div> + ) +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/update-vendor-sheet.tsx b/components/po-rfq/detail-table/update-vendor-sheet.tsx new file mode 100644 index 00000000..45e4a602 --- /dev/null +++ b/components/po-rfq/detail-table/update-vendor-sheet.tsx @@ -0,0 +1,449 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { RfqDetailView } from "./rfq-detail-column" +import { updateRfqDetail } from "@/lib/procurement-rfqs/services" + +// 폼 유효성 검증 스키마 +const updateRfqDetailSchema = z.object({ + vendorId: z.string().min(1, "벤더를 선택해주세요"), + currency: z.string().min(1, "통화를 선택해주세요"), + paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), + incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), + incotermsDetail: z.string().optional(), + deliveryDate: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + materialPriceRelatedYn: z.boolean().default(false), +}) + +type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema> + +// 데이터 타입 정의 +interface Vendor { + id: number; + vendorName: string; + vendorCode: string; +} + +interface Currency { + code: string; + name: string; +} + +interface PaymentTerm { + code: string; + description: string; +} + +interface Incoterm { + code: string; + description: string; +} + +interface UpdateRfqDetailSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + detail: RfqDetailView | null; + vendors: Vendor[]; + currencies: Currency[]; + paymentTerms: PaymentTerm[]; + incoterms: Incoterm[]; + onSuccess?: () => void; +} + +export function UpdateRfqDetailSheet({ + detail, + vendors, + currencies, + paymentTerms, + incoterms, + onSuccess, + ...props +}: UpdateRfqDetailSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [vendorOpen, setVendorOpen] = React.useState(false) + + const form = useForm<UpdateRfqDetailFormValues>({ + resolver: zodResolver(updateRfqDetailSchema), + defaultValues: { + vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", + currency: detail?.currency || "", + paymentTermsCode: detail?.paymentTermsCode || "", + incotermsCode: detail?.incotermsCode || "", + incotermsDetail: detail?.incotermsDetail || "", + deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", + taxCode: detail?.taxCode || "", + placeOfShipping: detail?.placeOfShipping || "", + placeOfDestination: detail?.placeOfDestination || "", + materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, + }, + }) + + // detail이 변경될 때 form 값 업데이트 + React.useEffect(() => { + if (detail) { + const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id + + form.reset({ + vendorId: vendorId ? String(vendorId) : "", + currency: detail.currency || "", + paymentTermsCode: detail.paymentTermsCode || "", + incotermsCode: detail.incotermsCode || "", + incotermsDetail: detail.incotermsDetail || "", + deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", + taxCode: detail.taxCode || "", + placeOfShipping: detail.placeOfShipping || "", + placeOfDestination: detail.placeOfDestination || "", + materialPriceRelatedYn: detail.materialPriceRelatedYn || false, + }) + } + }, [detail, form, vendors]) + + function onSubmit(values: UpdateRfqDetailFormValues) { + if (!detail) return + + startUpdateTransition(async () => { + try { + const result = await updateRfqDetail(detail.detailId, values) + + if (!result.success) { + toast.error(result.message || "수정 중 오류가 발생했습니다") + return + } + + props.onOpenChange?.(false) + toast.success("RFQ 벤더 정보가 수정되었습니다") + onSuccess?.() + } catch (error) { + console.error("RFQ 벤더 수정 오류:", error) + toast.error("수정 중 오류가 발생했습니다") + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl"> + <SheetHeader className="text-left"> + <SheetTitle>RFQ 벤더 정보 수정</SheetTitle> + <SheetDescription> + 벤더 정보를 수정하고 저장하세요 + </SheetDescription> + </SheetHeader> + <ScrollArea className="flex-1 pr-4"> + <Form {...form}> + <form + id="update-rfq-detail-form" + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* 검색 가능한 벤더 선택 필드 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? vendors.find((vendor) => String(vendor.id) === field.value) + ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` + : "벤더를 선택하세요" + : "벤더를 선택하세요"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="벤더 검색..." /> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <ScrollArea className="h-60"> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + String(vendor.id) === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {vendor.vendorName} ({vendor.vendorCode}) + </CommandItem> + ))} + </CommandGroup> + </ScrollArea> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem> + <FormLabel>통화 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {currencies.map((currency) => ( + <SelectItem key={currency.code} value={currency.code}> + {currency.name} ({currency.code}) + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem> + <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="지불 조건 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {paymentTerms.map((term) => ( + <SelectItem key={term.code} value={term.code}> + {term.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {incoterms.map((incoterm) => ( + <SelectItem key={incoterm.code} value={incoterm.code}> + {incoterm.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormLabel>인코텀즈 세부사항</FormLabel> + <FormControl> + <Input {...field} placeholder="인코텀즈 세부사항" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>납품 예정일</FormLabel> + <FormControl> + <Input {...field} type="date" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem> + <FormLabel>세금 코드</FormLabel> + <FormControl> + <Input {...field} placeholder="세금 코드" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem> + <FormLabel>선적지</FormLabel> + <FormControl> + <Input {...field} placeholder="선적지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem> + <FormLabel>도착지</FormLabel> + <FormControl> + <Input {...field} placeholder="도착지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>자재 가격 관련 여부</FormLabel> + </div> + </FormItem> + )} + /> + </form> + </Form> + </ScrollArea> + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + form="update-rfq-detail-form" + disabled={isUpdatePending} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/vendor-communication-drawer.tsx b/components/po-rfq/detail-table/vendor-communication-drawer.tsx new file mode 100644 index 00000000..34efdfc2 --- /dev/null +++ b/components/po-rfq/detail-table/vendor-communication-drawer.tsx @@ -0,0 +1,518 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { ProcurementRfqsView } from "@/db/schema" +import { RfqDetailView } from "./rfq-detail-column" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import { + Send, + Paperclip, + DownloadCloud, + File, + FileText, + Image as ImageIcon, + AlertCircle, + X +} from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { formatDateTime } from "@/lib/utils" +import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 +import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services" + +// 타입 정의 +interface Comment { + id: number; + rfqId: number; + vendorId: number | null // null 허용으로 변경 + userId?: number | null // null 허용으로 변경 + content: string; + isVendorComment: boolean | null; // null 허용으로 변경 + createdAt: Date; + updatedAt: Date; + userName?: string | null // null 허용으로 변경 + vendorName?: string | null // null 허용으로 변경 + attachments: Attachment[]; + isRead: boolean | null // null 허용으로 변경 +} + +interface Attachment { + id: number; + fileName: string; + fileSize: number; + fileType: string; + filePath: string; + uploadedAt: Date; +} + +// 프롭스 정의 +interface VendorCommunicationDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfq: ProcurementRfqsView | null; + selectedVendor: RfqDetailView | null; + onSuccess?: () => void; +} + +async function sendComment(params: { + rfqId: number; + vendorId: number; + content: string; + attachments?: File[]; +}): Promise<Comment> { + try { + // 폼 데이터 생성 (파일 첨부를 위해) + const formData = new FormData(); + formData.append('rfqId', params.rfqId.toString()); + formData.append('vendorId', params.vendorId.toString()); + formData.append('content', params.content); + formData.append('isVendorComment', 'false'); + + // 첨부파일 추가 + if (params.attachments && params.attachments.length > 0) { + params.attachments.forEach((file) => { + formData.append(`attachments`, file); + }); + } + + // API 엔드포인트 구성 + const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; + + // API 호출 + const response = await fetch(url, { + method: 'POST', + body: formData, // multipart/form-data 형식 사용 + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API 요청 실패: ${response.status} ${errorText}`); + } + + // 응답 데이터 파싱 + const result = await response.json(); + + if (!result.success || !result.data) { + throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); + } + + return result.data.comment; + } catch (error) { + console.error('코멘트 전송 오류:', error); + throw error; + } +} + +export function VendorCommunicationDrawer({ + open, + onOpenChange, + selectedRfq, + selectedVendor, + onSuccess +}: VendorCommunicationDrawerProps) { + // 상태 관리 + const [comments, setComments] = useState<Comment[]>([]); + const [newComment, setNewComment] = useState(""); + const [attachments, setAttachments] = useState<File[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef<HTMLInputElement>(null); + const messagesEndRef = useRef<HTMLDivElement>(null); + + // 첨부파일 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); + + // 드로어가 열릴 때 데이터 로드 + useEffect(() => { + if (open && selectedRfq && selectedVendor) { + loadComments(); + } + }, [open, selectedRfq, selectedVendor]); + + // 스크롤 최하단으로 이동 + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [comments]); + + // 코멘트 로드 함수 + const loadComments = async () => { + if (!selectedRfq || !selectedVendor) return; + + try { + setIsLoading(true); + + // Server Action을 사용하여 코멘트 데이터 가져오기 + const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId); + setComments(commentsData); + + // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 + await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId); + } catch (error) { + console.error("코멘트 로드 오류:", error); + toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }; + + // 파일 선택 핸들러 + const handleFileSelect = () => { + fileInputRef.current?.click(); + }; + + // 파일 변경 핸들러 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + if (e.target.files && e.target.files.length > 0) { + const newFiles = Array.from(e.target.files); + setAttachments(prev => [...prev, ...newFiles]); + } + }; + + // 파일 제거 핸들러 + const handleRemoveFile = (index: number) => { + setAttachments(prev => prev.filter((_, i) => i !== index)); + }; + + console.log(newComment) + + // 코멘트 전송 핸들러 + const handleSubmitComment = async () => { + console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) + console.log(!newComment.trim() && attachments.length === 0) + + if (!newComment.trim() && attachments.length === 0) return; + if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; + + console.log("버튼 클릭") + + try { + setIsSubmitting(true); + + // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) + const newCommentObj = await sendComment({ + rfqId: selectedRfq.id, + vendorId: selectedVendor.vendorId, + content: newComment, + attachments: attachments + }); + + // 상태 업데이트 + setComments(prev => [...prev, newCommentObj]); + setNewComment(""); + setAttachments([]); + + toast.success("메시지가 전송되었습니다"); + + // 데이터 새로고침 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("코멘트 전송 오류:", error); + toast.error("메시지 전송 중 오류가 발생했습니다"); + } finally { + setIsSubmitting(false); + } + }; + + // 첨부파일 미리보기 + const handleAttachmentPreview = (attachment: Attachment) => { + setSelectedAttachment(attachment); + setPreviewDialogOpen(true); + }; + + // 첨부파일 다운로드 + const handleAttachmentDownload = (attachment: Attachment) => { + // TODO: 실제 다운로드 구현 + window.open(attachment.filePath, '_blank'); + }; + + // 파일 아이콘 선택 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; + if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) + return <FileText className="h-5 w-5 text-green-500" />; + if (fileType.includes("document") || fileType.includes("word")) + return <FileText className="h-5 w-5 text-blue-500" />; + return <File className="h-5 w-5 text-gray-500" />; + }; + + // 첨부파일 미리보기 다이얼로그 + const renderAttachmentPreviewDialog = () => { + if (!selectedAttachment) return null; + + const isImage = selectedAttachment.fileType.startsWith("image/"); + const isPdf = selectedAttachment.fileType.includes("pdf"); + + return ( + <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {getFileIcon(selectedAttachment.fileType)} + {selectedAttachment.fileName} + </DialogTitle> + <DialogDescription> + {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} + </DialogDescription> + </DialogHeader> + + <div className="min-h-[300px] flex items-center justify-center p-4"> + {isImage ? ( + <img + src={selectedAttachment.filePath} + alt={selectedAttachment.fileName} + className="max-h-[500px] max-w-full object-contain" + /> + ) : isPdf ? ( + <iframe + src={`${selectedAttachment.filePath}#toolbar=0`} + className="w-full h-[500px]" + title={selectedAttachment.fileName} + /> + ) : ( + <div className="flex flex-col items-center gap-4 p-8"> + {getFileIcon(selectedAttachment.fileType)} + <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> + <Button + variant="outline" + onClick={() => handleAttachmentDownload(selectedAttachment)} + > + <DownloadCloud className="h-4 w-4 mr-2" /> + 다운로드 + </Button> + </div> + )} + </div> + </DialogContent> + </Dialog> + ); + }; + + if (!selectedRfq || !selectedVendor) { + return null; + } + + return ( + <Drawer open={open} onOpenChange={onOpenChange}> + <DrawerContent className="max-h-[85vh]"> + <DrawerHeader className="border-b"> + <DrawerTitle className="flex items-center gap-2"> + <Avatar className="h-8 w-8"> + <AvatarFallback className="bg-primary/10"> + {selectedVendor.vendorName?.[0] || 'V'} + </AvatarFallback> + </Avatar> + <div> + <span>{selectedVendor.vendorName}</span> + <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge> + </div> + </DrawerTitle> + <DrawerDescription> + RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName} + </DrawerDescription> + </DrawerHeader> + + <div className="p-0 flex flex-col h-[60vh]"> + {/* 메시지 목록 */} + <ScrollArea className="flex-1 p-4"> + {isLoading ? ( + <div className="flex h-full items-center justify-center"> + <p className="text-muted-foreground">메시지 로딩 중...</p> + </div> + ) : comments.length === 0 ? ( + <div className="flex h-full items-center justify-center"> + <div className="flex flex-col items-center gap-2"> + <AlertCircle className="h-6 w-6 text-muted-foreground" /> + <p className="text-muted-foreground">아직 메시지가 없습니다</p> + </div> + </div> + ) : ( + <div className="space-y-4"> + {comments.map(comment => ( + <div + key={comment.id} + className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`} + > + {comment.isVendorComment && ( + <Avatar className="h-8 w-8 mt-1"> + <AvatarFallback className="bg-primary/10"> + {comment.vendorName?.[0] || 'V'} + </AvatarFallback> + </Avatar> + )} + + <div className={`rounded-lg p-3 max-w-[80%] ${ + comment.isVendorComment + ? 'bg-muted' + : 'bg-primary text-primary-foreground' + }`}> + <div className="text-sm font-medium mb-1"> + {comment.isVendorComment ? comment.vendorName : comment.userName} + </div> + + {comment.content && ( + <div className="text-sm whitespace-pre-wrap break-words"> + {comment.content} + </div> + )} + + {/* 첨부파일 표시 */} + {comment.attachments.length > 0 && ( + <div className={`mt-2 pt-2 ${ + comment.isVendorComment + ? 'border-t border-t-border/30' + : 'border-t border-t-primary-foreground/20' + }`}> + {comment.attachments.map(attachment => ( + <div + key={attachment.id} + className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" + onClick={() => handleAttachmentPreview(attachment)} + > + {getFileIcon(attachment.fileType)} + <span className="flex-1 truncate">{attachment.fileName}</span> + <span className="text-xs opacity-70"> + {formatFileSize(attachment.fileSize)} + </span> + <Button + variant="ghost" + size="icon" + className="h-6 w-6 rounded-full" + onClick={(e) => { + e.stopPropagation(); + handleAttachmentDownload(attachment); + }} + > + <DownloadCloud className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + )} + + <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> + {formatDateTime(comment.createdAt)} + </div> + </div> + + {!comment.isVendorComment && ( + <Avatar className="h-8 w-8 mt-1"> + <AvatarFallback className="bg-primary/20"> + {comment.userName?.[0] || 'U'} + </AvatarFallback> + </Avatar> + )} + </div> + ))} + <div ref={messagesEndRef} /> + </div> + )} + </ScrollArea> + + {/* 선택된 첨부파일 표시 */} + {attachments.length > 0 && ( + <div className="p-2 bg-muted mx-4 rounded-md mb-2"> + <div className="text-xs font-medium mb-1">첨부파일</div> + <div className="flex flex-wrap gap-2"> + {attachments.map((file, index) => ( + <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> + {file.type.startsWith("image/") ? ( + <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> + ) : ( + <File className="h-4 w-4 mr-1 text-gray-500" /> + )} + <span className="truncate max-w-[100px]">{file.name}</span> + <Button + variant="ghost" + size="icon" + className="h-4 w-4 ml-1 p-0" + onClick={() => handleRemoveFile(index)} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {/* 메시지 입력 영역 */} + <div className="p-4 border-t"> + <div className="flex gap-2 items-end"> + <div className="flex-1"> + <Textarea + placeholder="메시지를 입력하세요..." + className="min-h-[80px] resize-none" + value={newComment} + onChange={(e) => setNewComment(e.target.value)} + /> + </div> + <div className="flex flex-col gap-2"> + <input + type="file" + ref={fileInputRef} + className="hidden" + multiple + onChange={handleFileChange} + /> + <Button + variant="outline" + size="icon" + onClick={handleFileSelect} + title="파일 첨부" + > + <Paperclip className="h-4 w-4" /> + </Button> + <Button + onClick={handleSubmitComment} + disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} + > + <Send className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + </div> + + <DrawerFooter className="border-t"> + <div className="flex justify-between"> + <Button variant="outline" onClick={() => loadComments()}> + 새로고침 + </Button> + <DrawerClose asChild> + <Button variant="outline">닫기</Button> + </DrawerClose> + </div> + </DrawerFooter> + </DrawerContent> + + {renderAttachmentPreviewDialog()} + </Drawer> + ); +}
\ No newline at end of file diff --git a/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx b/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx new file mode 100644 index 00000000..72cf187c --- /dev/null +++ b/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx @@ -0,0 +1,665 @@ +"use client" + +import * as React from "react" +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { toast } from "sonner" + +// Lucide 아이콘 +import { Plus, Minus } from "lucide-react" + +import { ProcurementRfqsView } from "@/db/schema" +import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services" +import { formatCurrency, formatDate } from "@/lib/utils" + +// 견적 정보 타입 +interface VendorQuotation { + id: number + rfqId: number + vendorId: number + vendorName?: string | null + quotationCode: string + quotationVersion: number + totalItemsCount: number + subTotal: string + taxTotal: string + discountTotal: string + totalPrice: string + currency: string + validUntil: string | Date // 수정: string | Date 허용 + estimatedDeliveryDate: string | Date // 수정: string | Date 허용 + paymentTermsCode: string + paymentTermsDescription?: string | null + incotermsCode: string + incotermsDescription?: string | null + incotermsDetail: string + status: string + remark: string + rejectionReason: string + submittedAt: string | Date // 수정: string | Date 허용 + acceptedAt: string | Date // 수정: string | Date 허용 + createdAt: string | Date // 수정: string | Date 허용 + updatedAt: string | Date // 수정: string | Date 허용 +} + +// 견적 아이템 타입 +interface QuotationItem { + id: number + quotationId: number + prItemId: number + materialCode: string | null // Changed from string to string | null + materialDescription: string | null // Changed from string to string | null + quantity: string + uom: string | null // Changed assuming this might be null + unitPrice: string + totalPrice: string + currency: string | null // Changed from string to string | null + vendorMaterialCode: string | null // Changed from string to string | null + vendorMaterialDescription: string | null // Changed from string to string | null + deliveryDate: Date | null // Changed from string to string | null + leadTimeInDays: number | null // Changed from number to number | null + taxRate: string | null // Changed from string to string | null + taxAmount: string | null // Changed from string to string | null + discountRate: string | null // Changed from string to string | null + discountAmount: string | null // Changed from string to string | null + remark: string | null // Changed from string to string | null + isAlternative: boolean | null // Changed from boolean to boolean | null + isRecommended: boolean | null // Changed from boolean to boolean | null +} + +interface VendorQuotationComparisonDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: ProcurementRfqsView | null +} + +export function VendorQuotationComparisonDialog({ + open, + onOpenChange, + selectedRfq, +}: VendorQuotationComparisonDialogProps) { + const [isLoading, setIsLoading] = useState(false) + const [quotations, setQuotations] = useState<VendorQuotation[]>([]) + const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({}) + const [activeTab, setActiveTab] = useState("summary") + + // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘 + const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({}) + + useEffect(() => { + async function loadQuotationData() { + if (!open || !selectedRfq?.id) return + + try { + setIsLoading(true) + // 1) 견적 목록 + const quotationsResult = await fetchVendorQuotations(selectedRfq.id) + const rawQuotationsData = quotationsResult.data || [] + + const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({ + id: rawData.id, + rfqId: rawData.rfqId, + vendorId: rawData.vendorId, + vendorName: rawData.vendorName || null, + quotationCode: rawData.quotationCode || '', + quotationVersion: rawData.quotationVersion || 0, + totalItemsCount: rawData.totalItemsCount || 0, + subTotal: rawData.subTotal || '0', + taxTotal: rawData.taxTotal || '0', + discountTotal: rawData.discountTotal || '0', + totalPrice: rawData.totalPrice || '0', + currency: rawData.currency || 'KRW', + validUntil: rawData.validUntil || '', + estimatedDeliveryDate: rawData.estimatedDeliveryDate || '', + paymentTermsCode: rawData.paymentTermsCode || '', + paymentTermsDescription: rawData.paymentTermsDescription || null, + incotermsCode: rawData.incotermsCode || '', + incotermsDescription: rawData.incotermsDescription || null, + incotermsDetail: rawData.incotermsDetail || '', + status: rawData.status || '', + remark: rawData.remark || '', + rejectionReason: rawData.rejectionReason || '', + submittedAt: rawData.submittedAt || '', + acceptedAt: rawData.acceptedAt || '', + createdAt: rawData.createdAt || '', + updatedAt: rawData.updatedAt || '', + })); + + setQuotations(quotationsData); + + // 벤더별로 접힘 상태 기본값(true) 설정 + const collapsedInit: Record<number, boolean> = {} + quotationsData.forEach((q) => { + collapsedInit[q.id] = true + }) + setCollapsedVendors(collapsedInit) + + // 2) 견적 아이템 + const qIds = quotationsData.map((q) => q.id) + if (qIds.length > 0) { + const itemsResult = await fetchQuotationItems(qIds) + const itemsData = itemsResult.data || [] + + const itemsByQuotation: Record<number, QuotationItem[]> = {} + itemsData.forEach((item) => { + if (!itemsByQuotation[item.quotationId]) { + itemsByQuotation[item.quotationId] = [] + } + itemsByQuotation[item.quotationId].push(item) + }) + setQuotationItems(itemsByQuotation) + } + } catch (error) { + console.error("견적 데이터 로드 오류:", error) + toast.error("견적 데이터를 불러오는 데 실패했습니다") + } finally { + setIsLoading(false) + } + } + + loadQuotationData() + }, [open, selectedRfq]) + + // 견적 상태 -> 뱃지 색 + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "Submitted": + return "default" + case "Accepted": + return "default" + case "Rejected": + return "destructive" + case "Revised": + return "destructive" + default: + return "secondary" + } + } + + // 모든 prItemId 모음 + const allItemIds = React.useMemo(() => { + const itemSet = new Set<number>() + Object.values(quotationItems).forEach((items) => { + items.forEach((it) => itemSet.add(it.prItemId)) + }) + return Array.from(itemSet) + }, [quotationItems]) + + // 아이템 찾는 함수 + const findItemByQuotationId = (prItemId: number, qid: number) => { + const items = quotationItems[qid] || [] + return items.find((i) => i.prItemId === prItemId) + } + + // 접힘 상태 토글 + const toggleVendor = (qid: number) => { + setCollapsedVendors((prev) => ({ + ...prev, + [qid]: !prev[qid], + })) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */} + <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}> + <DialogHeader> + <DialogTitle>벤더 견적 비교</DialogTitle> + <DialogDescription> + {selectedRfq + ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}` + : ""} + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="space-y-4"> + <Skeleton className="h-8 w-1/2" /> + <Skeleton className="h-48 w-full" /> + </div> + ) : quotations.length === 0 ? ( + <div className="py-8 text-center text-muted-foreground"> + 제출된(Submitted) 견적이 없습니다 + </div> + ) : ( + <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="summary">견적 요약 비교</TabsTrigger> + <TabsTrigger value="items">아이템별 비교</TabsTrigger> + </TabsList> + + {/* ======================== 요약 비교 탭 ======================== */} + <TabsContent value="summary" className="mt-4"> + {/* + table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px]) + -> 컨테이너보다 넓으면 수평 스크롤 발생. + */} + <div className="border rounded-md max-h-[60vh] overflow-auto"> + <table className="table-fixed w-full border-collapse"> + <thead className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead + className="sticky left-0 top-0 z-20 bg-background p-2" + > + 항목 + </TableHead> + {quotations.map((q) => ( + <TableHead key={q.id} className="p-2 text-center whitespace-nowrap"> + {q.vendorName || `벤더 ID: ${q.vendorId}`} + </TableHead> + ))} + </TableRow> + </thead> + <tbody> + {/* 견적 상태 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 견적 상태 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`status-${q.id}`} className="p-2"> + <Badge variant={getStatusBadgeVariant(q.status)}> + {q.status} + </Badge> + </TableCell> + ))} + </TableRow> + + {/* 견적 버전 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 견적 버전 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`version-${q.id}`} className="p-2"> + v{q.quotationVersion} + </TableCell> + ))} + </TableRow> + + {/* 총 금액 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 총 금액 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`total-${q.id}`} className="p-2 font-semibold"> + {formatCurrency(Number(q.totalPrice), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 소계 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 소계 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`subtotal-${q.id}`} className="p-2"> + {formatCurrency(Number(q.subTotal), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 세금 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 세금 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`tax-${q.id}`} className="p-2"> + {formatCurrency(Number(q.taxTotal), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 할인 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 할인 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`discount-${q.id}`} className="p-2"> + {formatCurrency(Number(q.discountTotal), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 통화 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 통화 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`currency-${q.id}`} className="p-2"> + {q.currency} + </TableCell> + ))} + </TableRow> + + {/* 유효기간 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 유효 기간 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`valid-${q.id}`} className="p-2"> + {formatDate(q.validUntil, "KR")} + </TableCell> + ))} + </TableRow> + + {/* 예상 배송일 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 예상 배송일 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`delivery-${q.id}`} className="p-2"> + {formatDate(q.estimatedDeliveryDate, "KR")} + </TableCell> + ))} + </TableRow> + + {/* 지불 조건 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 지불 조건 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`payment-${q.id}`} className="p-2"> + {q.paymentTermsDescription || q.paymentTermsCode} + </TableCell> + ))} + </TableRow> + + {/* 인코텀즈 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 인코텀즈 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`incoterms-${q.id}`} className="p-2"> + {q.incotermsDescription || q.incotermsCode} + {q.incotermsDetail && ( + <div className="text-xs text-muted-foreground mt-1"> + {q.incotermsDetail} + </div> + )} + </TableCell> + ))} + </TableRow> + + {/* 제출일 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 제출일 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`submitted-${q.id}`} className="p-2"> + {formatDate(q.submittedAt, "KR")} + </TableCell> + ))} + </TableRow> + + {/* 비고 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 비고 + </TableCell> + {quotations.map((q) => ( + <TableCell + key={`remark-${q.id}`} + className="p-2 whitespace-pre-wrap" + > + {q.remark || "-"} + </TableCell> + ))} + </TableRow> + </tbody> + </table> + </div> + </TabsContent> + + {/* ====================== 아이템별 비교 탭 ====================== */} + <TabsContent value="items" className="mt-4"> + {/* 컨테이너에 테이블 관련 클래스 직접 적용 */} + <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" > + <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}> + <table className="w-full border-collapse"> + <thead className="sticky top-0 bg-background z-10"> + {/* 첫 번째 헤더 행 */} + <tr> + {/* 첫 행: 자재(코드) 컬럼 */} + <th + rowSpan={2} + className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left" + style={{ + width: '250px', + minWidth: '250px', + backgroundColor: 'white', + }} + > + 자재 (코드) + </th> + + {/* 벤더 헤더 (접힘/펼침) */} + {quotations.map((q, index) => { + const collapsed = collapsedVendors[q.id] + // 접힌 상태면 1칸, 펼친 상태면 6칸 + return ( + <th + key={q.id} + className="p-2 text-center whitespace-nowrap border border-gray-200" + colSpan={collapsed ? 1 : 6} + style={{ + borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '', + backgroundColor: 'white', + }} + > + {/* + / - 버튼 */} + <div className="flex items-center gap-2 justify-center"> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-1" + onClick={() => toggleVendor(q.id)} + > + {collapsed ? <Plus size={16} /> : <Minus size={16} />} + </Button> + <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> + </div> + </th> + ) + })} + </tr> + + {/* 두 번째 헤더 행 - 하위 컬럼들 */} + <tr className="border-b border-b-gray-200"> + {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */} + {quotations.flatMap((q, qIndex) => { + // 접힌 상태면 추가 헤더 없음 + if (collapsedVendors[q.id]) { + return [ + <th + key={`${q.id}-collapsed`} + className="p-2 text-center whitespace-nowrap border border-gray-200" + style={{ backgroundColor: 'white' }} + > + 총액 + </th> + ]; + } + + // 펼친 상태면 6개 컬럼 표시 + const columns = [ + { key: 'unitprice', label: '단가' }, + { key: 'totalprice', label: '총액' }, + { key: 'tax', label: '세금' }, + { key: 'discount', label: '할인' }, + { key: 'leadtime', label: '리드타임' }, + { key: 'alternative', label: '대체품' }, + ]; + + return columns.map((col, colIndex) => { + const isFirstInGroup = colIndex === 0; + const isLastInGroup = colIndex === columns.length - 1; + + return ( + <th + key={`${q.id}-${col.key}`} + className={`p-2 text-center whitespace-nowrap border border-gray-200 ${ + isFirstInGroup ? 'border-l border-l-gray-200' : '' + } ${ + isLastInGroup ? 'border-r border-r-gray-200' : '' + }`} + style={{ backgroundColor: 'white' }} + > + {col.label} + </th> + ); + }); + })} + </tr> + </thead> + + {/* 테이블 바디 */} + <tbody> + {allItemIds.map((itemId) => { + // 자재 기본 정보는 첫 번째 벤더 아이템 기준 + const firstQid = quotations[0]?.id + const sampleItem = firstQid + ? findItemByQuotationId(itemId, firstQid) + : undefined + + return ( + <tr key={itemId} className="border-b border-gray-100"> + {/* 자재 (코드) 셀 */} + <td + className="sticky left-0 z-10 p-2 align-top border-r border-gray-100" + style={{ + width: '250px', + minWidth: '250px', + backgroundColor: 'white', + }} + > + {sampleItem?.materialDescription || sampleItem?.materialCode || ""} + {sampleItem && ( + <div className="text-xs text-muted-foreground mt-1"> + 코드: {sampleItem.materialCode} | 수량:{" "} + {sampleItem.quantity} {sampleItem.uom} + </div> + )} + </td> + + {/* 벤더별 아이템 데이터 */} + {quotations.flatMap((q, qIndex) => { + const collapsed = collapsedVendors[q.id] + const itemData = findItemByQuotationId(itemId, q.id) + + // 접힌 상태면 총액만 표시 + if (collapsed) { + return [ + <td + key={`${q.id}-collapsed`} + className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100" + > + {itemData + ? formatCurrency(Number(itemData.totalPrice), itemData.currency) + : "N/A"} + </td> + ]; + } + + // 펼친 상태 - 아이템 없음 + if (!itemData) { + return [ + <td + key={`${q.id}-empty`} + colSpan={6} + className="p-2 text-center text-sm border-r border-gray-100" + > + 없음 + </td> + ]; + } + + // 펼친 상태 - 모든 컬럼 표시 + const columns = [ + { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' }, + { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true }, + { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' }, + { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' }, + { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' }, + { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' }, + ]; + + return columns.map((col, colIndex) => { + const isFirstInGroup = colIndex === 0; + const isLastInGroup = colIndex === columns.length - 1; + + return ( + <td + key={`${q.id}-${col.key}`} + className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${ + isFirstInGroup ? 'border-l border-l-gray-100' : '' + } ${ + isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100' + }`} + > + {col.render()} + </td> + ); + }); + })} + </tr> + ); + })} + + {/* 아이템이 전혀 없는 경우 */} + {allItemIds.length === 0 && ( + <tr> + <td + colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버 + className="text-center p-4 border border-gray-100" + > + 아이템 정보가 없습니다 + </td> + </tr> + )} + </tbody> + </table> + </div> + </div> + </TabsContent> + </Tabs> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/components/po-rfq/po-rfq-container.tsx b/components/po-rfq/po-rfq-container.tsx new file mode 100644 index 00000000..e5159242 --- /dev/null +++ b/components/po-rfq/po-rfq-container.tsx @@ -0,0 +1,261 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { useSearchParams, useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeft, PanelLeftClose, PanelLeftOpen } from "lucide-react" + +// shadcn/ui components +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + +import { cn } from "@/lib/utils" +import { ProcurementRfqsView } from "@/db/schema" +import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table" +import { getPORfqs } from "@/lib/procurement-rfqs/services" +import { RFQFilterSheet } from "./rfq-filter-sheet" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { RfqDetailTables } from "./detail-table/rfq-detail-table" + +interface RfqContainerProps { + // 초기 데이터 (필수) + initialData: Awaited<ReturnType<typeof getPORfqs>> + // 서버 액션으로 데이터를 가져오는 함수 + fetchData: (params: any) => Promise<Awaited<ReturnType<typeof getPORfqs>>> +} + +export default function RFQContainer({ + initialData, + fetchData +}: RfqContainerProps) { + const router = useRouter() + const searchParams = useSearchParams() + + // Whether the filter panel is open (now a side panel instead of sheet) + const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false) + + // 데이터 상태 관리 - 초기 데이터로 시작 + const [data, setData] = useState<Awaited<ReturnType<typeof getPORfqs>>>(initialData) + const [isLoading, setIsLoading] = useState(false) + + // 선택된 문서를 이 state로 관리 + const [selectedRfq, setSelectedRfq] = useState<ProcurementRfqsView | null>(null) + + // 패널 collapse + const [isTopCollapsed, setIsTopCollapsed] = useState(false) + + // 이전 URL 파라미터를 저장하기 위한 ref + const prevParamsRef = useRef<string>(searchParams.toString()) + + // 현재 URL 파라미터로부터 필터 데이터 구성 + const getFilterParams = useCallback(() => { + return { + page: searchParams.get('page') || '1', + perPage: searchParams.get('perPage') || '10', + sort: searchParams.get('sort') || JSON.stringify([{ id: "updatedAt", desc: true }]), + basicFilters: searchParams.get('basicFilters') || null, + basicJoinOperator: searchParams.get('basicJoinOperator') || 'and', + filters: searchParams.get('filters') || null, + joinOperator: searchParams.get('joinOperator') || 'and', + search: searchParams.get('search') || '', + } + }, [searchParams]) + + // 데이터 로드 함수 + const loadData = useCallback(async () => { + try { + setIsLoading(true) + const filterParams = getFilterParams() + + console.log("데이터 로드 시작:", filterParams) + + // 서버 액션으로 데이터 가져오는 함수 + const newData = await fetchData(filterParams) + + console.log("데이터 로드 완료:", newData.data.length, "건") + + setData(newData) + } catch (error) { + console.error("데이터 로드 오류:", error) + } finally { + setIsLoading(false) + } + }, [fetchData, getFilterParams]) + + const refreshData = useCallback(() => { + // 현재 파라미터로 데이터 다시 로드 + loadData(); + }, [loadData]); + + // URL 파라미터 변경 감지 + useEffect(() => { + const currentParams = searchParams.toString() + + // 파라미터가 변경되었을 때만 데이터 로드 + if (currentParams !== prevParamsRef.current) { + console.log("URL 파라미터 변경 감지:", { + previous: prevParamsRef.current, + current: currentParams, + }) + + prevParamsRef.current = currentParams + loadData() + } + }, [searchParams, loadData]) + + // 문서 선택 핸들러 + const handleSelectRfq = (rfq: ProcurementRfqsView | null) => { + setSelectedRfq(rfq) + } + + // 조회 버튼 클릭 핸들러 - RFQFilterSheet에 전달 + // 페이지 리라우팅을 통해 처리하므로 별도 로직 불필요 + const handleSearch = () => { + // Close the panel after search + setIsFilterPanelOpen(false) + } + + const [panelHeight, setPanelHeight] = useState<number>(400) + + // Get active filter count for UI display + const getActiveFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + console.error("Error parsing filters:", e) + return 0 + } + } + + // Filter panel width in pixels + const FILTER_PANEL_WIDTH = 400; + + // Table refresh key - 패널 상태가 변경되면 테이블을 강제로 재렌더링 + const [tableRefreshKey, setTableRefreshKey] = useState(0); + + useEffect(() => { + // 패널 상태가 변경될 때 테이블 강제 재렌더링 + setTableRefreshKey(prev => prev + 1); + }, [isFilterPanelOpen]); + + return ( + <div className="h-[calc(100vh-220px)] w-full overflow-hidden relative"> + {/* Fixed Filter Panel - 가장 왼쪽부터 시작, 전체 높이 맞춤 */} + <div + className={cn( + "fixed left-0 bg-background border-r overflow-hidden flex flex-col transition-all duration-300 ease-in-out z-30", + isFilterPanelOpen ? "border-r" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + height: 'calc(100vh - 130px)' // 나머지 높이 전체 사용 + }} + > + {/* Filter Content - 제목 포함하여 내부에서 처리 */} + <div className="h-full"> + <RFQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={isLoading} + /> + </div> + </div> + + {/* Main Content Panel - 패널이 열릴 때 오른쪽으로 이동 */} + <div + className="h-full overflow-hidden transition-all duration-300 ease-in-out" + style={{ + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* Filter Toggle Button - 메인 콘텐츠 상단에 위치 */} + <div className="flex items-center p-4 border-b bg-background pl-0"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-md" + > + { + 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 className="flex-1" /> + <div className="text-sm text-muted-foreground"> + {data && !isLoading && ( + <span>총 {data.total || 0}건</span> + )} + </div> + </div> + + {/* Main Content Area */} + <div className="h-[calc(100%-64px)] w-full overflow-hidden"> + {isLoading ? ( + // 로딩 중 상태 + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + ) : ( + // 데이터 로드 완료 상태 + <ResizablePanelGroup direction="vertical" className="h-full"> + <ResizablePanel + defaultSize={55} + minSize={0} + maxSize={95} + collapsible + collapsedSize={10} + onCollapse={() => setIsTopCollapsed(true)} + onExpand={() => setIsTopCollapsed(false)} + onResize={(size) => { + setPanelHeight(size) + }} + className={cn("overflow-y-auto overflow-x-hidden border-b", isTopCollapsed && "transition-all")} + > + <div className="flex h-full min-h-0 flex-col"> + <RFQListTable + key={tableRefreshKey} // Force re-render when panel toggles + maxHeight={`${panelHeight*0.5}vh`} + data={data} + onSelectRFQ={handleSelectRfq} + onDataRefresh={refreshData} + /> + </div> + </ResizablePanel> + + <ResizableHandle + withHandle + className="pointer-events-none data-[resize-handle]:pointer-events-auto" + /> + + <ResizablePanel + minSize={0} + defaultSize={35} + className="overflow-y-auto overflow-x-hidden" + > + <RfqDetailTables selectedRfq={selectedRfq} /> + </ResizablePanel> + </ResizablePanelGroup> + )} + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/po-rfq/rfq-filter-sheet.tsx b/components/po-rfq/rfq-filter-sheet.tsx new file mode 100644 index 00000000..31f02442 --- /dev/null +++ b/components/po-rfq/rfq-filter-sheet.tsx @@ -0,0 +1,643 @@ +"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 { DateRangePicker } from "../date-range-picker" +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" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 필터 스키마 정의 (RFQFilterBox와 동일) +const filterSchema = z.object({ + picCode: z.string().optional(), + projectCode: z.string().optional(), + rfqCode: z.string().optional(), + itemCode: z.string().optional(), + majorItemMaterialCode: z.string().optional(), + status: z.string().optional(), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// 상태 옵션 정의 +const statusOptions = [ + { value: "RFQ Created", label: "RFQ Created" }, + { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, + { value: "RFQ Sent", label: "RFQ Sent" }, + { value: "Quotation Analysis", label: "Quotation Analysis" }, + { value: "PO Transfer", label: "PO Transfer" }, + { value: "PO Create", label: "PO Create" }, +] + +type FilterFormValues = z.infer<typeof filterSchema> + +interface RFQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +// Updated component for inline use (not a sheet anymore) +export function RFQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: RFQFilterSheetProps) { + 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 상태 관리 - 파라미터명을 'basicFilters'로 변경 + 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<FilterFormValues>({ + resolver: zodResolver(filterSchema), + defaultValues: { + picCode: "", + projectCode: "", + rfqCode: "", + itemCode: "", + majorItemMaterialCode: "", + status: "", + dateRange: { + 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 === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { + formValues.dateRange = { + 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]) // form 의존성 제거 + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 - 개선된 버전 + async function onSubmit(data: FilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.picCode?.trim()) { + newFilters.push({ + id: "picCode", + value: data.picCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.projectCode?.trim()) { + newFilters.push({ + id: "projectCode", + value: data.projectCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.itemCode?.trim()) { + newFilters.push({ + id: "itemCode", + value: data.itemCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.majorItemMaterialCode?.trim()) { + newFilters.push({ + id: "majorItemMaterialCode", + value: data.majorItemMaterialCode.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() + }) + } + + // Add date range to params if it exists + if (data.dateRange?.from) { + newFilters.push({ + id: "rfqSendDate", + value: [ + data.dateRange.from.toISOString().split('T')[0], + data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined + ].filter(Boolean), + type: "date", + operator: "isBetween", + rowId: generateId() + }) + } + + console.log("기본 필터 적용:", newFilters); + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 먼저 필터를 설정 + await setFilters(newFilters.length > 0 ? newFilters : null); + + // 그 다음 페이지를 1로 설정 + await setPage("1"); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + onSearch(); + } + } catch (error) { + console.error("필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 - 개선된 버전 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + picCode: "", + projectCode: "", + rfqCode: "", + itemCode: "", + majorItemMaterialCode: "", + status: "", + dateRange: { from: undefined, to: undefined }, + }); + + // 필터와 조인 연산자를 초기화 + await setFilters(null); + await setJoinOperator("and"); + await setPage("1"); + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("필터 초기화 오류:", 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]"> + {/* 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">검색 필터</h3> + </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-6 pt-4"> + {/* 발주 담당 */} + <FormField + control={form.control} + name="picCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("발주담당")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("발주담당 입력")} + {...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("picCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 코드 */} + <FormField + control={form.control} + name="projectCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("프로젝트 코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("프로젝트 코드 입력")} + {...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("projectCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ NO. */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ NO.")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("RFQ 번호 입력")} + {...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("rfqCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 */} + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재그룹")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재그룹 입력")} + {...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("itemCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재코드 */} + <FormField + control={form.control} + name="majorItemMaterialCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재코드 입력")} + {...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("majorItemMaterialCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("Status")}</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={t("Select status")} /> + {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> + {statusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ 전송일 */} + <FormField + control={form.control} + name="dateRange" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ 전송일")}</FormLabel> + <FormControl> + <div className="relative"> + <DateRangePicker + triggerSize="default" + triggerClassName="w-full bg-white" + align="start" + showClearButton={true} + placeholder={t("RFQ 전송일 범위를 고르세요")} + 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("dateRange", { 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" + > + {t("초기화")} + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? t("조회 중...") : t("조회")} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file |
