diff options
| author | joonhoekim <26rote@gmail.com> | 2025-06-24 01:51:59 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-06-24 01:51:59 +0000 |
| commit | 6824e097d768f724cf439b410ccfb1ab9685ac98 (patch) | |
| tree | 1f297313637878e7a4ad6c89b84d5a2c3e9eb650 /lib/techsales-rfq/table | |
| parent | f4825dd3853188de4688fb4a56c0f4e847da314b (diff) | |
| parent | 4e63d8427d26d0d1b366ddc53650e15f3481fc75 (diff) | |
(merge) 대표님/최겸 작업사항 머지
Diffstat (limited to 'lib/techsales-rfq/table')
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={ |
