summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:18:16 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:18:16 +0000
commit748bb1720fd81e97a84c3e92f89d606e976b52e3 (patch)
treea7f7f377035cd04912fe0541368884f976f4ee6d /components
parent9e280704988fdeffa05c1d8cbb731722f666c6af (diff)
(대표님) 컴포넌트 파트 커밋
Diffstat (limited to 'components')
-rw-r--r--components/client-data-table/data-table.tsx45
-rw-r--r--components/data-table/expandable-data-table.tsx421
-rw-r--r--components/form-data/import-excel-form.tsx34
-rw-r--r--components/form-data/sedp-compare-dialog.tsx317
-rw-r--r--components/layout/providers.tsx49
-rw-r--r--components/po-rfq/detail-table/add-vendor-dialog.tsx512
-rw-r--r--components/po-rfq/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--components/po-rfq/detail-table/rfq-detail-column.tsx369
-rw-r--r--components/po-rfq/detail-table/rfq-detail-table.tsx519
-rw-r--r--components/po-rfq/detail-table/update-vendor-sheet.tsx449
-rw-r--r--components/po-rfq/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx665
-rw-r--r--components/po-rfq/po-rfq-container.tsx261
-rw-r--r--components/po-rfq/rfq-filter-sheet.tsx643
-rw-r--r--components/pq-input/pq-input-tabs.tsx895
-rw-r--r--components/pq-input/pq-review-wrapper.tsx330
-rw-r--r--components/shell.tsx7
-rw-r--r--components/ui/alert.tsx3
-rw-r--r--components/ui/badge.tsx2
-rw-r--r--components/vendor-data/vendor-data-container.tsx80
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>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({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>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({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"}