diff options
Diffstat (limited to 'components')
20 files changed, 2018 insertions, 4251 deletions
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<TData, TValue> { 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<TData, TValue>({ data, advancedFilterFields, autoSizeColumns = true, + compact = true, // 기본값 true children, + maxHeight, onSelectedRowsChange }: DataTableProps<TData, TValue>) { - // (1) React Table 상태 const [rowSelection, setRowSelection] = React.useState({}) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) @@ -65,7 +67,7 @@ export function ClientDataTable<TData, TValue>({ const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({}) const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: ["TAG_NO", "TAG_DESC"], - right: ["update"], + right: ["update", 'actions'], }) const table = useReactTable({ @@ -111,6 +113,23 @@ export function ClientDataTable<TData, TValue>({ 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 ( <div className="space-y-4"> @@ -124,13 +143,13 @@ export function ClientDataTable<TData, TValue>({ </ClientDataTableAdvancedToolbar> <div className="rounded-md border"> - <div className="overflow-auto" style={{maxHeight:'33.6rem'}}> + <div className="overflow-auto" style={{ maxHeight: maxHeight || '34rem' }} > <UiTable className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed" > <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id}> + <TableRow key={headerGroup.id} className={compactStyles.headerRow}> {headerGroup.headers.map((header) => { // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 if (header.column.getIsGrouped()) { @@ -142,6 +161,7 @@ export function ClientDataTable<TData, TValue>({ 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<TData, TValue>({ return ( <TableRow key={row.id} - className="bg-muted/20" + className={compactStyles.groupRow} data-state={row.getIsExpanded() && "expanded"} > {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} - <TableCell colSpan={table.getVisibleFlatColumns().length}> + <TableCell + colSpan={table.getVisibleFlatColumns().length} + className={compact ? "py-1 px-2" : ""} + > {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} {row.getCanExpand() && ( <button @@ -205,9 +228,9 @@ export function ClientDataTable<TData, TValue>({ }} > {row.getIsExpanded() ? ( - <ChevronUp size={16} /> + <ChevronUp size={compact ? 14 : 16} /> ) : ( - <ChevronRight size={16} /> + <ChevronRight size={compact ? 14 : 16} /> )} </button> )} @@ -231,6 +254,7 @@ export function ClientDataTable<TData, TValue>({ return ( <TableRow key={row.id} + className={compactStyles.row} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => { @@ -243,6 +267,7 @@ export function ClientDataTable<TData, TValue>({ <TableCell key={cell.id} data-column-id={cell.column.id} + className={compactStyles.cell} style={{ ...getCommonPinningStyles({ column: cell.column }), width: cell.column.getSize() @@ -265,7 +290,7 @@ export function ClientDataTable<TData, TValue>({ <TableRow> <TableCell colSpan={table.getAllColumns().length} - className="h-24 text-center" + className={compactStyles.emptyRow + " text-center"} > No results. </TableCell> 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<TData> extends React.HTMLAttributes<HTMLDivElement> { + table: TanstackTable<TData> + floatingBar?: React.ReactNode | null + autoSizeColumns?: boolean + compact?: boolean + expandedRows?: Set<string> + setExpandedRows?: React.Dispatch<React.SetStateAction<Set<string>>> + renderExpandedContent?: (row: TData) => React.ReactNode + expandable?: boolean // 확장 기능 활성화 여부 + maxHeight?: string | number + expandedRowClassName?: string // 확장된 행의 커스텀 클래스 +} + +/** + * 확장 가능한 데이터 테이블 - 행 확장 시 바로 아래에 컨텐츠 표시 + * 개선사항: + * - 가로스크롤과 확장된 내용 독립성 보장 + * - 동적 높이 계산으로 세로스크롤 문제 해결 + * - 향상된 접근성 및 키보드 네비게이션 + */ +export function ExpandableDataTable<TData>({ + table, + floatingBar = null, + autoSizeColumns = true, + compact = false, + expandedRows = new Set(), + setExpandedRows, + renderExpandedContent, + expandable = false, + maxHeight, + expandedRowClassName, + children, + className, + ...props +}: ExpandableDataTableProps<TData>) { + + useAutoSizeColumns(table, autoSizeColumns) + + // 스크롤 컨테이너 참조 + const scrollContainerRef = React.useRef<HTMLDivElement>(null) + const [expandedHeights, setExpandedHeights] = React.useState<Map<string, number>>(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 ( + <button + onClick={(e) => toggleRowExpansion(rowId, e)} + onKeyDown={(e) => handleKeyDown(e, rowId)} + className="inline-flex items-center justify-center w-6 h-6 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" + aria-label={isExpanded ? "행 축소" : "행 확장"} + aria-expanded={isExpanded} + tabIndex={0} + type="button" + > + {isExpanded ? ( + <ChevronDown className="w-4 h-4" /> + ) : ( + <ChevronRight className="w-4 h-4" /> + )} + </button> + ) + } + + // 확장된 내용 래퍼 컴포넌트 (높이 측정 기능 포함) + const ExpandedContentWrapper = React.memo<{ + rowId: string + children: React.ReactNode + }>(({ rowId, children }) => { + const contentRef = React.useRef<HTMLDivElement>(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 ( + <div ref={contentRef} className="w-full"> + {children} + </div> + ) + }) + + ExpandedContentWrapper.displayName = "ExpandedContentWrapper" + + return ( + <div className={cn("w-full space-y-2.5", className)} {...props}> + {children} + + {/* 메인 테이블 컨테이너 - 가로스크롤 문제 해결 */} + <div + ref={scrollContainerRef} + className="relative rounded-md border" + style={{ + // maxHeight: maxHeight || '35rem', + minHeight: '200px' // 최소 높이 보장 + }} + > + {/* 가로스크롤 영역 */} + <div className="overflow-x-auto overflow-y-hidden h-full"> + {/* 세로스크롤 영역 (확장된 내용 포함) */} + <div className="overflow-y-auto h-full"> + <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> + {/* 테이블 헤더 */} + <TableHeader className="bg-background"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}> + {/* 확장 버튼 컬럼 헤더 */} + {expandable && ( + <TableHead + className={cn("w-10 bg-background", compact ? "py-1 px-2" : "")} + style={{ position: 'sticky', left: 0, zIndex: 11 }} + /> + )} + + {headerGroup.headers.map((header) => { + if (header.column.getIsGrouped()) { + return null + } + + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + className={cn( + compact ? "py-1 px-2 text-sm" : "", + "bg-background" + )} + style={{ + ...getCommonPinningStyles({ column: header.column }), + width: header.getSize(), + }} + > + <div style={{ position: "relative" }}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {header.column.getCanResize() && ( + <DataTableResizer header={header} /> + )} + </div> + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> + + {/* 테이블 바디 */} + <TableBody> + {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 ( + <TableRow + key={row.id} + className={compactStyles.groupRow} + data-state={row.getIsExpanded() && "expanded"} + > + <TableCell + colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)} + className={compact ? "py-1 px-2" : ""} + > + {row.getCanExpand() && ( + <button + onClick={row.getToggleExpandedHandler()} + className="inline-flex items-center justify-center mr-2 w-5 h-5 hover:bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + style={{ + marginLeft: `${row.depth * 1.5}rem`, + }} + aria-label={row.getIsExpanded() ? "그룹 축소" : "그룹 확장"} + > + {row.getIsExpanded() ? ( + <ChevronUp size={compact ? 14 : 16} /> + ) : ( + <ChevronRight size={compact ? 14 : 16} /> + )} + </button> + )} + + <span className="font-semibold"> + {columnLabel}: {row.getValue(groupingColumnId)} + </span> + <span className="ml-2 text-xs text-muted-foreground"> + ({row.subRows.length} rows) + </span> + </TableCell> + </TableRow> + ) + } + + // 일반 Row와 확장된 컨텐츠를 함께 렌더링 + return ( + <React.Fragment key={row.id}> + {/* 메인 데이터 행 */} + <TableRow + className={cn( + compactStyles.row, + isExpanded && "bg-muted/30 border-b-0" + )} + data-state={row.getIsSelected() && "selected"} + > + {/* 확장 버튼 셀 */} + {expandable && ( + <TableCell + className={cn("w-10", compactStyles.cell)} + style={{ + position: 'sticky', + left: 0, + zIndex: 1, + backgroundColor: isExpanded ? 'rgb(248 250 252)' : 'white' + }} + > + {renderExpandButton(row.id)} + </TableCell> + )} + + {/* 데이터 셀들 */} + {row.getVisibleCells().map((cell) => { + if (cell.column.getIsGrouped()) { + return null + } + + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + className={compactStyles.cell} + style={{ + ...getCommonPinningStyles({ column: cell.column }), + width: cell.column.getSize(), + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ) + })} + </TableRow> + + {/* 확장된 컨텐츠 행 - 가로스크롤 독립성 보장 */} + {isExpanded && renderExpandedContent && ( + <TableRow className="hover:bg-transparent"> + <TableCell + colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)} + className={cn( + compactStyles.expandedCell, + "border-t-0 relative", + expandedRowClassName + )} + style={{ + minHeight: expandedHeight || 'auto', + }} + > + {/* 확장된 내용을 위한 고정 폭 컨테이너 */} + <div className="relative w-full"> + {/* 가로스크롤과 독립적인 확장 영역 */} + <div + className="absolute left-0 right-0 top-0 border-t" + style={{ + width: '80vw', + marginLeft: 'calc(-48vw + 50%)', + }} + > + <div className="max-w-none mx-auto"> + <ExpandedContentWrapper rowId={row.id}> + {renderExpandedContent(row.original)} + </ExpandedContentWrapper> + </div> + </div> + + {/* 높이 유지를 위한 스페이서 */} + <div + className="opacity-0 pointer-events-none" + style={{ height: Math.max(expandedHeight, 200) }} + /> + </div> + </TableCell> + </TableRow> + )} + </React.Fragment> + ) + }) + ) : ( + // 데이터가 없을 때 + <TableRow> + <TableCell + colSpan={table.getAllColumns().length + (expandable ? 1 : 0)} + className={compactStyles.emptyRow + " text-center"} + > + No results. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + </div> + </div> + + <div className="flex flex-col gap-2.5"> + {/* Pagination */} + <DataTablePagination table={table} /> + + {/* Floating Bar (선택된 행 있을 때) */} + {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar} + </div> + </div> + ) +}
\ 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<string, GenericData>(); + 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<string, any> = {}; + + // 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 <span>(empty)</span>; + return <span className="text-muted-foreground italic">(empty)</span>; } // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정) @@ -54,7 +56,7 @@ const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string ); }; -// 범례 컴포넌트 추가 +// 범례 컴포넌트 const ColorLegend = () => { return ( <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded"> @@ -76,6 +78,64 @@ const ColorLegend = () => { ); }; +// 확장 가능한 차이점 표시 컴포넌트 +const DifferencesCard = ({ + attributes, + columnLabelMap, + showOnlyDifferences +}: { + attributes: ComparisonResult['attributes']; + columnLabelMap: Record<string, string>; + showOnlyDifferences: boolean; +}) => { + const attributesToShow = showOnlyDifferences + ? attributes.filter(attr => !attr.isMatching) + : attributes; + + if (attributesToShow.length === 0) { + return ( + <div className="text-center text-muted-foreground py-4"> + 모든 속성이 일치합니다 + </div> + ); + } + + return ( + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4"> + {attributesToShow.map((attr) => ( + <Card key={attr.key} className={`${!attr.isMatching ? 'border-red-200' : 'border-green-200'}`}> + <CardContent className="p-3"> + <div className="font-medium text-sm mb-2 truncate" title={attr.label}> + {attr.label} + {attr.uom && <span className="text-xs text-muted-foreground ml-1">({attr.uom})</span>} + </div> + {attr.isMatching ? ( + <div className="text-sm"> + <DisplayValue value={attr.localValue} uom={attr.uom} /> + </div> + ) : ( + <div className="space-y-2"> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">로컬:</span> + <span className="line-through text-red-500 flex-1"> + <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> + </span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">SEDP:</span> + <span className="text-green-500 flex-1"> + <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> + </span> + </div> + </div> + )} + </CardContent> + </Card> + ))} + </div> + ); +}; + 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<Set<string>>(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<string>(); - 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<string>(); + 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 ( <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogHeader> <DialogTitle className="mb-2">SEDP 데이터 비교</DialogTitle> <div className="flex items-center justify-between gap-2 pr-8"> - <div className="flex items-center gap-2"> - <Switch - checked={showOnlyDifferences} - onCheckedChange={setShowOnlyDifferences} - id="show-differences" - /> - <label htmlFor="show-differences" className="text-sm cursor-pointer"> - 차이가 있는 항목만 표시 - </label> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + <Switch + checked={showOnlyDifferences} + onCheckedChange={setShowOnlyDifferences} + id="show-differences" + /> + <label htmlFor="show-differences" className="text-sm cursor-pointer"> + 차이가 있는 항목만 표시 + </label> + </div> + + {/* 검색 입력 */} + <div className="relative"> + <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="태그 번호 또는 설명 검색..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8 w-64" + /> + </div> </div> + <div className="flex items-center gap-2"> <Badge variant={matchingTags === totalTags && totalMissingTags === 0 ? "default" : "destructive"}> {matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''} @@ -329,7 +417,7 @@ export function SEDPCompareDialog({ </div> </DialogHeader> - {/* 범례 추가 */} + {/* 범례 */} <div className="mb-4"> <ColorLegend /> </div> @@ -357,7 +445,7 @@ export function SEDPCompareDialog({ <div> <h3 className="text-sm font-medium mb-2">로컬에만 있는 태그 ({missingTags.localOnly.length})</h3> <Table> - <TableHeader> + <TableHeader className="sticky top-0 bg-background z-10"> <TableRow> <TableHead className="w-[180px]">Tag Number</TableHead> <TableHead>Tag Description</TableHead> @@ -379,7 +467,7 @@ export function SEDPCompareDialog({ <div> <h3 className="text-sm font-medium mb-2">SEDP에만 있는 태그 ({missingTags.sedpOnly.length})</h3> <Table> - <TableHeader> + <TableHeader className="sticky top-0 bg-background z-10"> <TableRow> <TableHead className="w-[180px]">Tag Number</TableHead> <TableHead>Tag Description</TableHead> @@ -404,100 +492,85 @@ export function SEDPCompareDialog({ )} </div> ) : filteredResults.length > 0 ? ( - // 개선된 테이블 구조 - <div className="overflow-x-auto"> + // 개선된 확장 가능한 테이블 구조 + <div className="border rounded-md"> <Table> - <TableHeader> + <TableHeader className="sticky top-0 bg-muted/50 z-10"> <TableRow> - <TableHead className="sticky left-0 z-10 bg-background w-[180px]">Tag Number</TableHead> - <TableHead className="sticky left-[180px] z-10 bg-background w-[200px]">Tag Description</TableHead> - <TableHead className="sticky left-[380px] z-10 bg-background w-[100px]">상태</TableHead> - - {/* 동적으로 속성 열 헤더 생성 */} - {columnsToDisplay.length > 0 ? ( - columnsToDisplay.map(col => ( - <TableHead key={col.key} className="min-w-[150px]"> - {columnLabelMap[col.key] || col.key} - {columnUomMap[col.key] && ( - <span className="text-xs text-muted-foreground ml-1"> - ({columnUomMap[col.key]}) - </span> - )} - </TableHead> - )) - ) : ( - <TableHead> - <div className="flex items-center justify-center text-muted-foreground"> - <EyeOff className="h-4 w-4 mr-2" /> - <span>차이가 있는 항목이 없습니다</span> - </div> - </TableHead> - )} + <TableHead className="w-12"></TableHead> + <TableHead className="w-[180px]">Tag Number</TableHead> + <TableHead className="w-[250px]">Tag Description</TableHead> + <TableHead className="w-[120px]">상태</TableHead> + <TableHead>차이점 개수</TableHead> </TableRow> </TableHeader> <TableBody> {filteredResults.map((result) => ( - <TableRow key={result.tagNo} className={!result.isMatching ? "bg-muted/30" : ""}> - <TableCell className="sticky left-0 z-10 bg-background font-medium"> - {result.tagNo} - </TableCell> - <TableCell className="sticky left-[180px] z-10 bg-background"> - {result.tagDesc} - </TableCell> - <TableCell className="sticky left-[380px] z-10 bg-background"> - {result.isMatching ? ( - <Badge variant="default" className="flex items-center gap-1"> - <CheckCircle className="h-3 w-3" /> - <span>일치</span> - </Badge> - ) : ( - <Badge variant="destructive" className="flex items-center gap-1"> - <AlertCircle className="h-3 w-3" /> - <span>차이 있음</span> - </Badge> - )} - </TableCell> - - {/* 각 속성에 대한 셀 동적 생성 */} - {columnsToDisplay.length > 0 ? ( - columnsToDisplay.map(col => { - const attr = result.attributes.find(a => a.key === col.key); - - if (!attr) return <TableCell key={col.key}>-</TableCell>; - - return ( - <TableCell - key={col.key} - className={!attr.isMatching ? "bg-muted/50" : ""} - > - {attr.isMatching ? ( - <DisplayValue value={attr.localValue} uom={attr.uom} /> - ) : ( - <div className="flex flex-col gap-1"> - <div className="line-through text-red-500"> - <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> - </div> - <div className="text-green-500"> - <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> - </div> - </div> - )} - </TableCell> - ); - }) - ) : ( + <React.Fragment key={result.tagNo}> + {/* 메인 행 */} + <TableRow + className={`hover:bg-muted/20 cursor-pointer ${!result.isMatching ? "bg-muted/10" : ""}`} + onClick={() => toggleRowExpansion(result.tagNo)} + > <TableCell> - <span className="text-muted-foreground">모든 값이 일치합니다</span> + {result.attributes.some(attr => !attr.isMatching) ? ( + expandedRows.has(result.tagNo) ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + ) + ) : null} + </TableCell> + <TableCell className="font-medium"> + {result.tagNo} </TableCell> + <TableCell title={result.tagDesc}> + <div className="truncate"> + {result.tagDesc} + </div> + </TableCell> + <TableCell> + {result.isMatching ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + <span>일치</span> + </Badge> + ) : ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertCircle className="h-3 w-3" /> + <span>차이</span> + </Badge> + )} + </TableCell> + <TableCell> + {!result.isMatching && ( + <span className="text-sm text-muted-foreground"> + {result.attributes.filter(attr => !attr.isMatching).length}개 속성이 다름 + </span> + )} + </TableCell> + </TableRow> + + {/* 확장된 차이점 표시 행 */} + {expandedRows.has(result.tagNo) && ( + <TableRow> + <TableCell colSpan={5} className="p-0 bg-muted/5"> + <DifferencesCard + attributes={result.attributes} + columnLabelMap={columnLabelMap} + showOnlyDifferences={showOnlyDifferences} + /> + </TableCell> + </TableRow> )} - </TableRow> + </React.Fragment> ))} </TableBody> </Table> </div> ) : ( <div className="flex items-center justify-center h-full text-muted-foreground"> - 현재 필터에 맞는 태그가 없습니다 + {searchTerm ? "검색 결과가 없습니다" : "현재 필터에 맞는 태그가 없습니다"} </div> )} </TabsContent> 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 ( <JotaiProvider> <CacheProvider value={cache}> - <NextThemesProvider {...props}> <TooltipProvider delayDuration={0}> <NuqsAdapter> <SessionProvider> - {children} + {/* ✅ 간소화된 SWR 설정 적용 */} + <SWRConfig value={swrConfig}> + {children} + </SWRConfig> </SessionProvider> </NuqsAdapter> </TooltipProvider> </NextThemesProvider> </CacheProvider> - </JotaiProvider> ) } 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 }) => ( - <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 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<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 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<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 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<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 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<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 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<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 deleted file mode 100644 index 72cf187c..00000000 --- a/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"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 deleted file mode 100644 index e5159242..00000000 --- a/components/po-rfq/po-rfq-container.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"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 deleted file mode 100644 index 31f02442..00000000 --- a/components/po-rfq/rfq-filter-sheet.tsx +++ /dev/null @@ -1,643 +0,0 @@ -"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 diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx new file mode 100644 index 00000000..2574a5b0 --- /dev/null +++ b/components/pq-input/pq-input-tabs.tsx @@ -0,0 +1,895 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { useToast } from "@/hooks/use-toast" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" + +// Form components +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" + +// Custom Dropzone, FileList components +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list" + +// Dialog components +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" + +// Additional UI +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" + +// Server actions +import { + uploadFileAction, + savePQAnswersAction, + submitPQAction, + ProjectPQ, +} from "@/lib/pq/service" +import { PQGroupData } from "@/lib/pq/service" + +// ---------------------------------------------------------------------- +// 1) Define client-side file shapes +// ---------------------------------------------------------------------- +interface UploadedFileState { + fileName: string + url: string + size?: number +} + +interface LocalFileState { + fileObj: File + uploaded: boolean +} + +// ---------------------------------------------------------------------- +// 2) Zod schema for the entire form +// ---------------------------------------------------------------------- +const pqFormSchema = z.object({ + answers: z.array( + z.object({ + criteriaId: z.number(), + // Must have at least 1 char + answer: z.string().min(1, "Answer is required"), + + // Existing, uploaded files + uploadedFiles: z + .array( + z.object({ + fileName: z.string(), + url: z.string(), + size: z.number().optional(), + }) + ) + .min(1, "At least one file attachment is required"), + + // Local (not-yet-uploaded) files + newUploads: z.array( + z.object({ + fileObj: z.any(), + uploaded: z.boolean().default(false), + }) + ), + + // track saved state + saved: z.boolean().default(false), + }) + ), +}) + +type PQFormValues = z.infer<typeof pqFormSchema> + +// ---------------------------------------------------------------------- +// 3) Main Component: PQInputTabs +// ---------------------------------------------------------------------- +export function PQInputTabs({ + data, + vendorId, + projectId, + projectData, + isReadOnly = false, + currentPQ, // 추가: 현재 PQ Submission 정보 +}: { + data: PQGroupData[] + vendorId: number + projectId?: number + projectData?: ProjectPQ | null + isReadOnly?: boolean + currentPQ?: { // PQ Submission 정보 + id: number; + status: string; + type: string; + } | null +}) { + + const [isSaving, setIsSaving] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [allSaved, setAllSaved] = React.useState(false) + const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) + + const { toast } = useToast() + + const shouldDisableInput = isReadOnly; + + // ---------------------------------------------------------------------- + // A) Create initial form values + // Mark items as "saved" if they have existing answer or attachments + // ---------------------------------------------------------------------- + function createInitialFormValues(): PQFormValues { + const answers: PQFormValues["answers"] = [] + + data.forEach((group) => { + group.items.forEach((item) => { + // Check if the server item is already "complete" + const hasExistingAnswer = item.answer && item.answer.trim().length > 0 + const hasExistingAttachments = item.attachments && item.attachments.length > 0 + + // If either is present, we consider it "saved" initially + const isAlreadySaved = hasExistingAnswer || hasExistingAttachments + + answers.push({ + criteriaId: item.criteriaId, + answer: item.answer || "", + uploadedFiles: item.attachments.map((attach) => ({ + fileName: attach.fileName, + url: attach.filePath, + size: attach.fileSize, + })), + newUploads: [], + saved: isAlreadySaved, + }) + }) + }) + + return { answers } + } + + // ---------------------------------------------------------------------- + // B) Set up react-hook-form + // ---------------------------------------------------------------------- + const form = useForm<PQFormValues>({ + resolver: zodResolver(pqFormSchema), + defaultValues: createInitialFormValues(), + mode: "onChange", + }) + + // ---------------------------------------------------------------------- + // C) Track if all items are saved => controls Submit PQ button + // ---------------------------------------------------------------------- + React.useEffect(() => { + const values = form.getValues() + // We consider items "saved" if `saved===true` AND they have an answer or attachments + const allItemsSaved = values.answers.every( + (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) + ) + setAllSaved(allItemsSaved) + }, [form.watch()]) + + // Helper to find the array index by criteriaId + const getAnswerIndex = (criteriaId: number): number => { + return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) + } + + // ---------------------------------------------------------------------- + // D) Handling File Drops, Removal + // ---------------------------------------------------------------------- + const handleDropAccepted = (criteriaId: number, files: File[]) => { + const answerIndex = getAnswerIndex(criteriaId) + if (answerIndex === -1) return + + // Convert each dropped file into a LocalFileState + const newLocalFiles: LocalFileState[] = files.map((f) => ({ + fileObj: f, + uploaded: false, + })) + + const current = form.getValues(`answers.${answerIndex}.newUploads`) + form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], { + shouldDirty: true, + }) + + // Mark unsaved + form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) + } + + const handleDropRejected = () => { + toast({ + title: "File upload rejected", + description: "Please check file size and type.", + variant: "destructive", + }) + } + + const removeNewUpload = (answerIndex: number, fileIndex: number) => { + const current = [...form.getValues(`answers.${answerIndex}.newUploads`)] + current.splice(fileIndex, 1) + form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true }) + + form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) + } + + const removeUploadedFile = (answerIndex: number, fileIndex: number) => { + const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] + current.splice(fileIndex, 1) + form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true }) + + form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) + } + + // ---------------------------------------------------------------------- + // E) Saving a Single Item + // ---------------------------------------------------------------------- + const handleSaveItem = async (answerIndex: number) => { + try { + const answerData = form.getValues(`answers.${answerIndex}`) + + // Validation + if (!answerData.answer) { + toast({ + title: "Validation Error", + description: "Answer is required", + variant: "destructive", + }) + return + } + + // Upload new files (if any) + if (answerData.newUploads.length > 0) { + setIsSaving(true) + + for (const localFile of answerData.newUploads) { + try { + const uploadResult = await uploadFileAction(localFile.fileObj) + const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) + currentUploaded.push({ + fileName: uploadResult.fileName, + url: uploadResult.url, + size: uploadResult.size, + }) + form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, { + shouldDirty: true, + }) + } catch (error) { + console.error("File upload error:", error) + toast({ + title: "Upload Error", + description: "Failed to upload file", + variant: "destructive", + }) + } + } + + // Clear newUploads + form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true }) + } + + // Save to DB + const updatedAnswer = form.getValues(`answers.${answerIndex}`) + const saveResult = await savePQAnswersAction({ + vendorId, + projectId, // 프로젝트 ID 전달 + answers: [ + { + criteriaId: updatedAnswer.criteriaId, + answer: updatedAnswer.answer, + attachments: updatedAnswer.uploadedFiles.map((f) => ({ + fileName: f.fileName, + url: f.url, + size: f.size, + })), + }, + ], + }) + + if (saveResult.ok) { + // Mark as saved + form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) + toast({ + title: "Saved", + description: "Item saved successfully", + }) + } + } catch (error) { + console.error("Save error:", error) + toast({ + title: "Save Error", + description: "Failed to save item", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + // For convenience + const answers = form.getValues().answers + const dirtyFields = form.formState.dirtyFields.answers + + // Check if any item is dirty or has new uploads + const isAnyItemDirty = answers.some((answer, i) => { + const itemDirty = !!dirtyFields?.[i] + const hasNewUploads = answer.newUploads.length > 0 + return itemDirty || hasNewUploads + }) + + // ---------------------------------------------------------------------- + // F) Save All Items + // ---------------------------------------------------------------------- + const handleSaveAll = async () => { + try { + setIsSaving(true) + const answers = form.getValues().answers + + // Only save items that are dirty or have new uploads + for (let i = 0; i < answers.length; i++) { + const itemDirty = !!dirtyFields?.[i] + const hasNewUploads = answers[i].newUploads.length > 0 + if (!itemDirty && !hasNewUploads) continue + + await handleSaveItem(i) + } + + toast({ + title: "All Saved", + description: "All items saved successfully", + }) + } catch (error) { + console.error("Save all error:", error) + toast({ + title: "Save Error", + description: "Failed to save all items", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + // ---------------------------------------------------------------------- + // G) Submission with Confirmation Dialog + // ---------------------------------------------------------------------- + const handleSubmitPQ = () => { + if (!allSaved) { + toast({ + title: "Cannot Submit", + description: "Please save all items before submitting", + variant: "destructive", + }) + return + } + setShowConfirmDialog(true) + } + + const handleConfirmSubmission = async () => { + try { + setIsSubmitting(true); + setShowConfirmDialog(false); + + // pqSubmissionId가 있으면 포함하여 전달 + const result = await submitPQAction({ + vendorId, + projectId, + pqSubmissionId: currentPQ?.id, // 현재 PQ Submission ID 사용 + }); + + if (result.ok) { + toast({ + title: "PQ Submitted", + description: "Your PQ information has been submitted successfully", + }); + // 제출 후 PQ 목록 페이지로 리디렉션 + window.location.href = "/partners/pq"; + } else { + toast({ + title: "Submit Error", + description: result.error || "Failed to submit PQ", + variant: "destructive", + }); + } + } catch (error) { + console.error("Submit error:", error); + toast({ + title: "Submit Error", + description: "Failed to submit PQ information", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + // 프로젝트 정보 표시 섹션 + const renderProjectInfo = () => { + if (!projectData) return null; + + return ( + <div className="mb-6 bg-muted p-4 rounded-md"> + <div className="flex items-center justify-between mb-2"> + <h3 className="text-lg font-semibold">프로젝트 정보</h3> + <Badge variant={getStatusVariant(projectData.status)}> + {getStatusLabel(projectData.status)} + </Badge> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p> + <p>{projectData.projectCode}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground">프로젝트명</p> + <p>{projectData.projectName}</p> + </div> + {projectData.submittedAt && ( + <div className="col-span-1 md:col-span-2"> + <p className="text-sm font-medium text-muted-foreground">제출일</p> + <p>{formatDate(projectData.submittedAt)}</p> + </div> + )} + </div> + </div> + ); + }; + + // 상태 표시용 함수 + const getStatusLabel = (status: string) => { + switch (status) { + case "REQUESTED": return "요청됨"; + case "IN_PROGRESS": return "진행중"; + case "SUBMITTED": return "제출됨"; + case "APPROVED": return "승인됨"; + case "REJECTED": return "반려됨"; + default: return status; + } + }; + + const getStatusVariant = (status: string) => { + switch (status) { + case "REQUESTED": return "secondary"; + case "IN_PROGRESS": return "default"; + case "SUBMITTED": return "outline"; + case "APPROVED": return "outline"; + case "REJECTED": return "destructive"; + default: return "secondary"; + } + }; + + // 날짜 형식화 함수 + const formatDate = (date: Date) => { + if (!date) return "-"; + return new Date(date).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + // ---------------------------------------------------------------------- + // H) Render + // ---------------------------------------------------------------------- + return ( + <Form {...form}> + <form> + {/* 프로젝트 정보 섹션 */} + {renderProjectInfo()} + + <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> + {/* Top Controls */} + <div className="flex justify-between items-center mb-4"> + <TabsList className="grid grid-cols-4"> + {data.map((group) => ( + <TabsTrigger + key={group.groupName} + value={group.groupName} + className="truncate" + > + <div className="flex items-center gap-2"> + {/* Mobile: truncated version */} + <span className="block sm:hidden"> + {group.groupName.length > 5 + ? group.groupName.slice(0, 5) + "..." + : group.groupName} + </span> + {/* Desktop: full text */} + <span className="hidden sm:block">{group.groupName}</span> + <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> + {group.items.length} + </span> + </div> + </TabsTrigger> + ))} + </TabsList> + + <div className="flex gap-2"> + {/* Save All button */} + <Button + type="button" + variant="outline" + disabled={isSaving || !isAnyItemDirty || shouldDisableInput} + onClick={handleSaveAll} + > + {isSaving ? "Saving..." : "Save All"} + <Save className="ml-2 h-4 w-4" /> + </Button> + + {/* Submit PQ button */} + <Button + type="button" + disabled={!allSaved || isSubmitting || shouldDisableInput} + onClick={handleSubmitPQ} + > + {isSubmitting ? "Submitting..." : "Submit PQ"} + <CheckCircle2 className="ml-2 h-4 w-4" /> + </Button> + </div> + </div> + + {/* Render each group */} + {data.map((group) => ( + <TabsContent key={group.groupName} value={group.groupName}> + {/* 2-column grid */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"> + {group.items.map((item) => { + const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item + const answerIndex = getAnswerIndex(criteriaId) + if (answerIndex === -1) return null + + const isSaved = form.watch(`answers.${answerIndex}.saved`) + const hasAnswer = form.watch(`answers.${answerIndex}.answer`) + const newUploads = form.watch(`answers.${answerIndex}.newUploads`) + const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex] + + const isItemDirty = !!dirtyFieldsItem + const hasNewUploads = newUploads.length > 0 + const canSave = isItemDirty || hasNewUploads + + // For "Not Saved" vs. "Saved" status label + const hasUploads = + form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || + newUploads.length > 0 + const isValid = !!hasAnswer || hasUploads + + return ( + <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full"> + <Card className={isSaved ? "border-green-200" : ""}> + <CardHeader className="pb-1"> + <div className="flex justify-between"> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <CollapsibleTrigger asChild> + <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> + <ChevronsUpDown className="h-4 w-4" /> + <span className="sr-only">Toggle</span> + </Button> + </CollapsibleTrigger> + <CardTitle className="text-md"> + {code} - {checkPoint} + </CardTitle> + </div> + {description && ( + <CardDescription className="mt-1 whitespace-pre-wrap"> + {description} + </CardDescription> + )} + </div> + + {/* Save Status & Button */} + <div className="flex items-center gap-2"> + {!isSaved && canSave && ( + <span className="text-amber-600 text-xs flex items-center"> + <AlertTriangle className="h-4 w-4 mr-1" /> + Not Saved + </span> + )} + {isSaved && ( + <span className="text-green-600 text-xs flex items-center"> + <CheckCircle2 className="h-4 w-4 mr-1" /> + Saved + </span> + )} + + <Button + size="sm" + variant="outline" + disabled={isSaving || !canSave} + onClick={() => handleSaveItem(answerIndex)} + > + Save + </Button> + </div> + </div> + </CardHeader> + + <CollapsibleContent> + <CardContent className="pt-3 space-y-3"> + {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} + {projectId && contractInfo && ( + <div className="space-y-1"> + <FormLabel className="text-sm font-medium">계약 정보</FormLabel> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {contractInfo} + </div> + </div> + )} + + {projectId && additionalRequirement && ( + <div className="space-y-1"> + <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {additionalRequirement} + </div> + </div> + )} + + {/* Answer Field */} + <FormField + control={form.control} + name={`answers.${answerIndex}.answer`} + render={({ field }) => ( + <FormItem className="mt-2"> + <FormLabel>Answer</FormLabel> + <FormControl> + <Textarea + {...field} + disabled={shouldDisableInput} + className="min-h-24" + placeholder="Enter your answer here" + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + + {/* Attachments / Dropzone */} + <div className="grid gap-2 mt-3"> + <FormLabel>Attachments</FormLabel> + <Dropzone + maxSize={6e8} // 600MB + onDropAccepted={(files) => + handleDropAccepted(criteriaId, files) + } + onDropRejected={handleDropRejected} + > + {() => ( + <FormItem> + <DropzoneZone className="flex justify-center h-24"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop files here</DropzoneTitle> + <DropzoneDescription> + Max size: 600MB + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription> + Or click to browse files + </FormDescription> + <FormMessage /> + </FormItem> + )} + </Dropzone> + </div> + + {/* Existing + Pending Files */} + <div className="mt-4 space-y-4"> + {/* 1) Not-yet-uploaded files */} + {newUploads.length > 0 && ( + <div className="grid gap-2"> + <h6 className="text-sm font-medium"> + Pending Files ({newUploads.length}) + </h6> + <FileList> + {newUploads.map((f, fileIndex) => { + const fileObj = f.fileObj + if (!fileObj) return null + + return ( + <FileListItem key={fileIndex}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileObj.name}</FileListName> + <FileListDescription> + {prettyBytes(fileObj.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => + removeNewUpload(answerIndex, fileIndex) + } + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ) + })} + </FileList> + </div> + )} + + {/* 2) Already uploaded files */} + {form + .watch(`answers.${answerIndex}.uploadedFiles`) + .map((file, fileIndex) => ( + <FileListItem key={fileIndex}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + {/* If you want to display the path: + <FileListDescription>{file.url}</FileListDescription> + */} + </FileListInfo> + {file.size && ( + <span className="text-xs text-muted-foreground"> + {prettyBytes(file.size)} + </span> + )} + <FileListAction + onClick={() => + removeUploadedFile(answerIndex, fileIndex) + } + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + ) + })} + </div> + </TabsContent> + ))} + </Tabs> + </form> + + {/* Confirmation Dialog */} + <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Submission</DialogTitle> + <DialogDescription> + {projectId + ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?` + : "일반 PQ 응답을 제출하시겠습니까?" + } 제출 후에는 수정이 불가능합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 max-h-[600px] overflow-y-auto "> + {data.map((group) => ( + <Collapsible key={group.groupName} defaultOpen> + <CollapsibleTrigger asChild> + <div className="flex justify-between items-center p-2 mb-1 cursor-pointer "> + <p className="font-semibold">{group.groupName}</p> + <ChevronsUpDown className="h-4 w-4 ml-2" /> + </div> + </CollapsibleTrigger> + + <CollapsibleContent> + {group.items.map((item) => { + const answerObj = form + .getValues() + .answers.find((a) => a.criteriaId === item.criteriaId) + + if (!answerObj) return null + + return ( + <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm"> + {/* code & checkPoint */} + <p className="font-semibold"> + {item.code} - {item.checkPoint} + </p> + + {/* user's typed answer */} + <p className="text-sm font-medium mt-2">Answer:</p> + <p className="whitespace-pre-wrap text-sm"> + {answerObj.answer || "(no answer)"} + </p> + {/* attachments */} + <p>Attachments:</p> + {answerObj.uploadedFiles.length > 0 ? ( + <ul className="list-disc list-inside ml-4 text-xs"> + {answerObj.uploadedFiles.map((file, idx) => ( + <li key={idx}>{file.fileName}</li> + ))} + </ul> + ) : ( + <p className="text-xs text-muted-foreground">(none)</p> + )} + </div> + ) + })} + </CollapsibleContent> + </Collapsible> + ))} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowConfirmDialog(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button onClick={handleConfirmSubmission} disabled={isSubmitting}> + {isSubmitting ? "Submitting..." : "Confirm Submit"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </Form> + ) +}
\ No newline at end of file diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx new file mode 100644 index 00000000..216df422 --- /dev/null +++ b/components/pq-input/pq-review-wrapper.tsx @@ -0,0 +1,330 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, + CardFooter +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Textarea } from "@/components/ui/textarea" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog" +import { useToast } from "@/hooks/use-toast" +import { CheckCircle, AlertCircle, FileText, Paperclip } from "lucide-react" +import { PQGroupData } from "@/lib/pq/service" +import { approvePQAction, rejectPQAction } from "@/lib/pq/service" + +// PQ 제출 정보 타입 +interface PQSubmission { + id: number + vendorId: number + vendorName: string + vendorCode: string + type: string + status: string + projectId: number | null + projectName: string | null + projectCode: string | null + submittedAt: Date | null + approvedAt: Date | null + rejectedAt: Date | null + rejectReason: string | null +} + +interface PQReviewWrapperProps { + pqData: PQGroupData[] + vendorId: number + pqSubmission: PQSubmission + canReview: boolean +} + +export function PQReviewWrapper({ + pqData, + vendorId, + pqSubmission, + canReview +}: PQReviewWrapperProps) { + const router = useRouter() + const { toast } = useToast() + const [isApproving, setIsApproving] = React.useState(false) + const [isRejecting, setIsRejecting] = React.useState(false) + const [showApproveDialog, setShowApproveDialog] = React.useState(false) + const [showRejectDialog, setShowRejectDialog] = React.useState(false) + const [rejectReason, setRejectReason] = React.useState("") + + // PQ 승인 처리 + const handleApprove = async () => { + try { + setIsApproving(true) + + const result = await approvePQAction({ + pqSubmissionId: pqSubmission.id, + vendorId: vendorId + }) + + if (result.ok) { + toast({ + title: "PQ 승인 완료", + description: "PQ가 성공적으로 승인되었습니다.", + }) + // 페이지 새로고침 + router.refresh() + } else { + toast({ + title: "승인 실패", + description: result.error || "PQ 승인 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } catch (error) { + console.error("PQ 승인 오류:", error) + toast({ + title: "승인 실패", + description: "PQ 승인 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsApproving(false) + setShowApproveDialog(false) + } + } + + // PQ 거부 처리 + const handleReject = async () => { + if (!rejectReason.trim()) { + toast({ + title: "거부 사유 필요", + description: "거부 사유를 입력해주세요.", + variant: "destructive" + }) + return + } + + try { + setIsRejecting(true) + + const result = await rejectPQAction({ + pqSubmissionId: pqSubmission.id, + vendorId: vendorId, + rejectReason: rejectReason + }) + + if (result.ok) { + toast({ + title: "PQ 거부 완료", + description: "PQ가 거부되었습니다.", + }) + // 페이지 새로고침 + router.refresh() + } else { + toast({ + title: "거부 실패", + description: result.error || "PQ 거부 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } catch (error) { + console.error("PQ 거부 오류:", error) + toast({ + title: "거부 실패", + description: "PQ 거부 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsRejecting(false) + setShowRejectDialog(false) + } + } + + return ( + <div className="space-y-6"> + {/* 그룹별 PQ 항목 표시 */} + {pqData.map((group) => ( + <div key={group.groupName} className="space-y-4"> + <h3 className="text-lg font-medium">{group.groupName}</h3> + + <div className="grid grid-cols-1 gap-4"> + {group.items.map((item) => ( + <Card key={item.criteriaId}> + <CardHeader> + <div className="flex justify-between items-start"> + <div> + <CardTitle className="text-base"> + {item.code} - {item.checkPoint} + </CardTitle> + {item.description && ( + <CardDescription className="mt-1 whitespace-pre-wrap"> + {item.description} + </CardDescription> + )} + </div> + {/* 항목 상태 표시 */} + {!!item.answer || item.attachments.length > 0 ? ( + <Badge variant="outline" className="text-green-600 bg-green-50"> + <CheckCircle className="h-3 w-3 mr-1" /> + 답변 있음 + </Badge> + ) : ( + <Badge variant="outline" className="text-amber-600 bg-amber-50"> + <AlertCircle className="h-3 w-3 mr-1" /> + 답변 없음 + </Badge> + )} + </div> + </CardHeader> + <CardContent className="space-y-4"> + {/* 프로젝트별 추가 정보 */} + {pqSubmission.projectId && item.contractInfo && ( + <div className="space-y-1"> + <p className="text-sm font-medium">계약 정보</p> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {item.contractInfo} + </div> + </div> + )} + + {pqSubmission.projectId && item.additionalRequirement && ( + <div className="space-y-1"> + <p className="text-sm font-medium">추가 요구사항</p> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {item.additionalRequirement} + </div> + </div> + )} + + {/* 벤더 답변 */} + <div className="space-y-1"> + <p className="text-sm font-medium flex items-center gap-1"> + <FileText className="h-4 w-4" /> + 벤더 답변 + </p> + <div className="rounded-md border p-3 min-h-20 whitespace-pre-wrap"> + {item.answer || <span className="text-muted-foreground">답변 없음</span>} + </div> + </div> + + {/* 첨부 파일 */} + {item.attachments.length > 0 && ( + <div className="space-y-1"> + <p className="text-sm font-medium flex items-center gap-1"> + <Paperclip className="h-4 w-4" /> + 첨부 파일 ({item.attachments.length}) + </p> + <div className="rounded-md border p-3"> + <ul className="space-y-1"> + {item.attachments.map((attachment, idx) => ( + <li key={idx} className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <a + href={attachment.filePath} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-blue-600 hover:underline" + > + {attachment.fileName} + </a> + </li> + ))} + </ul> + </div> + </div> + )} + </CardContent> + </Card> + ))} + </div> + </div> + ))} + + {/* 검토 버튼 */} + {canReview && ( + <div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border"> + <div className="flex gap-2"> + <Button + variant="outline" + onClick={() => setShowRejectDialog(true)} + disabled={isRejecting} + > + {isRejecting ? "거부 중..." : "거부"} + </Button> + <Button + variant="default" + onClick={() => setShowApproveDialog(true)} + disabled={isApproving} + > + {isApproving ? "승인 중..." : "승인"} + </Button> + </div> + </div> + )} + + {/* 승인 확인 다이얼로그 */} + <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>PQ 승인 확인</DialogTitle> + <DialogDescription> + {pqSubmission.vendorName}의 {pqSubmission.type === "GENERAL" ? "일반" : "프로젝트"} PQ를 승인하시겠습니까? + {pqSubmission.projectId && ( + <span> 프로젝트: {pqSubmission.projectName}</span> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setShowApproveDialog(false)}> + 취소 + </Button> + <Button onClick={handleApprove} disabled={isApproving}> + {isApproving ? "승인 중..." : "승인"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 거부 확인 다이얼로그 */} + <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>PQ 거부</DialogTitle> + <DialogDescription> + {pqSubmission.vendorName}의 {pqSubmission.type === "GENERAL" ? "일반" : "프로젝트"} PQ를 거부하는 이유를 입력해주세요. + {pqSubmission.projectId && ( + <span> 프로젝트: {pqSubmission.projectName}</span> + )} + </DialogDescription> + </DialogHeader> + <Textarea + value={rejectReason} + onChange={(e) => setRejectReason(e.target.value)} + placeholder="거부 사유를 입력하세요" + className="min-h-24" + /> + <DialogFooter> + <Button variant="outline" onClick={() => setShowRejectDialog(false)}> + 취소 + </Button> + <Button + variant="destructive" + onClick={handleReject} + disabled={isRejecting || !rejectReason.trim()} + > + {isRejecting ? "거부 중..." : "거부"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ) +}
\ No newline at end of file diff --git a/components/shell.tsx b/components/shell.tsx index 8082109b..cfa03c9e 100644 --- a/components/shell.tsx +++ b/components/shell.tsx @@ -3,13 +3,14 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" -const shellVariants = cva("grid items-center gap-8 pb-8 pt-6 md:py-8", { +const shellVariants = cva("", { variants: { variant: { - default: "container", + default: "container grid items-center gap-8 pb-8 pt-6 md:py-8", sidebar: "", centered: "container flex h-dvh max-w-2xl flex-col justify-center py-16", markdown: "container max-w-3xl py-8 md:py-10 lg:py-10", + fullscreen: "container h-full flex flex-col gap-4 py-4", // 새로운 fullscreen variant }, }, defaultVariants: { @@ -34,4 +35,4 @@ function Shell({ ) } -export { Shell, shellVariants } +export { Shell, shellVariants }
\ No newline at end of file diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx index 5afd41d1..e3292bb0 100644 --- a/components/ui/alert.tsx +++ b/components/ui/alert.tsx @@ -11,6 +11,9 @@ const alertVariants = cva( default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + // success 추가 + success: + "border-green-500/30 bg-green-50 text-green-700 [&>svg]:text-green-600", }, }, defaultVariants: { diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index e87d62bf..ddf9c287 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -15,6 +15,8 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", + success: + "border-transparent bg-green-500 text-white shadow hover:bg-green-600", }, }, defaultVariants: { diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index fcecae43..47397c72 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -14,6 +14,8 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Button } from "@/components/ui/button" import { FormInput } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" +import { selectedModeAtom } from '@/atoms' +import { useAtom } from 'jotai' interface PackageData { itemId: number @@ -88,15 +90,19 @@ export function VendorDataContainer({ // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 const isShipProject = currentProject?.projectType === "ship" + const [selectedMode, setSelectedMode] = useAtom(selectedModeAtom) + // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) const modeFromUrl = searchParams?.get('mode') const initialMode = isShipProject ? "ENG" : (modeFromUrl === "ENG" || modeFromUrl === "IM") ? modeFromUrl : "IM" - - // 모드 선택 상태 - 프로젝트 타입에 따라 초기값 결정 - const [selectedMode, setSelectedMode] = React.useState<"IM" | "ENG">(initialMode) - + + // 모드 초기화 (기존의 useState 초기값 대신) + React.useEffect(() => { + setSelectedMode(initialMode as "IM" | "ENG") + }, [initialMode, setSelectedMode]) + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false const currentPackageName = isTagOrFormRoute ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" @@ -218,25 +224,57 @@ export function VendorDataContainer({ } // 모드 변경 핸들러 - const handleModeChange = (mode: "IM" | "ENG") => { - // ship 프로젝트인 경우 모드 변경 금지 - if (isShipProject && mode !== "ENG") return; - - setSelectedMode(mode); +// 모드 변경 핸들러 +const handleModeChange = async (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; + + setSelectedMode(mode); + + // 모드가 변경될 때 자동 네비게이션 + if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; - // 현재 URL에서 mode 파라미터 업데이트 (현재 경로 유지) + if (mode === "IM") { + // IM 모드: 첫 번째 패키지로 이동 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); + } else { + // ENG 모드: 폼 목록을 먼저 로드 + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(firstPackageId, mode); + setFormList(result.forms || []); + + // 폼이 있으면 첫 번째 폼으로 이동 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0]; + setSelectedFormCode(firstForm.formCode); + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } else { + // 폼이 없으면 모드만 변경 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + // 오류 발생 시 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } finally { + setIsLoadingForms(false); + } + } + } else { + // 패키지가 없는 경우, 모드만 변경 const url = new URL(window.location.href); url.searchParams.set('mode', mode); router.replace(url.pathname + url.search); - - // 모드가 변경되면 현재 패키지의 폼 다시 로드 - if (selectedPackageId) { - loadFormsList(selectedPackageId, mode); - } else if (currentContract?.packages?.length) { - const firstPackageId = currentContract.packages[0].itemId; - loadFormsList(firstPackageId, mode); - } - }; + } +}; return ( <TooltipProvider delayDuration={0}> @@ -298,7 +336,7 @@ export function VendorDataContainer({ className="w-full" > <TabsList className="w-full"> - <TabsTrigger value="IM" className="flex-1">IM</TabsTrigger> + <TabsTrigger value="IM" className="flex-1">H/O</TabsTrigger> <TabsTrigger value="ENG" className="flex-1">ENG</TabsTrigger> </TabsList> @@ -357,7 +395,7 @@ export function VendorDataContainer({ className="h-8 px-2" onClick={() => handleModeChange("IM")} > - IM + H/O </Button> <Button variant={selectedMode === "ENG" ? "default" : "ghost"} |
