From 748bb1720fd81e97a84c3e92f89d606e976b52e3 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 00:18:16 +0000 Subject: (대표님) 컴포넌트 파트 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/client-data-table/data-table.tsx | 45 +- components/data-table/expandable-data-table.tsx | 421 ++++++++++ components/form-data/import-excel-form.tsx | 34 +- components/form-data/sedp-compare-dialog.tsx | 317 +++++--- components/layout/providers.tsx | 49 +- .../po-rfq/detail-table/add-vendor-dialog.tsx | 512 ------------ .../po-rfq/detail-table/delete-vendor-dialog.tsx | 150 ---- .../po-rfq/detail-table/rfq-detail-column.tsx | 369 --------- .../po-rfq/detail-table/rfq-detail-table.tsx | 519 ------------ .../po-rfq/detail-table/update-vendor-sheet.tsx | 449 ----------- .../detail-table/vendor-communication-drawer.tsx | 518 ------------ .../vendor-quotation-comparison-dialog.tsx | 665 --------------- components/po-rfq/po-rfq-container.tsx | 261 ------ components/po-rfq/rfq-filter-sheet.tsx | 643 --------------- components/pq-input/pq-input-tabs.tsx | 895 +++++++++++++++++++++ components/pq-input/pq-review-wrapper.tsx | 330 ++++++++ components/shell.tsx | 7 +- components/ui/alert.tsx | 3 + components/ui/badge.tsx | 2 + components/vendor-data/vendor-data-container.tsx | 80 +- 20 files changed, 2018 insertions(+), 4251 deletions(-) create mode 100644 components/data-table/expandable-data-table.tsx delete mode 100644 components/po-rfq/detail-table/add-vendor-dialog.tsx delete mode 100644 components/po-rfq/detail-table/delete-vendor-dialog.tsx delete mode 100644 components/po-rfq/detail-table/rfq-detail-column.tsx delete mode 100644 components/po-rfq/detail-table/rfq-detail-table.tsx delete mode 100644 components/po-rfq/detail-table/update-vendor-sheet.tsx delete mode 100644 components/po-rfq/detail-table/vendor-communication-drawer.tsx delete mode 100644 components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx delete mode 100644 components/po-rfq/po-rfq-container.tsx delete mode 100644 components/po-rfq/rfq-filter-sheet.tsx create mode 100644 components/pq-input/pq-input-tabs.tsx create mode 100644 components/pq-input/pq-review-wrapper.tsx (limited to 'components') diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx index 9067a475..b7851bb8 100644 --- a/components/client-data-table/data-table.tsx +++ b/components/client-data-table/data-table.tsx @@ -40,8 +40,9 @@ interface DataTableProps { data: TData[] advancedFilterFields: any[] autoSizeColumns?: boolean + compact?: boolean // compact 모드 추가 onSelectedRowsChange?: (selected: TData[]) => void - + maxHeight?: string | number /** 추가로 표시할 버튼/컴포넌트 */ children?: React.ReactNode } @@ -51,11 +52,12 @@ export function ClientDataTable({ data, advancedFilterFields, autoSizeColumns = true, + compact = true, // 기본값 true children, + maxHeight, onSelectedRowsChange }: DataTableProps) { - // (1) React Table 상태 const [rowSelection, setRowSelection] = React.useState({}) const [columnVisibility, setColumnVisibility] = React.useState({}) @@ -65,7 +67,7 @@ export function ClientDataTable({ const [columnSizing, setColumnSizing] = React.useState({}) const [columnPinning, setColumnPinning] = React.useState({ left: ["TAG_NO", "TAG_DESC"], - right: ["update"], + right: ["update", 'actions'], }) const table = useReactTable({ @@ -111,6 +113,23 @@ export function ClientDataTable({ onSelectedRowsChange(selectedRows) }, [rowSelection, table, onSelectedRowsChange]) + // 컴팩트 모드를 위한 클래스 정의 + const compactStyles = compact ? { + row: "h-7", // 행 높이 축소 + cell: "py-1 px-2 text-sm", // 셀 패딩 축소 및 폰트 크기 조정 + header: "py-1 px-2 text-sm", // 헤더 패딩 축소 + headerRow: "h-8", // 헤더 행 높이 축소 + groupRow: "py-1 bg-muted/20 text-sm", // 그룹 행 패딩 축소 + emptyRow: "h-16", // 데이터 없을 때 행 높이 조정 + } : { + row: "", + cell: "", + header: "", + headerRow: "", + groupRow: "bg-muted/20", + emptyRow: "h-24", + } + // (2) 렌더 return (
@@ -124,13 +143,13 @@ export function ClientDataTable({
-
+
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 if (header.column.getIsGrouped()) { @@ -142,6 +161,7 @@ export function ClientDataTable({ key={header.id} colSpan={header.colSpan} data-column-id={header.column.id} + className={compactStyles.header} style={{ ...getCommonPinningStyles({ column: header.column }), width: header.getSize() @@ -189,11 +209,14 @@ export function ClientDataTable({ return ( {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} - + {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} {row.getCanExpand() && ( )} @@ -231,6 +254,7 @@ export function ClientDataTable({ return ( {row.getVisibleCells().map((cell) => { @@ -243,6 +267,7 @@ export function ClientDataTable({ ({ No results. diff --git a/components/data-table/expandable-data-table.tsx b/components/data-table/expandable-data-table.tsx new file mode 100644 index 00000000..9005e2fb --- /dev/null +++ b/components/data-table/expandable-data-table.tsx @@ -0,0 +1,421 @@ +"use client" + +import * as React from "react" +import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" +import { ChevronRight, ChevronUp, ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { getCommonPinningStyles } from "@/lib/data-table" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { DataTablePagination } from "@/components/data-table/data-table-pagination" +import { DataTableResizer } from "@/components/data-table/data-table-resizer" +import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns" + +interface ExpandableDataTableProps extends React.HTMLAttributes { + table: TanstackTable + floatingBar?: React.ReactNode | null + autoSizeColumns?: boolean + compact?: boolean + expandedRows?: Set + setExpandedRows?: React.Dispatch>> + renderExpandedContent?: (row: TData) => React.ReactNode + expandable?: boolean // 확장 기능 활성화 여부 + maxHeight?: string | number + expandedRowClassName?: string // 확장된 행의 커스텀 클래스 +} + +/** + * 확장 가능한 데이터 테이블 - 행 확장 시 바로 아래에 컨텐츠 표시 + * 개선사항: + * - 가로스크롤과 확장된 내용 독립성 보장 + * - 동적 높이 계산으로 세로스크롤 문제 해결 + * - 향상된 접근성 및 키보드 네비게이션 + */ +export function ExpandableDataTable({ + table, + floatingBar = null, + autoSizeColumns = true, + compact = false, + expandedRows = new Set(), + setExpandedRows, + renderExpandedContent, + expandable = false, + maxHeight, + expandedRowClassName, + children, + className, + ...props +}: ExpandableDataTableProps) { + + useAutoSizeColumns(table, autoSizeColumns) + + // 스크롤 컨테이너 참조 + const scrollContainerRef = React.useRef(null) + const [expandedHeights, setExpandedHeights] = React.useState>(new Map()) + + // 행 확장/축소 핸들러 (개선된 버전) + const toggleRowExpansion = React.useCallback((rowId: string, event?: React.MouseEvent) => { + if (!setExpandedRows) return + + if (event) { + event.stopPropagation() + } + + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + // 높이 정보도 제거 + setExpandedHeights(prev => { + const newHeights = new Map(prev) + newHeights.delete(rowId) + return newHeights + }) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + }, [expandedRows, setExpandedRows]) + + // 키보드 네비게이션 핸들러 + const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleRowExpansion(rowId) + } + }, [toggleRowExpansion]) + + // 확장된 내용의 높이 측정 및 업데이트 + const updateExpandedHeight = React.useCallback((rowId: string, height: number) => { + setExpandedHeights(prev => { + if (prev.get(rowId) !== height) { + const newHeights = new Map(prev) + newHeights.set(rowId, height) + return newHeights + } + return prev + }) + }, []) + + // 컴팩트 모드를 위한 클래스 정의 (개선된 버전) + const compactStyles = compact ? { + row: "h-7", + cell: "py-1 px-2 text-sm", + expandedCell: "py-2 px-4", + groupRow: "py-1 bg-muted/20 text-sm", + emptyRow: "h-16", + } : { + row: "", + cell: "", + expandedCell: "py-0 px-0", // 패딩 제거하여 확장 컨텐츠가 전체 영역 사용 + groupRow: "bg-muted/20", + emptyRow: "h-24", + } + + // 확장 버튼 렌더링 함수 (접근성 개선) + const renderExpandButton = (rowId: string) => { + if (!expandable || !setExpandedRows) return null + + const isExpanded = expandedRows.has(rowId) + + return ( + + ) + } + + // 확장된 내용 래퍼 컴포넌트 (높이 측정 기능 포함) + const ExpandedContentWrapper = React.memo<{ + rowId: string + children: React.ReactNode + }>(({ rowId, children }) => { + const contentRef = React.useRef(null) + + React.useEffect(() => { + if (contentRef.current) { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + updateExpandedHeight(rowId, entry.contentRect.height) + } + }) + + resizeObserver.observe(contentRef.current) + return () => resizeObserver.disconnect() + } + }, [rowId]) + + return ( +
+ {children} +
+ ) + }) + + ExpandedContentWrapper.displayName = "ExpandedContentWrapper" + + return ( +
+ {children} + + {/* 메인 테이블 컨테이너 - 가로스크롤 문제 해결 */} +
+ {/* 가로스크롤 영역 */} +
+ {/* 세로스크롤 영역 (확장된 내용 포함) */} +
+ + {/* 테이블 헤더 */} + + {table.getHeaderGroups().map((headerGroup) => ( + + {/* 확장 버튼 컬럼 헤더 */} + {expandable && ( + + )} + + {headerGroup.headers.map((header) => { + if (header.column.getIsGrouped()) { + return null + } + + return ( + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {header.column.getCanResize() && ( + + )} +
+
+ ) + })} +
+ ))} +
+ + {/* 테이블 바디 */} + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isExpanded = expandedRows.has(row.id) + const expandedHeight = expandedHeights.get(row.id) || 0 + + // 그룹핑 헤더 Row + if (row.getIsGrouped()) { + const groupingColumnId = row.groupingColumnId ?? "" + const groupingColumn = table.getColumn(groupingColumnId) + + let columnLabel = groupingColumnId + if (groupingColumn) { + const headerDef = groupingColumn.columnDef.meta?.excelHeader + if (typeof headerDef === "string") { + columnLabel = headerDef + } + } + + return ( + + + {row.getCanExpand() && ( + + )} + + + {columnLabel}: {row.getValue(groupingColumnId)} + + + ({row.subRows.length} rows) + + + + ) + } + + // 일반 Row와 확장된 컨텐츠를 함께 렌더링 + return ( + + {/* 메인 데이터 행 */} + + {/* 확장 버튼 셀 */} + {expandable && ( + + {renderExpandButton(row.id)} + + )} + + {/* 데이터 셀들 */} + {row.getVisibleCells().map((cell) => { + if (cell.column.getIsGrouped()) { + return null + } + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + + {/* 확장된 컨텐츠 행 - 가로스크롤 독립성 보장 */} + {isExpanded && renderExpandedContent && ( + + + {/* 확장된 내용을 위한 고정 폭 컨테이너 */} +
+ {/* 가로스크롤과 독립적인 확장 영역 */} +
+
+ + {renderExpandedContent(row.original)} + +
+
+ + {/* 높이 유지를 위한 스페이서 */} +
+
+ + + )} + + ) + }) + ) : ( + // 데이터가 없을 때 + + + No results. + + + )} + +
+
+
+
+ +
+ {/* Pagination */} + + + {/* Floating Bar (선택된 행 있을 때) */} + {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar} +
+
+ ) +} \ No newline at end of file diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index fd9adc1c..d425a909 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -49,8 +49,14 @@ export async function importExcelData({ try { if (onPendingChange) onPendingChange(true); - // Get existing tag numbers + // Get existing tag numbers and create a map for quick lookup const existingTagNumbers = new Set(tableData.map((d) => d.TAG_NO)); + const existingDataMap = new Map(); + tableData.forEach(item => { + if (item.TAG_NO) { + existingDataMap.set(item.TAG_NO, item); + } + }); const workbook = new ExcelJS.Workbook(); // const arrayBuffer = await file.arrayBuffer(); @@ -130,12 +136,38 @@ export async function importExcelData({ let errorMessage = ""; const rowObj: Record = {}; + + // Get the TAG_NO first to identify existing data + const tagNoColIndex = keyToIndexMap.get("TAG_NO"); + const tagNo = tagNoColIndex ? String(rowValues[tagNoColIndex] ?? "").trim() : ""; + const existingRowData = existingDataMap.get(tagNo); // Process each column columnsJSON.forEach((col) => { const colIndex = keyToIndexMap.get(col.key); if (colIndex === undefined) return; + // Check if this column should be ignored (col.shi === true) + if (col.shi === true) { + // Use existing value instead of Excel value + if (existingRowData && existingRowData[col.key] !== undefined) { + rowObj[col.key] = existingRowData[col.key]; + } else { + // If no existing data, use appropriate default + switch (col.type) { + case "NUMBER": + rowObj[col.key] = null; + break; + case "STRING": + case "LIST": + default: + rowObj[col.key] = ""; + break; + } + } + return; // Skip processing Excel value for this column + } + const cellValue = rowValues[colIndex] ?? ""; let stringVal = String(cellValue).trim(); diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx index 37fe18ed..3107193a 100644 --- a/components/form-data/sedp-compare-dialog.tsx +++ b/components/form-data/sedp-compare-dialog.tsx @@ -3,12 +3,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Loader, RefreshCw, AlertCircle, CheckCircle, Info, EyeOff } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Loader, RefreshCw, AlertCircle, CheckCircle, Info, EyeOff, ChevronDown, ChevronRight, Search } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { DataTableColumnJSON } from "./form-data-table-columns"; import { ExcelDownload } from "./sedp-excel-download"; import { Switch } from "../ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; interface SEDPCompareDialogProps { isOpen: boolean; @@ -37,7 +39,7 @@ interface ComparisonResult { // Component for formatting display value with UOM const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string; isSedp?: boolean }) => { if (value === "" || value === null || value === undefined) { - return (empty); + return (empty); } // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정) @@ -54,7 +56,7 @@ const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string ); }; -// 범례 컴포넌트 추가 +// 범례 컴포넌트 const ColorLegend = () => { return (
@@ -76,6 +78,64 @@ const ColorLegend = () => { ); }; +// 확장 가능한 차이점 표시 컴포넌트 +const DifferencesCard = ({ + attributes, + columnLabelMap, + showOnlyDifferences +}: { + attributes: ComparisonResult['attributes']; + columnLabelMap: Record; + showOnlyDifferences: boolean; +}) => { + const attributesToShow = showOnlyDifferences + ? attributes.filter(attr => !attr.isMatching) + : attributes; + + if (attributesToShow.length === 0) { + return ( +
+ 모든 속성이 일치합니다 +
+ ); + } + + return ( +
+ {attributesToShow.map((attr) => ( + + +
+ {attr.label} + {attr.uom && ({attr.uom})} +
+ {attr.isMatching ? ( +
+ +
+ ) : ( +
+
+ 로컬: + + + +
+
+ SEDP: + + + +
+
+ )} +
+
+ ))} +
+ ); +}; + export function SEDPCompareDialog({ isOpen, onClose, @@ -92,11 +152,10 @@ export function SEDPCompareDialog({ const [missingTags, setMissingTags] = React.useState<{ localOnly: { tagNo: string; tagDesc: string }[]; sedpOnly: { tagNo: string; tagDesc: string }[]; - }>( - { localOnly: [], sedpOnly: [] } - ); - // 추가: 차이점만 표시하는 옵션 + }>({ localOnly: [], sedpOnly: [] }); const [showOnlyDifferences, setShowOnlyDifferences] = React.useState(true); + const [searchTerm, setSearchTerm] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState>(new Set()); // Stats for summary const totalTags = comparisonResults.length; @@ -119,41 +178,56 @@ export function SEDPCompareDialog({ return { columnLabelMap: labelMap, columnUomMap: uomMap }; }, [columnsJSON]); - // Filter results based on active tab + // Filter and search results const filteredResults = React.useMemo(() => { + let results = comparisonResults; + + // Filter by tab switch (activeTab) { case "matching": - return comparisonResults.filter(r => r.isMatching); + results = results.filter(r => r.isMatching); + break; case "differences": - return comparisonResults.filter(r => !r.isMatching); + results = results.filter(r => !r.isMatching); + break; case "all": default: - return comparisonResults; + break; + } + + // Apply search filter + if (searchTerm.trim()) { + const search = searchTerm.toLowerCase(); + results = results.filter(r => + r.tagNo.toLowerCase().includes(search) || + r.tagDesc.toLowerCase().includes(search) + ); } - }, [comparisonResults, activeTab]); - // 변경: 표시할 컬럼 결정 (차이가 있는 컬럼만 or 모든 컬럼) - const columnsToDisplay = React.useMemo(() => { - // 기본 컬럼 (TAG_NO, TAG_DESC 제외) - const columns = columnsJSON.filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC"); + return results; + }, [comparisonResults, activeTab, searchTerm]); - if (!showOnlyDifferences) { - return columns; // 모든 컬럼 표시 + // Toggle row expansion + const toggleRowExpansion = (tagNo: string) => { + const newExpanded = new Set(expandedRows); + if (newExpanded.has(tagNo)) { + newExpanded.delete(tagNo); + } else { + newExpanded.add(tagNo); } + setExpandedRows(newExpanded); + }; - // 하나라도 차이가 있는 속성만 필터링 - const columnsWithDifferences = new Set(); - comparisonResults.forEach(result => { - result.attributes.forEach(attr => { - if (!attr.isMatching) { - columnsWithDifferences.add(attr.key); - } + // Auto-expand rows with differences when switching to differences tab + React.useEffect(() => { + if (activeTab === "differences") { + const newExpanded = new Set(); + filteredResults.filter(r => !r.isMatching).forEach(r => { + newExpanded.add(r.tagNo); }); - }); - - // 차이가 있는 컬럼만 반환 - return columns.filter(col => columnsWithDifferences.has(col.key)); - }, [columnsJSON, comparisonResults, showOnlyDifferences]); + setExpandedRows(newExpanded); + } + }, [activeTab, filteredResults]); const fetchAndCompareData = React.useCallback(async () => { if (!projectCode || !formCode) { @@ -294,20 +368,34 @@ export function SEDPCompareDialog({ return ( - + SEDP 데이터 비교
-
- - +
+
+ + +
+ + {/* 검색 입력 */} +
+ + setSearchTerm(e.target.value)} + className="pl-8 w-64" + /> +
+
{matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''} @@ -329,7 +417,7 @@ export function SEDPCompareDialog({
- {/* 범례 추가 */} + {/* 범례 */}
@@ -357,7 +445,7 @@ export function SEDPCompareDialog({

로컬에만 있는 태그 ({missingTags.localOnly.length})

- + Tag Number Tag Description @@ -379,7 +467,7 @@ export function SEDPCompareDialog({

SEDP에만 있는 태그 ({missingTags.sedpOnly.length})

- + Tag Number Tag Description @@ -404,100 +492,85 @@ export function SEDPCompareDialog({ )} ) : filteredResults.length > 0 ? ( - // 개선된 테이블 구조 -
+ // 개선된 확장 가능한 테이블 구조 +
- + - Tag Number - Tag Description - 상태 - - {/* 동적으로 속성 열 헤더 생성 */} - {columnsToDisplay.length > 0 ? ( - columnsToDisplay.map(col => ( - - {columnLabelMap[col.key] || col.key} - {columnUomMap[col.key] && ( - - ({columnUomMap[col.key]}) - - )} - - )) - ) : ( - -
- - 차이가 있는 항목이 없습니다 -
-
- )} + + Tag Number + Tag Description + 상태 + 차이점 개수
{filteredResults.map((result) => ( - - - {result.tagNo} - - - {result.tagDesc} - - - {result.isMatching ? ( - - - 일치 - - ) : ( - - - 차이 있음 - - )} - - - {/* 각 속성에 대한 셀 동적 생성 */} - {columnsToDisplay.length > 0 ? ( - columnsToDisplay.map(col => { - const attr = result.attributes.find(a => a.key === col.key); - - if (!attr) return -; - - return ( - - {attr.isMatching ? ( - - ) : ( -
-
- -
-
- -
-
- )} -
- ); - }) - ) : ( + + {/* 메인 행 */} + toggleRowExpansion(result.tagNo)} + > - 모든 값이 일치합니다 + {result.attributes.some(attr => !attr.isMatching) ? ( + expandedRows.has(result.tagNo) ? ( + + ) : ( + + ) + ) : null} + + + {result.tagNo} + +
+ {result.tagDesc} +
+
+ + {result.isMatching ? ( + + + 일치 + + ) : ( + + + 차이 + + )} + + + {!result.isMatching && ( + + {result.attributes.filter(attr => !attr.isMatching).length}개 속성이 다름 + + )} + +
+ + {/* 확장된 차이점 표시 행 */} + {expandedRows.has(result.tagNo) && ( + + + + + )} -
+ ))}
) : (
- 현재 필터에 맞는 태그가 없습니다 + {searchTerm ? "검색 결과가 없습니다" : "현재 필터에 맞는 태그가 없습니다"}
)} diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx index 1c645531..376c419a 100644 --- a/components/layout/providers.tsx +++ b/components/layout/providers.tsx @@ -4,15 +4,47 @@ import * as React from "react" import { Provider as JotaiProvider } from "jotai" import { ThemeProvider as NextThemesProvider } from "next-themes" import { NuqsAdapter } from "nuqs/adapters/next/app" -import { SessionProvider } from "next-auth/react"; -import { CacheProvider } from '@emotion/react'; +import { SessionProvider } from "next-auth/react" +import { CacheProvider } from '@emotion/react' +import { SWRConfig } from 'swr' // ✅ SWR 추가 import { TooltipProvider } from "@/components/ui/tooltip" -import createEmotionCache from './createEmotionCashe'; +import createEmotionCache from './createEmotionCashe' +const cache = createEmotionCache() -const cache = createEmotionCache(); - +// 간소화된 SWR 설정 +const swrConfig = { + // 기본 동작 설정 + revalidateOnFocus: false, // 포커스시 자동 갱신 비활성화 + revalidateOnReconnect: true, // 재연결시 갱신 활성화 + shouldRetryOnError: false, // 에러시 자동 재시도 비활성화 (수동으로 제어) + dedupingInterval: 2000, // 2초 내 중복 요청 방지 + refreshInterval: 0, // 기본적으로 자동 갱신 비활성화 (개별 훅에서 설정) + + // 간단한 전역 에러 핸들러 (토스트 없이 로깅만) + onError: (error: any, key: string) => { + // 개발 환경에서만 상세 로깅 + if (process.env.NODE_ENV === 'development') { + console.warn('SWR fetch failed:', { + url: key, + status: error?.status, + message: error?.message + }) + } + + // 401 Unauthorized의 경우 특별 처리 (선택사항) + if (error?.status === 401) { + console.warn('Authentication required') + // 필요시 로그인 페이지로 리다이렉트 + // window.location.href = '/login' + } + }, + + // 전역 성공 핸들러는 제거 (너무 많은 로그 방지) + + // 기본 fetcher 제거 (각 훅에서 개별 관리) +} export function ThemeProvider({ children, @@ -21,18 +53,19 @@ export function ThemeProvider({ return ( - - {children} + {/* ✅ 간소화된 SWR 설정 적용 */} + + {children} + - ) } diff --git a/components/po-rfq/detail-table/add-vendor-dialog.tsx b/components/po-rfq/detail-table/add-vendor-dialog.tsx deleted file mode 100644 index 5e83ad8f..00000000 --- a/components/po-rfq/detail-table/add-vendor-dialog.tsx +++ /dev/null @@ -1,512 +0,0 @@ -"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 }) => ( - - {children} * - -); - -// 폼 유효성 검증 스키마 -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 - -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([]) - const [isSubmitting, setIsSubmitting] = useState(false) - - // 벤더 선택을 위한 팝오버 상태 - const [vendorOpen, setVendorOpen] = useState(false) - - const form = useForm({ - 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) => { - 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 ( - - {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} - - {/* 고정 헤더 */} -
- - 벤더 추가 - - {selectedRfq ? ( - <> - {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. - - ) : ( - "RFQ에 벤더를 추가합니다." - )} - - -
- - {/* 스크롤 가능한 콘텐츠 영역 */} -
-
- - {/* 검색 가능한 벤더 선택 필드 */} - ( - - 벤더 - - - - - - - - - - 검색 결과가 없습니다 - - - - {availableVendors.length > 0 ? ( - availableVendors.map((vendor) => ( - { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - - {vendor.vendorName} ({vendor.vendorCode}) - - )) - ) : ( - 추가 가능한 벤더가 없습니다 - )} - - - - - - - - - )} - /> - - ( - - 통화 - - - - )} - /> - -
- ( - - 지불 조건 - - - - )} - /> - - ( - - 인코텀즈 - - - - )} - /> -
- - {/* 나머지 필드들은 동일하게 유지 */} - ( - - 인코텀즈 세부사항 - - - - - - )} - /> - -
- ( - - 납품 예정일 - - - - - - )} - /> - - ( - - 세금 코드 - - - - - - )} - /> -
- -
- ( - - 선적지 - - - - - - )} - /> - - ( - - 도착지 - - - - - - )} - /> -
- - ( - - - - -
- 자재 가격 관련 여부 -
-
- )} - /> - - {/* 파일 업로드 섹션 */} -
- -
-
- -
- - {/* 업로드된 파일 목록 */} - {attachments.length > 0 && ( -
-

업로드된 파일

-
    - {attachments.map((file, index) => ( -
  • -
    - - {file.name} - - ({(file.size / 1024).toFixed(1)} KB) - -
    - -
  • - ))} -
-
- )} -
-
- - -
- - {/* 고정 푸터 */} -
- - - - -
-
-
- ) -} \ 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 deleted file mode 100644 index 49d982e1..00000000 --- a/components/po-rfq/detail-table/delete-vendor-dialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"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 { - 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 ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - - - - - - - - - - - ) - } - - return ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - - - - - - - - - - - ) -} \ 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 deleted file mode 100644 index 31f251ce..00000000 --- a/components/po-rfq/detail-table/rfq-detail-column.tsx +++ /dev/null @@ -1,369 +0,0 @@ -"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 { - row: Row; - 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 { - setRowAction: React.Dispatch< - React.SetStateAction | null> - >; - unreadMessages?: Record; // 벤더 ID별 읽지 않은 메시지 수 -} - -export function getRfqDetailColumns({ - setRowAction, - unreadMessages = {}, -}: GetColumnsProps): ColumnDef[] { - return [ - { - accessorKey: "quotationStatus", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("quotationStatus")}
, - meta: { - excelHeader: "견적 상태" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "quotationVersion", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("quotationVersion")}
, - meta: { - excelHeader: "견적 버전" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("vendorCode")}
, - meta: { - excelHeader: "벤더 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("vendorName")}
, - meta: { - excelHeader: "벤더명" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "vendorType", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.original.vendorCountry === "KR"?"D":"F"}
, - meta: { - excelHeader: "내외자" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("currency")}
, - meta: { - excelHeader: "통화" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "paymentTermsCode", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("paymentTermsCode")}
, - meta: { - excelHeader: "지불 조건 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "paymentTermsDescription", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("paymentTermsDescription")}
, - meta: { - excelHeader: "지불 조건" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsCode", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("incotermsCode")}
, - meta: { - excelHeader: "인코텀스 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "incotermsDescription", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("incotermsDescription")}
, - meta: { - excelHeader: "인코텀스" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsDetail", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("incotermsDetail")}
, - meta: { - excelHeader: "인코텀스 상세" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "deliveryDate", - header: ({ column }) => ( - - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "납품일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "taxCode", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("taxCode")}
, - meta: { - excelHeader: "세금 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "placeOfShipping", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("placeOfShipping")}
, - meta: { - excelHeader: "선적지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "placeOfDestination", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("placeOfDestination")}
, - meta: { - excelHeader: "도착지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "materialPriceRelatedYn", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}
, - meta: { - excelHeader: "원자재 가격 연동" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "updatedByUserName", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("updatedByUserName")}
, - meta: { - excelHeader: "수정자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDateTime(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "수정일시" - }, - enableResizing: true, - size: 140, - }, - // 커뮤니케이션 컬럼 추가 - { - id: "communication", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const vendorId = row.original.vendorId || 0; - const unreadCount = unreadMessages[vendorId] || 0; - - return ( - - ); - }, - enableResizing: false, - size: 80, - }, - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - - - - - - setRowAction({ row, type: "update" })} - > - Edit - - - setRowAction({ row, type: "delete" })} - > - Delete - ⌘⌫ - - - - ) - }, - 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 deleted file mode 100644 index 787b7c3b..00000000 --- a/components/po-rfq/detail-table/rfq-detail-table.tsx +++ /dev/null @@ -1,519 +0,0 @@ -"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([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const [selectedDetail, setSelectedDetail] = React.useState(null) - - const [vendors, setVendors] = React.useState([]) - const [currencies, setCurrencies] = React.useState([]) - const [paymentTerms, setPaymentTerms] = React.useState([]) - const [incoterms, setIncoterms] = React.useState([]) - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState>({}) - 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 ( -
- RFQ를 선택하세요 -
- ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( -
- - - -
- ) - } - - 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 ( -
- - {/* 메시지 및 새로고침 영역 */} - - - {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - - - -
-
- {totalUnreadMessages > 0 && ( - - 읽지 않은 메시지: {totalUnreadMessages}건 - - )} - {vendorsWithQuotations > 0 && ( - - 견적 제출: {vendorsWithQuotations}개 벤더 - - )} -
-
- {/* 견적 비교 버튼 추가 */} - - -
-
- -
- - ) : ( -
-
-

RFQ에 대한 세부 정보가 없습니다

- -
-
- )} - - {/* 벤더 추가 다이얼로그 */} - { - setVendorDialogOpen(open); - if (!open) setIsAdddialogLoading(false); - }} - selectedRfq={selectedRfq} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - existingVendorIds={existingVendorIds} - /> - - {/* 벤더 정보 수정 시트 */} - - - {/* 벤더 정보 삭제 다이얼로그 */} - - - {/* 벤더 커뮤니케이션 드로어 */} - { - setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 - if (!open) loadUnreadMessages(); - }} - selectedRfq={selectedRfq} - selectedVendor={selectedVendor} - onSuccess={handleRefreshData} - /> - - {/* 견적 비교 다이얼로그 추가 */} - -
- ) -} \ 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 deleted file mode 100644 index 45e4a602..00000000 --- a/components/po-rfq/detail-table/update-vendor-sheet.tsx +++ /dev/null @@ -1,449 +0,0 @@ -"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 - -// 데이터 타입 정의 -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 { - 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({ - 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 ( - - - - RFQ 벤더 정보 수정 - - 벤더 정보를 수정하고 저장하세요 - - - -
- - {/* 검색 가능한 벤더 선택 필드 */} - ( - - 벤더 * - - - - - - - - - - 검색 결과가 없습니다 - - - {vendors.map((vendor) => ( - { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - - {vendor.vendorName} ({vendor.vendorCode}) - - ))} - - - - - - - - )} - /> - - ( - - 통화 * - - - - )} - /> - -
- ( - - 지불 조건 * - - - - )} - /> - - ( - - 인코텀즈 * - - - - )} - /> -
- - ( - - 인코텀즈 세부사항 - - - - - - )} - /> - -
- ( - - 납품 예정일 - - - - - - )} - /> - - ( - - 세금 코드 - - - - - - )} - /> -
- -
- ( - - 선적지 - - - - - - )} - /> - - ( - - 도착지 - - - - - - )} - /> -
- - ( - - - - -
- 자재 가격 관련 여부 -
-
- )} - /> - - -
- - - - - - -
-
- ) -} \ 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 deleted file mode 100644 index 34efdfc2..00000000 --- a/components/po-rfq/detail-table/vendor-communication-drawer.tsx +++ /dev/null @@ -1,518 +0,0 @@ -"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 { - 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([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef(null); - const messagesEndRef = useRef(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState(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) => { - 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 ; - if (fileType.includes("pdf")) return ; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return ; - if (fileType.includes("document") || fileType.includes("word")) - return ; - return ; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); - - return ( - - - - - {getFileIcon(selectedAttachment.fileType)} - {selectedAttachment.fileName} - - - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} - - - -
- {isImage ? ( - {selectedAttachment.fileName} - ) : isPdf ? ( -