summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/table')
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx8
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx4
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx21
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx10
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx312
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx101
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx178
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx341
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx4
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx99
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx22
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx231
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx183
13 files changed, 1008 insertions, 506 deletions
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
index 4ba98cc7..7bbbfa75 100644
--- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -362,18 +362,16 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
)}
/>
- <Separator className="my-4" />
-
{/* RFQ 설명 */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>RFQ 설명</FormLabel>
+ <FormLabel>RFQ Title</FormLabel>
<FormControl>
<Input
- placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
{...field}
/>
</FormControl>
@@ -381,9 +379,7 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
</FormItem>
)}
/>
-
<Separator className="my-4" />
-
{/* 마감일 설정 */}
<FormField
control={form.control}
diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index 8a66f26e..b616f526 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -385,10 +385,10 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>RFQ 설명</FormLabel>
+ <FormLabel>RFQ Title</FormLabel>
<FormControl>
<Input
- placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
{...field}
/>
</FormControl>
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
index 70f56ebd..6536e230 100644
--- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -3,7 +3,6 @@
import * as React from "react"
import { toast } from "sonner"
import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-import { Input } from "@/components/ui/input"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { CalendarIcon } from "lucide-react"
@@ -43,6 +42,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { Input } from "@/components/ui/input"
// 공종 타입 import
import {
@@ -354,7 +354,24 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
/>
<Separator className="my-4" />
-
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Separator className="my-4" />
{/* 마감일 설정 */}
<FormField
control={form.control}
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
index 3574111f..8f2fe948 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -29,6 +29,8 @@ type VendorFormValues = z.infer<typeof vendorFormSchema>
type TechSalesRfq = {
id: number
rfqCode: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm: string | null // 프로젝트 타입명 추가
status: string
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
}
@@ -118,10 +120,8 @@ export function AddVendorDialog({
setIsSearching(true)
try {
// 선택된 RFQ의 타입을 기반으로 벤더 검색
- const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" :
- selectedRfq?.rfqCode?.includes("TOP") ? "TOP" :
- selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined;
-
+ const rfqType = selectedRfq?.rfqType || undefined;
+ console.log("rfqType", rfqType) // 디버깅용
const results = await searchTechVendors(term, 100, rfqType)
// 이미 추가된 벤더 제외
@@ -136,7 +136,7 @@ export function AddVendorDialog({
setIsSearching(false)
}
},
- [existingVendorIds]
+ [existingVendorIds, selectedRfq?.rfqType]
)
// 검색어 변경 시 디바운스 적용
diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
new file mode 100644
index 00000000..7832fa2b
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
@@ -0,0 +1,312 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationSnapshot {
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string | null
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+}
+
+interface QuotationRevision {
+ id: number
+ version: number
+ snapshot: QuotationSnapshot
+ changeReason: string | null
+ revisionNote: string | null
+ revisedBy: number | null
+ revisedAt: Date
+ revisedByName: string | null
+ attachments: QuotationAttachment[]
+}
+
+interface QuotationHistoryData {
+ current: {
+ id: number
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+ attachments: QuotationAttachment[]
+ }
+ revisions: QuotationRevision[]
+}
+
+interface QuotationHistoryDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+}
+
+const statusConfig = {
+ "Draft": { label: "초안", color: "bg-yellow-100 text-yellow-800" },
+ "Submitted": { label: "제출됨", color: "bg-blue-100 text-blue-800" },
+ "Revised": { label: "수정됨", color: "bg-purple-100 text-purple-800" },
+ "Accepted": { label: "승인됨", color: "bg-green-100 text-green-800" },
+ "Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" },
+}
+
+function QuotationCard({
+ data,
+ version,
+ isCurrent = false,
+ changeReason,
+ revisedBy,
+ revisedAt,
+ attachments
+}: {
+ data: QuotationSnapshot | QuotationHistoryData["current"]
+ version: number
+ isCurrent?: boolean
+ changeReason?: string | null
+ revisedBy?: string | null
+ revisedAt?: Date
+ attachments?: QuotationAttachment[]
+}) {
+ const statusInfo = statusConfig[data.status as keyof typeof statusConfig] ||
+ { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" }
+
+ return (
+ <Card className={`${isCurrent ? "border-blue-500 shadow-md" : "border-gray-200"}`}>
+ <CardHeader className="pb-3">
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg flex items-center gap-2">
+ <span>버전 {version}</span>
+ {isCurrent && <Badge variant="default">현재</Badge>}
+ </CardTitle>
+ <Badge className={statusInfo.color}>
+ {statusInfo.label}
+ </Badge>
+ </div>
+ {changeReason && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <FileText className="size-4" />
+ <span>{changeReason}</span>
+ </div>
+ )}
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">견적 금액</p>
+ <p className="text-lg font-semibold">
+ {data.totalPrice ? `${data.currency} ${Number(data.totalPrice).toLocaleString()}` : "미입력"}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">유효 기한</p>
+ <p className="text-sm">
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
+ </p>
+ </div>
+ </div>
+
+ {data.remark && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">비고</p>
+ <p className="text-sm bg-gray-50 p-2 rounded">{data.remark}</p>
+ </div>
+ )}
+
+ {/* 첨부파일 섹션 */}
+ {attachments && attachments.length > 0 && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
+ <Paperclip className="size-3" />
+ 첨부파일 ({attachments.length}개)
+ </p>
+ <div className="space-y-1">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
+ <div className="flex items-center gap-2 min-w-0 flex-1">
+ <div className="min-w-0 flex-1">
+ <p className="font-medium truncate" title={attachment.originalFileName}>
+ {attachment.originalFileName}
+ </p>
+ {attachment.description && (
+ <p className="text-muted-foreground truncate" title={attachment.description}>
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="text-muted-foreground whitespace-nowrap ml-2">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Clock className="size-3" />
+ <span>
+ {isCurrent
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
+ }
+ </span>
+ </div>
+ {revisedBy && (
+ <div className="flex items-center gap-1">
+ <User className="size-3" />
+ <span>{revisedBy}</span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
+export function QuotationHistoryDialog({
+ open,
+ onOpenChange,
+ quotationId
+}: QuotationHistoryDialogProps) {
+ const [data, setData] = useState<QuotationHistoryData | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationHistory()
+ }
+ }, [open, quotationId])
+
+ const loadQuotationHistory = async () => {
+ if (!quotationId) return
+
+ try {
+ setIsLoading(true)
+ const { getTechSalesVendorQuotationWithRevisions } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationWithRevisions(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setData(result.data as QuotationHistoryData)
+ } catch (error) {
+ console.error("견적 히스토리 로드 오류:", error)
+ toast.error("견적 히스토리를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ onOpenChange(newOpen)
+ if (!newOpen) {
+ setData(null) // 다이얼로그 닫을 때 데이터 초기화
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className=" max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>견적서 수정 히스토리</DialogTitle>
+ <DialogDescription>
+ 견적서의 변경 이력을 확인할 수 있습니다. 최신 버전부터 순서대로 표시됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-32" />
+ <Skeleton className="h-32 w-full" />
+ </div>
+ ))}
+ </div>
+ ) : data ? (
+ <>
+ {/* 현재 버전 */}
+ <QuotationCard
+ data={data.current}
+ version={data.current.quotationVersion || 1}
+ isCurrent={true}
+ attachments={data.current.attachments}
+ />
+
+ {/* 이전 버전들 */}
+ {data.revisions.length > 0 ? (
+ data.revisions.map((revision) => (
+ <QuotationCard
+ key={revision.id}
+ data={revision.snapshot}
+ version={revision.version}
+ changeReason={revision.changeReason}
+ revisedBy={revision.revisedByName}
+ revisedAt={revision.revisedAt}
+ attachments={revision.attachments}
+ />
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>수정 이력이 없습니다.</p>
+ <p className="text-sm">이 견적서는 아직 수정되지 않았습니다.</p>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>견적서 정보를 불러올 수 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index 3e50a516..e921fcaa 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table";
import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2 } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -38,6 +38,24 @@ export interface RfqDetailView {
createdAt: Date | null
updatedAt: Date | null
createdByName: string | null
+ quotationCode?: string | null
+ rfqCode?: string | null
+ quotationAttachments?: Array<{
+ id: number
+ revisionId: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+}
+
+// 견적서 정보 타입 (Sheet용)
+export interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
}
interface GetColumnsProps<TData> {
@@ -45,11 +63,15 @@ interface GetColumnsProps<TData> {
React.SetStateAction<DataTableRowAction<TData> | null>
>;
unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+ onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
+ openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
setRowAction,
- unreadMessages = {}
+ unreadMessages = {},
+ onQuotationClick,
+ openQuotationAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -66,15 +88,15 @@ export function getRfqDetailColumns({
),
cell: ({ row }) => {
const status = row.original.status;
- const isDraft = status === "Draft";
+ const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
- disabled={!isDraft}
+ disabled={!isSelectable}
aria-label="행 선택"
- className={!isDraft ? "opacity-50 cursor-not-allowed" : ""}
+ className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
/>
);
},
@@ -163,15 +185,31 @@ export function getRfqDetailColumns({
cell: ({ row }) => {
const value = row.getValue("totalPrice") as string | number | null;
const currency = row.getValue("currency") as string | null;
+ const quotationId = row.original.id;
if (value === null || value === undefined) return "-";
// 숫자로 변환 시도
const numValue = typeof value === 'string' ? parseFloat(value) : value;
+ const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
+
+ // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
+ if (onQuotationClick && quotationId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-left justify-start hover:underline"
+ onClick={() => onQuotationClick(quotationId)}
+ title="견적 히스토리 보기"
+ >
+ {displayValue} {currency}
+ </Button>
+ );
+ }
return (
<div className="font-medium">
- {isNaN(numValue) ? value : numValue.toLocaleString()} {currency}
+ {displayValue} {currency}
</div>
);
},
@@ -182,6 +220,57 @@ export function getRfqDetailColumns({
size: 140,
},
{
+ accessorKey: "quotationAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.quotationAttachments || [];
+ const attachmentCount = attachments.length;
+
+ if (attachmentCount === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={() => {
+ // 견적서 첨부파일 sheet 열기
+ if (openQuotationAttachmentsSheet) {
+ const quotation = row.original;
+ openQuotationAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ }
+ }}
+ title={
+ attachmentCount === 1
+ ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
+ : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
accessorKey: "currency",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="통화" />
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index f2eda8d9..1d701bd5 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -12,12 +12,14 @@ import { toast } from "sonner"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react"
+import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
import { ClientDataTable } from "@/components/client-data-table/data-table"
import { AddVendorDialog } from "./add-vendor-dialog"
import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
-import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog"
import { DeleteVendorsDialog } from "../delete-vendors-dialog"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import type { QuotationInfo } from "./rfq-detail-column"
// 기본적인 RFQ 타입 정의
interface TechSalesRfq {
@@ -30,6 +32,8 @@ interface TechSalesRfq {
rfqSendDate?: Date | null
dueDate?: Date | null
createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
}
// 프로퍼티 정의
@@ -58,9 +62,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
// 읽지 않은 메시지 개수
const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
- // 견적 비교 다이얼로그 상태 관리
- const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
-
// 테이블 선택 상태 관리
const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
const [isSendingRfq, setIsSendingRfq] = useState(false)
@@ -69,6 +70,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
// 벤더 삭제 확인 다이얼로그 상태 추가
const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
// selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
@@ -108,6 +119,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
detailId: item.id,
rfqId: selectedRfqId,
rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
vendorId: item.vendorId ? Number(item.vendorId) : undefined,
})) || []
@@ -121,7 +134,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
console.error("데이터 새로고침 오류:", error)
toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
}
- }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
// 벤더 추가 핸들러 메모이제이션
const handleAddVendor = useCallback(async () => {
@@ -180,6 +193,54 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
}, [selectedRows, selectedRfqId, handleRefreshData]);
+ // 벤더 선택 핸들러 추가
+ const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
+
+ const handleAcceptVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("선택할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (selectedRows.length > 1) {
+ toast.warning("하나의 벤더만 선택할 수 있습니다.");
+ return;
+ }
+
+ const selectedQuotation = selectedRows[0];
+ if (selectedQuotation.status !== "Submitted") {
+ toast.warning("제출된 견적서만 선택할 수 있습니다.");
+ return;
+ }
+
+ try {
+ setIsAcceptingVendors(true);
+
+ // 벤더 견적 승인 서비스 함수 호출
+ const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
+
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
+ } else {
+ toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 선택 오류:", error);
+ toast.error("벤더 선택 중 오류가 발생했습니다.");
+ } finally {
+ setIsAcceptingVendors(false);
+ }
+ }, [selectedRows, handleRefreshData]);
+
// 벤더 삭제 핸들러 메모이제이션
const handleDeleteVendors = useCallback(async () => {
if (selectedRows.length === 0) {
@@ -246,27 +307,47 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
await handleDeleteVendors();
}, [handleDeleteVendors]);
- // 견적 비교 다이얼로그 열기 핸들러 메모이제이션
- const handleOpenComparisonDialog = useCallback(() => {
- // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
- const hasSubmittedQuotations = details.some(detail =>
- detail.status === "Submitted" // RfqDetailView의 실제 필드 사용
- );
- if (!hasSubmittedQuotations) {
- toast.warning("제출된 견적이 없습니다.");
- return;
- }
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
- setComparisonDialogOpen(true);
- }, [details])
+ // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
+ const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingAttachments(true);
+ setSelectedQuotationInfo(quotationInfo);
+ setQuotationAttachmentsSheetOpen(true);
+
+ // 견적서 첨부파일 조회
+ const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
+ const result = await getTechSalesVendorQuotationAttachments(quotationId);
+
+ if (result.error) {
+ toast.error(result.error);
+ setQuotationAttachments([]);
+ } else {
+ setQuotationAttachments(result.data || []);
+ }
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
+ setQuotationAttachments([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ }, [])
// 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
const columns = useMemo(() =>
getRfqDetailColumns({
setRowAction,
- unreadMessages
- }), [unreadMessages])
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -493,6 +574,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
)}
</div>
<div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={selectedRows.length === 0 || isAcceptingVendors}
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
{/* RFQ 발송 버튼 */}
<Button
variant="outline"
@@ -525,22 +622,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
<span>벤더 삭제</span>
</Button>
- {/* 견적 비교 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenComparisonDialog}
- className="gap-2"
- disabled={
- !selectedRfq ||
- details.length === 0 ||
- vendorsWithQuotations === 0
- }
- >
- <BarChart2 className="size-4" aria-hidden="true" />
- <span>견적 비교/선택</span>
- </Button>
-
{/* 벤더 추가 버튼 */}
<Button
variant="outline"
@@ -586,7 +667,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
<AddVendorDialog
open={vendorDialogOpen}
onOpenChange={setVendorDialogOpen}
- selectedRfq={selectedRfq}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
existingVendorIds={existingVendorIds}
onSuccess={handleRefreshData}
/>
@@ -600,13 +681,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onSuccess={handleRefreshData}
/>
- {/* 견적 비교 다이얼로그 */}
- <VendorQuotationComparisonDialog
- open={comparisonDialogOpen}
- onOpenChange={setComparisonDialogOpen}
- selectedRfq={selectedRfq}
- />
-
{/* 다중 벤더 삭제 확인 다이얼로그 */}
<DeleteVendorsDialog
open={deleteConfirmDialogOpen}
@@ -615,6 +689,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onConfirm={executeDeleteVendors}
isLoading={isDeletingVendors}
/>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
deleted file mode 100644
index 0a6caa5c..00000000
--- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
+++ /dev/null
@@ -1,341 +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"
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
-
-// Lucide 아이콘
-import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react"
-
-import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service"
-import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions"
-import { formatCurrency, formatDate } from "@/lib/utils"
-import { techSalesVendorQuotations } from "@/db/schema/techSales"
-
-// 기술영업 견적 정보 타입
-interface TechSalesVendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- vendorName?: string | null
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- status: string
- remark: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- createdAt: Date
- updatedAt: Date
-}
-
-interface VendorQuotationComparisonDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: {
- id: number;
- rfqCode: string | null;
- status: string;
- [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
- } | null
-}
-
-export function VendorQuotationComparisonDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: VendorQuotationComparisonDialogProps) {
- const [isLoading, setIsLoading] = useState(false)
- const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([])
- const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null)
- const [isAccepting, setIsAccepting] = useState(false)
- const [showConfirmDialog, setShowConfirmDialog] = useState(false)
-
- useEffect(() => {
- async function loadQuotationData() {
- if (!open || !selectedRfq?.id) return
-
- try {
- setIsLoading(true)
- // 기술영업 견적 목록 조회 (제출된 견적만)
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId: selectedRfq.id,
- page: 1,
- perPage: 100,
- filters: [
- {
- id: "status" as keyof typeof techSalesVendorQuotations,
- value: "Submitted",
- type: "select" as const,
- operator: "eq" as const,
- rowId: "status"
- }
- ]
- })
-
- setQuotations(result.data || [])
- } 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"
- }
- }
-
- // 벤더 선택 핸들러
- const handleSelectVendor = (vendorId: number) => {
- setSelectedVendorId(vendorId)
- setShowConfirmDialog(true)
- }
-
- // 벤더 선택 확정
- const handleConfirmSelection = async () => {
- if (!selectedVendorId) return
-
- try {
- setIsAccepting(true)
-
- // 선택된 견적의 ID 찾기
- const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId)
- if (!selectedQuotation) {
- toast.error("선택된 견적을 찾을 수 없습니다")
- return
- }
-
- // 벤더 선택 API 호출
- const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id)
-
- if (result.success) {
- toast.success(result.message || "벤더가 선택되었습니다")
- setShowConfirmDialog(false)
- onOpenChange(false)
-
- // 페이지 새로고침 또는 데이터 재로드
- window.location.reload()
- } else {
- toast.error(result.error || "벤더 선택에 실패했습니다")
- }
- } catch (error) {
- console.error("벤더 선택 오류:", error)
- toast.error("벤더 선택에 실패했습니다")
- } finally {
- setIsAccepting(false)
- }
- }
-
- const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId)
-
- return (
- <>
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]">
- <DialogHeader>
- <DialogTitle>벤더 견적 비교 및 선택</DialogTitle>
- <DialogDescription>
- {selectedRfq
- ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요`
- : ""}
- </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>
- ) : (
- <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 w-32">
- 항목
- </TableHead>
- {quotations.map((q) => (
- <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48">
- <div className="flex flex-col items-center gap-2">
- <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
- <Button
- size="sm"
- variant={q.status === "Accepted" ? "default" : "outline"}
- onClick={() => handleSelectVendor(q.vendorId)}
- disabled={q.status === "Accepted"}
- className="gap-1"
- >
- {q.status === "Accepted" ? (
- <>
- <CheckCircle className="h-4 w-4" />
- 선택됨
- </>
- ) : (
- "선택"
- )}
- </Button>
- </div>
- </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 text-center">
- <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={`total-${q.id}`} className="p-2 font-semibold text-center">
- {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'}
- </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 text-center">
- {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 text-center">
- {q.validUntil ? 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={`submitted-${q.id}`} className="p-2 text-center">
- {q.submittedAt ? 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 text-center"
- >
- {q.remark || "-"}
- </TableCell>
- ))}
- </TableRow>
- </tbody>
- </table>
- </div>
- )}
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 벤더 선택 확인 다이얼로그 */}
- <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle>
- <AlertDialogDescription>
- <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까?
- <br />
- <br />
- 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다.
- 이 작업은 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={handleConfirmSelection}
- disabled={isAccepting}
- className="gap-2"
- >
- {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />}
- 확인
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
-}
diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
index 10bc9f1f..289ad312 100644
--- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
+++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
@@ -30,10 +30,10 @@ interface RfqItemsViewDialogProps {
onOpenChange: (open: boolean) => void;
rfq: {
id: number;
- rfqCode?: string;
+ rfqCode?: string | null;
status?: string;
description?: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
+ rfqType?: "SHIP" | "TOP" | "HULL" | null;
} | null;
}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 51c143a4..3009e036 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -6,13 +6,14 @@ import { formatDate, formatDateTime } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { DataTableRowAction } from "@/types/table"
-import { Paperclip, Package } from "lucide-react"
+import { Paperclip, Package, FileText, BarChart3 } from "lucide-react"
import { Button } from "@/components/ui/button"
// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
type TechSalesRfq = {
id: number
rfqCode: string | null
+ description: string | null
dueDate: Date
rfqSendDate: Date | null
status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
@@ -33,6 +34,8 @@ type TechSalesRfq = {
projMsrm: number
ptypeNm: string
attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
quotationCount: number
itemCount: number
// 나머지 필드는 사용할 때마다 추가
@@ -41,7 +44,7 @@ type TechSalesRfq = {
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
- openAttachmentsSheet: (rfqId: number) => void;
+ openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
openItemsDialog: (rfq: TechSalesRfq) => void;
}
@@ -110,6 +113,18 @@ export function getColumns({
size: 120,
},
{
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "RFQ Title"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -286,14 +301,14 @@ export function getColumns({
{
id: "attachments",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 첨부파일" />
),
cell: ({ row }) => {
const rfq = row.original
const attachmentCount = rfq.attachmentCount || 0
const handleClick = () => {
- openAttachmentsSheet(rfq.id)
+ openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
}
return (
@@ -325,5 +340,81 @@ export function getColumns({
excelHeader: "첨부파일"
},
},
+ {
+ id: "tbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasTbeAttachments = rfq.hasTbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'TBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ >
+ <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
+ {hasTbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "TBE 결과"
+ },
+ },
+ {
+ id: "cbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasCbeAttachments = rfq.hasCbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'CBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ >
+ <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
+ {hasCbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "CBE 결과"
+ },
+ },
]
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 424ca70e..615753cd 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -57,6 +57,7 @@ interface TechSalesRfq {
ptypeNm: string
attachmentCount: number
quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
// 필요에 따라 다른 필드들 추가
[key: string]: unknown
}
@@ -135,7 +136,7 @@ export function RFQListTable({
to: searchParams?.get('to') || undefined,
columnVisibility: {},
columnOrder: [],
- pinnedColumns: { left: [], right: ["items", "attachments"] },
+ pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
groupBy: [],
expandedRows: []
}), [searchParams])
@@ -170,6 +171,7 @@ export function RFQListTable({
setSelectedRfq({
id: rfqData.id,
rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
biddingProjectId: rfqData.biddingProjectId,
materialCode: rfqData.materialCode,
dueDate: rfqData.dueDate,
@@ -201,6 +203,7 @@ export function RFQListTable({
setProjectDetailRfq({
id: projectRfqData.id,
rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
biddingProjectId: projectRfqData.biddingProjectId,
materialCode: projectRfqData.materialCode,
dueDate: projectRfqData.dueDate,
@@ -238,8 +241,11 @@ export function RFQListTable({
}
}, [rowAction])
+ // 첨부파일 시트 상태에 타입 추가
+ const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
// 첨부파일 시트 열기 함수
- const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
try {
// 선택된 RFQ 찾기
const rfq = tableData?.data?.find(r => r.id === rfqId)
@@ -248,6 +254,9 @@ export function RFQListTable({
return
}
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
// 실제 첨부파일 목록 조회 API 호출
const result = await getTechSalesRfqAttachments(rfqId)
@@ -256,8 +265,11 @@ export function RFQListTable({
return
}
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
// API 응답을 ExistingTechSalesAttachment 형식으로 변환
- const attachments: ExistingTechSalesAttachment[] = result.data.map(att => ({
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
id: att.id,
techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
fileName: att.fileName,
@@ -265,12 +277,13 @@ export function RFQListTable({
filePath: att.filePath,
fileSize: att.fileSize || undefined,
fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
description: att.description || undefined,
createdBy: att.createdBy,
createdAt: att.createdAt,
}))
+ setAttachmentType(validAttachmentType)
setAttachmentsDefault(attachments)
setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
setAttachmentsOpen(true)
@@ -561,6 +574,7 @@ export function RFQListTable({
onOpenChange={setAttachmentsOpen}
defaultAttachments={attachmentsDefault}
rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
onAttachmentsUpdated={handleAttachmentsUpdated}
/>
diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
new file mode 100644
index 00000000..21c61773
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download, FileText, File, ImageIcon, AlertCircle } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { formatDate } from "@/lib/utils"
+import prettyBytes from "pretty-bytes"
+
+// 견적서 첨부파일 타입 정의
+export interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number
+ vendorId: number
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 견적서 정보 타입
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesQuotationAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: QuotationAttachment[]
+ isLoading?: boolean
+}
+
+export function TechSalesQuotationAttachmentsSheet({
+ quotation,
+ attachments,
+ isLoading = false,
+ ...props
+}: TechSalesQuotationAttachmentsSheetProps) {
+
+ // 파일 아이콘 선택 함수
+ const getFileIcon = (fileName: string) => {
+ const ext = fileName.split('.').pop()?.toLowerCase();
+ if (!ext) return <File className="h-5 w-5 text-gray-500" />;
+
+ // 이미지 파일
+ if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(ext)) {
+ return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ }
+ // PDF 파일
+ if (ext === 'pdf') {
+ return <FileText className="h-5 w-5 text-red-500" />;
+ }
+ // Excel 파일
+ if (['xlsx', 'xls', 'csv'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-green-500" />;
+ }
+ // Word 파일
+ if (['docx', 'doc'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ }
+ // 기본 파일
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 파일 다운로드 처리
+ const handleDownload = (attachment: QuotationAttachment) => {
+ const link = document.createElement('a');
+ link.href = attachment.filePath;
+ link.download = attachment.originalFileName || attachment.fileName;
+ link.target = '_blank';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ // 리비전별로 첨부파일 그룹핑
+ const groupedAttachments = React.useMemo(() => {
+ const groups = new Map<number, QuotationAttachment[]>();
+
+ attachments.forEach(attachment => {
+ const revisionId = attachment.revisionId;
+ if (!groups.has(revisionId)) {
+ groups.set(revisionId, []);
+ }
+ groups.get(revisionId)!.push(attachment);
+ });
+
+ // 리비전 ID 기준 내림차순 정렬 (최신 버전이 위에)
+ return Array.from(groups.entries())
+ .sort(([a], [b]) => b - a)
+ .map(([revisionId, files]) => ({
+ revisionId,
+ files: files.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ }));
+ }, [attachments]);
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>견적서 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ <div>견적서: {quotation?.quotationCode || "N/A"}</div>
+ {quotation?.vendorName && (
+ <div>벤더: {quotation.vendorName}</div>
+ )}
+ {quotation?.rfqCode && (
+ <div>RFQ: {quotation.rfqCode}</div>
+ )}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-auto">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
+ <p className="text-sm text-muted-foreground">첨부파일 로딩 중...</p>
+ </div>
+ </div>
+ ) : attachments.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-muted-foreground mb-2">첨부파일이 없습니다</p>
+ <p className="text-sm text-muted-foreground">
+ 이 견적서에는 첨부된 파일이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold text-sm">
+ 첨부파일 ({attachments.length}개)
+ </h6>
+ </div>
+
+ {groupedAttachments.map((group, groupIndex) => (
+ <div key={group.revisionId} className="space-y-3">
+ {/* 리비전 헤더 */}
+ <div className="flex items-center gap-2">
+ <Badge variant={group.revisionId === 0 ? "secondary" : "outline"} className="text-xs">
+ {group.revisionId === 0 ? "초기 버전" : `버전 ${group.revisionId}`}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ ({group.files.length}개 파일)
+ </span>
+ </div>
+
+ {/* 해당 리비전의 첨부파일들 */}
+ {group.files.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-start gap-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors ml-4"
+ >
+ <div className="mt-1">
+ {getFileIcon(attachment.fileName)}
+ </div>
+
+ <div className="flex-1 min-w-0">
+ <div className="flex items-start justify-between gap-2">
+ <div className="min-w-0 flex-1">
+ <p className="text-sm font-medium break-words leading-tight">
+ {attachment.originalFileName || attachment.fileName}
+ </p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {prettyBytes(attachment.fileSize)}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.isVendorUpload ? "벤더 업로드" : "시스템"}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatDate(attachment.createdAt)}
+ </p>
+ {attachment.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ {/* 다운로드 버튼 */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleDownload(attachment)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+
+ {/* 그룹 간 구분선 (마지막 그룹 제외) */}
+ {groupIndex < groupedAttachments.length - 1 && (
+ <Separator className="my-4" />
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
index ecdf6d81..a7b487e1 100644
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -27,7 +27,6 @@ import {
import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Dropzone,
@@ -63,7 +62,7 @@ export interface ExistingTechSalesAttachment {
filePath: string
fileSize?: number
fileType?: string
- attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
description?: string
createdBy: number
createdAt: Date
@@ -72,7 +71,7 @@ export interface ExistingTechSalesAttachment {
/** 새로 업로드할 파일 */
const newUploadSchema = z.object({
fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).default("RFQ_COMMON"),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
description: z.string().optional(),
})
@@ -85,7 +84,7 @@ const existingAttachSchema = z.object({
filePath: z.string(),
fileSize: z.number().optional(),
fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
description: z.string().optional(),
createdBy: z.number(),
createdAt: z.custom<Date>(),
@@ -112,27 +111,54 @@ interface TechSalesRfqAttachmentsSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
defaultAttachments?: ExistingTechSalesAttachment[]
rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
/** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
- /** 강제 읽기 전용 모드 (파트너/벤더용) */
- readOnly?: boolean
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
}
export function TechSalesRfqAttachmentsSheet({
defaultAttachments = [],
- onAttachmentsUpdated,
+ // onAttachmentsUpdated,
rfq,
- readOnly = false,
+ attachmentType = "RFQ_COMMON",
...props
}: TechSalesRfqAttachmentsSheetProps) {
const [isPending, setIsPending] = React.useState(false)
- // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- const isEditable = React.useMemo(() => {
- if (!rfq || readOnly) return false
- // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능
- return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)
- }, [rfq, readOnly])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: true
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: true
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: true
+ }
+ }
+ }, [attachmentType, rfq?.status])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
const form = useForm<AttachmentsFormValues>({
resolver: zodResolver(attachmentsFormSchema),
@@ -236,7 +262,7 @@ export function TechSalesRfqAttachmentsSheet({
.filter(upload => upload.fileObj)
.map(upload => ({
file: upload.fileObj as File,
- attachmentType: upload.attachmentType,
+ attachmentType: attachmentType,
description: upload.description,
}))
@@ -268,50 +294,50 @@ export function TechSalesRfqAttachmentsSheet({
toast.success(successMessage)
- // 즉시 첨부파일 목록 새로고침
- const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- if (refreshResult.error) {
- console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- } else {
- // 새로운 첨부파일 목록으로 폼 업데이트
- const refreshedAttachments = refreshResult.data.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfq.id,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize,
- fileType: att.fileType,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
- description: att.description,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- form.reset({
- techSalesRfqId: rfq.id,
- existing: refreshedAttachments.map(att => ({
- ...att,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- description: att.description || undefined,
- })),
- newUploads: [],
- })
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
- // 즉시 UI 업데이트를 위한 추가 피드백
- if (uploadedCount > 0) {
- toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- }
- }
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
- // 콜백으로 상위 컴포넌트에 변경사항 알림
- const newAttachmentCount = refreshResult.error ?
- (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- refreshResult.data.length
- onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
} catch (error) {
console.error("첨부파일 저장 오류:", error)
@@ -325,10 +351,11 @@ export function TechSalesRfqAttachmentsSheet({
<Sheet {...props}>
<SheetContent className="flex flex-col gap-6 sm:max-w-md">
<SheetHeader className="text-left">
- <SheetTitle>첨부파일 관리</SheetTitle>
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
<SheetDescription>
- RFQ: {rfq?.rfqCode || "N/A"}
- {!isEditable && (
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
<div className="mt-2 flex items-center gap-2 text-amber-600">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
@@ -345,7 +372,7 @@ export function TechSalesRfqAttachmentsSheet({
기존 첨부파일 ({existingFields.length}개)
</h6>
{existingFields.map((field, index) => {
- const typeLabel = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별"
+ const typeLabel = attachmentConfig.fileTypeLabel
const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
const dateText = field.createdAt ? formatDate(field.createdAt) : ""
@@ -384,7 +411,7 @@ export function TechSalesRfqAttachmentsSheet({
</a>
)}
{/* Remove button - 편집 가능할 때만 표시 */}
- {isEditable && (
+ {attachmentConfig.canEdit && (
<Button
type="button"
variant="ghost"
@@ -402,7 +429,7 @@ export function TechSalesRfqAttachmentsSheet({
</div>
{/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {isEditable ? (
+ {attachmentConfig.canEdit ? (
<>
<Dropzone
maxSize={MAX_FILE_SIZE}
@@ -467,30 +494,6 @@ export function TechSalesRfqAttachmentsSheet({
</FileListAction>
</FileListHeader>
- {/* 파일별 설정 */}
- <div className="px-4 pb-3 space-y-3">
- <FormField
- control={form.control}
- name={`newUploads.${idx}.attachmentType`}
- render={({ field: formField }) => (
- <FormItem>
- <FormLabel className="text-xs">파일 타입</FormLabel>
- <Select onValueChange={formField.onChange} defaultValue={formField.value}>
- <FormControl>
- <SelectTrigger className="h-8">
- <SelectValue placeholder="파일 타입 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="RFQ_COMMON">공통 파일</SelectItem>
- {/* <SelectItem value="VENDOR_SPECIFIC">벤더별 파일</SelectItem> */}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
</FileListItem>
)
})}
@@ -510,10 +513,10 @@ export function TechSalesRfqAttachmentsSheet({
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
<Button type="button" variant="outline">
- {isEditable ? "취소" : "닫기"}
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
</Button>
</SheetClose>
- {isEditable && (
+ {attachmentConfig.canEdit && (
<Button
type="submit"
disabled={