summaryrefslogtreecommitdiff
path: root/lib/b-rfq/final
diff options
context:
space:
mode:
Diffstat (limited to 'lib/b-rfq/final')
-rw-r--r--lib/b-rfq/final/final-rfq-detail-columns.tsx589
-rw-r--r--lib/b-rfq/final/final-rfq-detail-table.tsx297
-rw-r--r--lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx201
-rw-r--r--lib/b-rfq/final/update-final-rfq-sheet.tsx70
4 files changed, 1157 insertions, 0 deletions
diff --git a/lib/b-rfq/final/final-rfq-detail-columns.tsx b/lib/b-rfq/final/final-rfq-detail-columns.tsx
new file mode 100644
index 00000000..832923eb
--- /dev/null
+++ b/lib/b-rfq/final/final-rfq-detail-columns.tsx
@@ -0,0 +1,589 @@
+// final-rfq-detail-columns.tsx
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { type Row } from "@tanstack/react-table"
+import {
+ Ellipsis, Building, Eye, Edit,
+ MessageSquare, Settings, CheckCircle2, XCircle, DollarSign, Calendar
+} from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem,
+ DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { FinalRfqDetailView } from "@/db/schema"
+
+// RowAction 타입 정의
+export interface DataTableRowAction<TData> {
+ row: Row<TData>
+ type: "update"
+}
+
+interface GetFinalRfqDetailColumnsProps {
+ onSelectDetail?: (detail: any) => void
+ setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<FinalRfqDetailView> | null>>
+}
+
+export function getFinalRfqDetailColumns({
+ onSelectDetail,
+ setRowAction
+}: GetFinalRfqDetailColumnsProps = {}): ColumnDef<FinalRfqDetailView>[] {
+
+ return [
+ /** ───────────── 체크박스 ───────────── */
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ /** 1. RFQ Status */
+ {
+ accessorKey: "finalRfqStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 RFQ Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("finalRfqStatus") as string
+ const getFinalStatusColor = (status: string) => {
+ switch (status) {
+ case "DRAFT": return "outline"
+ case "Final RFQ Sent": return "default"
+ case "Quotation Received": return "success"
+ case "Vendor Selected": return "default"
+ default: return "secondary"
+ }
+ }
+ return (
+ <Badge variant={getFinalStatusColor(status) as any}>
+ {status}
+ </Badge>
+ )
+ },
+ size: 120
+ },
+
+ /** 2. RFQ No. */
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-medium">
+ {row.getValue("rfqCode") as string}
+ </div>
+ ),
+ size: 120,
+ },
+
+ /** 3. Rev. */
+ {
+ accessorKey: "returnRevision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Rev." />
+ ),
+ cell: ({ row }) => {
+ const revision = row.getValue("returnRevision") as number
+ return revision > 0 ? (
+ <Badge variant="outline">
+ Rev. {revision}
+ </Badge>
+ ) : (
+ <Badge variant="outline">
+ Rev. 0
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 4. Vendor Code */
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-medium">
+ {row.original.vendorCode}
+ </div>
+ ),
+ size: 100,
+ },
+
+ /** 5. Vendor Name */
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-medium">
+ {row.original.vendorName}
+ </div>
+ ),
+ size: 150,
+ },
+
+ /** 6. 업체분류 */
+ {
+ id: "vendorClassification",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체분류" />
+ ),
+ cell: ({ row }) => {
+ const vendorCode = row.original.vendorCode as string
+ return vendorCode ? (
+ <Badge variant="success" className="text-xs">
+ 정규업체
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="text-xs">
+ 잠재업체
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+
+ /** 7. CP 현황 */
+ {
+ accessorKey: "cpRequestYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CP 현황" />
+ ),
+ cell: ({ row }) => {
+ const cpRequest = row.getValue("cpRequestYn") as boolean
+ return cpRequest ? (
+ <Badge variant="success" className="text-xs">
+ 신청
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ 미신청
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 8. GTC현황 */
+ {
+ id: "gtcStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="GTC현황" />
+ ),
+ cell: ({ row }) => {
+ const gtc = row.original.gtc as string
+ const gtcValidDate = row.original.gtcValidDate as string
+ const prjectGtcYn = row.original.prjectGtcYn as boolean
+
+ if (prjectGtcYn || gtc) {
+ return (
+ <div className="space-y-1">
+ <Badge variant="success" className="text-xs">
+ 보유
+ </Badge>
+ {gtcValidDate && (
+ <div className="text-xs text-muted-foreground">
+ {gtcValidDate}
+ </div>
+ )}
+ </div>
+ )
+ }
+ return (
+ <Badge variant="outline" className="text-xs">
+ 미보유
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+
+ /** 9. TBE 결과 (스키마에 없어서 placeholder) */
+ {
+ id: "tbeResult",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ // TODO: TBE 결과 로직 구현 필요
+ return (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 10. 최종 선정 */
+ {
+ id: "finalSelection",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 선정" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.finalRfqStatus as string
+ return status === "Vendor Selected" ? (
+ <Badge variant="success" className="text-xs">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 선정
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 11. Currency */
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Currency" />
+ ),
+ cell: ({ row }) => {
+ const currency = row.getValue("currency") as string
+ return currency ? (
+ <Badge variant="outline" className="text-xs">
+ {/* <DollarSign className="h-3 w-3 mr-1" /> */}
+ {currency}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 12. Terms of Payment */
+ {
+ accessorKey: "paymentTermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Terms of Payment" />
+ ),
+ cell: ({ row }) => {
+ const paymentTermsCode = row.getValue("paymentTermsCode") as string
+ return paymentTermsCode ? (
+ <Badge variant="secondary" className="text-xs">
+ {paymentTermsCode}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 13. Payment Desc. */
+ {
+ accessorKey: "paymentTermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Payment Desc." />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("paymentTermsDescription") as string
+ return description ? (
+ <div className="text-xs max-w-[150px] truncate" title={description}>
+ {description}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 150,
+ },
+
+ /** 14. TAX */
+ {
+ accessorKey: "taxCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TAX" />
+ ),
+ cell: ({ row }) => {
+ const taxCode = row.getValue("taxCode") as string
+ return taxCode ? (
+ <Badge variant="outline" className="text-xs">
+ {taxCode}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 15. Delivery Date* */
+ {
+ accessorKey: "deliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Delivery Date*" />
+ ),
+ cell: ({ row }) => {
+ const deliveryDate = row.getValue("deliveryDate") as Date
+ return deliveryDate ? (
+ <div className="text-sm">
+ {formatDate(deliveryDate)}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 16. Country */
+ {
+ accessorKey: "vendorCountry",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Country" />
+ ),
+ cell: ({ row }) => {
+ const country = row.getValue("vendorCountry") as string
+ const countryDisplay = country === "KR" ? "D" : "F"
+ return (
+ <Badge variant="outline" className="text-xs">
+ {countryDisplay}
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 17. Place of Shipping */
+ {
+ accessorKey: "placeOfShipping",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Place of Shipping" />
+ ),
+ cell: ({ row }) => {
+ const placeOfShipping = row.getValue("placeOfShipping") as string
+ return placeOfShipping ? (
+ <div className="text-xs max-w-[120px] truncate" title={placeOfShipping}>
+ {placeOfShipping}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 18. Place of Destination */
+ {
+ accessorKey: "placeOfDestination",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Place of Destination" />
+ ),
+ cell: ({ row }) => {
+ const placeOfDestination = row.getValue("placeOfDestination") as string
+ return placeOfDestination ? (
+ <div className="text-xs max-w-[120px] truncate" title={placeOfDestination}>
+ {placeOfDestination}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 19. 초도 여부* */
+ {
+ accessorKey: "firsttimeYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="초도 여부*" />
+ ),
+ cell: ({ row }) => {
+ const firsttime = row.getValue("firsttimeYn") as boolean
+ return firsttime ? (
+ <Badge variant="success" className="text-xs">
+ 초도
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ 재구매
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 20. 연동제 적용* */
+ {
+ accessorKey: "materialPriceRelatedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="연동제 적용*" />
+ ),
+ cell: ({ row }) => {
+ const materialPrice = row.getValue("materialPriceRelatedYn") as boolean
+ return materialPrice ? (
+ <Badge variant="success" className="text-xs">
+ 적용
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ 미적용
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+
+ /** 21. Business Size */
+ {
+ id: "businessSizeDisplay",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Business Size" />
+ ),
+ cell: ({ row }) => {
+ const businessSize = row.original.vendorBusinessSize as string
+ return businessSize ? (
+ <Badge variant="outline" className="text-xs">
+ {businessSize}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
+ /** 22. 최종 Update일 */
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 Update일" />
+ ),
+ cell: ({ row }) => {
+ const updated = row.getValue("updatedAt") as Date
+ return updated ? (
+ <div className="text-sm">
+ {formatDate(updated)}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 23. 최종 Update담당자 (스키마에 없어서 placeholder) */
+ {
+ id: "updatedByUser",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 Update담당자" />
+ ),
+ cell: ({ row }) => {
+ // TODO: updatedBy 사용자 정보 조인 필요
+ return (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 24. Vendor 설명 */
+ {
+ accessorKey: "vendorRemark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor 설명" />
+ ),
+ cell: ({ row }) => {
+ const vendorRemark = row.getValue("vendorRemark") as string
+ return vendorRemark ? (
+ <div className="text-xs max-w-[150px] truncate" title={vendorRemark}>
+ {vendorRemark}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 150,
+ },
+
+ /** 25. 비고 */
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => {
+ const remark = row.getValue("remark") as string
+ return remark ? (
+ <div className="text-xs max-w-[150px] truncate" title={remark}>
+ {remark}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 150,
+ },
+
+ /** ───────────── 액션 ───────────── */
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem>
+ <MessageSquare className="mr-2 h-4 w-4" />
+ 벤더 견적 보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ {setRowAction && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-table.tsx b/lib/b-rfq/final/final-rfq-detail-table.tsx
new file mode 100644
index 00000000..8ae42e7e
--- /dev/null
+++ b/lib/b-rfq/final/final-rfq-detail-table.tsx
@@ -0,0 +1,297 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getFinalRfqDetail } from "../service" // 앞서 만든 서버 액션
+import {
+ getFinalRfqDetailColumns,
+ type DataTableRowAction
+} from "./final-rfq-detail-columns"
+import { FinalRfqDetailTableToolbarActions } from "./final-rfq-detail-toolbar-actions"
+import { UpdateFinalRfqSheet } from "./update-final-rfq-sheet"
+import { FinalRfqDetailView } from "@/db/schema"
+
+interface FinalRfqDetailTableProps {
+ promises: Promise<Awaited<ReturnType<typeof getFinalRfqDetail>>>
+ rfqId?: number
+}
+
+export function FinalRfqDetailTable({ promises, rfqId }: FinalRfqDetailTableProps) {
+ const { data, pageCount } = React.use(promises)
+
+ // 선택된 상세 정보
+ const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
+
+ // Row action 상태 (update만)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<FinalRfqDetailView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getFinalRfqDetailColumns({
+ onSelectDetail: setSelectedDetail,
+ setRowAction: setRowAction
+ }),
+ []
+ )
+
+ /**
+ * 필터 필드 정의
+ */
+ const filterFields: DataTableFilterField<any>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ placeholder: "RFQ 코드로 검색...",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ placeholder: "벤더명으로 검색...",
+ },
+ {
+ id: "rfqStatus",
+ label: "RFQ 상태",
+ options: [
+ { label: "Draft", value: "DRAFT", count: 0 },
+ { label: "문서 접수", value: "Doc. Received", count: 0 },
+ { label: "담당자 배정", value: "PIC Assigned", count: 0 },
+ { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
+ { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
+ { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
+ { label: "TBE 시작", value: "TBE started", count: 0 },
+ { label: "TBE 완료", value: "TBE finished", count: 0 },
+ { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
+ { label: "견적 접수", value: "Quotation Received", count: 0 },
+ { label: "벤더 선정", value: "Vendor Selected", count: 0 },
+ ],
+ },
+ {
+ id: "finalRfqStatus",
+ label: "최종 RFQ 상태",
+ options: [
+ { label: "초안", value: "DRAFT", count: 0 },
+ { label: "발송", value: "Final RFQ Sent", count: 0 },
+ { label: "견적 접수", value: "Quotation Received", count: 0 },
+ { label: "벤더 선정", value: "Vendor Selected", count: 0 },
+ ],
+ },
+ {
+ id: "vendorCountry",
+ label: "벤더 국가",
+ options: [
+ { label: "한국", value: "KR", count: 0 },
+ { label: "중국", value: "CN", count: 0 },
+ { label: "일본", value: "JP", count: 0 },
+ { label: "미국", value: "US", count: 0 },
+ { label: "독일", value: "DE", count: 0 },
+ ],
+ },
+ {
+ id: "currency",
+ label: "통화",
+ options: [
+ { label: "USD", value: "USD", count: 0 },
+ { label: "EUR", value: "EUR", count: 0 },
+ { label: "KRW", value: "KRW", count: 0 },
+ { label: "JPY", value: "JPY", count: 0 },
+ { label: "CNY", value: "CNY", count: 0 },
+ ],
+ },
+ ]
+
+ /**
+ * 고급 필터 필드
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ type: "text",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "vendorCountry",
+ label: "벤더 국가",
+ type: "multi-select",
+ options: [
+ { label: "한국", value: "KR" },
+ { label: "중국", value: "CN" },
+ { label: "일본", value: "JP" },
+ { label: "미국", value: "US" },
+ { label: "독일", value: "DE" },
+ ],
+ },
+ {
+ id: "rfqStatus",
+ label: "RFQ 상태",
+ type: "multi-select",
+ options: [
+ { label: "Draft", value: "DRAFT" },
+ { label: "문서 접수", value: "Doc. Received" },
+ { label: "담당자 배정", value: "PIC Assigned" },
+ { label: "문서 확정", value: "Doc. Confirmed" },
+ { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
+ { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
+ { label: "TBE 시작", value: "TBE started" },
+ { label: "TBE 완료", value: "TBE finished" },
+ { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
+ { label: "견적 접수", value: "Quotation Received" },
+ { label: "벤더 선정", value: "Vendor Selected" },
+ ],
+ },
+ {
+ id: "finalRfqStatus",
+ label: "최종 RFQ 상태",
+ type: "multi-select",
+ options: [
+ { label: "초안", value: "DRAFT" },
+ { label: "발송", value: "Final RFQ Sent" },
+ { label: "견적 접수", value: "Quotation Received" },
+ { label: "벤더 선정", value: "Vendor Selected" },
+ ],
+ },
+ {
+ id: "vendorBusinessSize",
+ label: "벤더 규모",
+ type: "multi-select",
+ options: [
+ { label: "대기업", value: "LARGE" },
+ { label: "중기업", value: "MEDIUM" },
+ { label: "소기업", value: "SMALL" },
+ { label: "스타트업", value: "STARTUP" },
+ ],
+ },
+ {
+ id: "incotermsCode",
+ label: "Incoterms",
+ type: "text",
+ },
+ {
+ id: "paymentTermsCode",
+ label: "Payment Terms",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "multi-select",
+ options: [
+ { label: "USD", value: "USD" },
+ { label: "EUR", value: "EUR" },
+ { label: "KRW", value: "KRW" },
+ { label: "JPY", value: "JPY" },
+ { label: "CNY", value: "CNY" },
+ ],
+ },
+ {
+ id: "dueDate",
+ label: "마감일",
+ type: "date",
+ },
+ {
+ id: "validDate",
+ label: "유효일",
+ type: "date",
+ },
+ {
+ id: "deliveryDate",
+ label: "납기일",
+ type: "date",
+ },
+ {
+ id: "shortList",
+ label: "Short List",
+ type: "boolean",
+ },
+ {
+ id: "returnYn",
+ label: "Return 여부",
+ type: "boolean",
+ },
+ {
+ id: "cpRequestYn",
+ label: "CP Request 여부",
+ type: "boolean",
+ },
+ {
+ id: "prjectGtcYn",
+ label: "Project GTC 여부",
+ type: "boolean",
+ },
+ {
+ id: "firsttimeYn",
+ label: "First Time 여부",
+ type: "boolean",
+ },
+ {
+ id: "materialPriceRelatedYn",
+ label: "Material Price Related 여부",
+ type: "boolean",
+ },
+ {
+ id: "classification",
+ label: "분류",
+ type: "text",
+ },
+ {
+ id: "sparepart",
+ label: "예비부품",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "등록일",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => originalRow.finalRfqId ? originalRow.finalRfqId.toString() : "1",
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <div className="space-y-6">
+ {/* 메인 테이블 */}
+ <div className="h-full w-full">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <FinalRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+
+ {/* Update Sheet */}
+ <UpdateFinalRfqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ finalRfq={rowAction?.type === "update" ? rowAction.row.original : null}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
new file mode 100644
index 00000000..d8be4f7b
--- /dev/null
+++ b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Mail,
+ CheckCircle2,
+ Loader,
+ Award,
+ RefreshCw
+} from "lucide-react"
+import { FinalRfqDetailView } from "@/db/schema"
+
+interface FinalRfqDetailTableToolbarActionsProps {
+ table: Table<FinalRfqDetailView>
+ rfqId?: number
+ onRefresh?: () => void // 데이터 새로고침 콜백
+}
+
+export function FinalRfqDetailTableToolbarActions({
+ table,
+ rfqId,
+ onRefresh
+}: FinalRfqDetailTableToolbarActionsProps) {
+ const router = useRouter()
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedDetails = selectedRows.map((row) => row.original)
+ const selectedCount = selectedRows.length
+
+ // 상태 관리
+ const [isEmailSending, setIsEmailSending] = React.useState(false)
+ const [isSelecting, setIsSelecting] = React.useState(false)
+
+ // RFQ 발송 핸들러 (로직 없음)
+ const handleBulkRfqSend = async () => {
+ if (selectedCount === 0) {
+ toast.error("발송할 RFQ를 선택해주세요.")
+ return
+ }
+
+ setIsEmailSending(true)
+
+ try {
+ // TODO: 실제 RFQ 발송 로직 구현
+ await new Promise(resolve => setTimeout(resolve, 2000)) // 임시 딜레이
+
+ toast.success(`${selectedCount}개의 최종 RFQ가 발송되었습니다.`)
+
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+
+ } catch (error) {
+ console.error("RFQ sending error:", error)
+ toast.error("최종 RFQ 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsEmailSending(false)
+ }
+ }
+
+ // 최종 선정 핸들러 (로직 없음)
+ const handleFinalSelection = async () => {
+ if (selectedCount === 0) {
+ toast.error("최종 선정할 벤더를 선택해주세요.")
+ return
+ }
+
+ if (selectedCount > 1) {
+ toast.error("최종 선정은 1개의 벤더만 가능합니다.")
+ return
+ }
+
+ setIsSelecting(true)
+
+ try {
+ // TODO: 실제 최종 선정 로직 구현
+ await new Promise(resolve => setTimeout(resolve, 1500)) // 임시 딜레이
+
+ const selectedVendor = selectedDetails[0]
+ toast.success(`${selectedVendor.vendorName}이(가) 최종 선정되었습니다.`)
+
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+
+ // 계약서 페이지로 이동 (필요시)
+ if (rfqId) {
+ setTimeout(() => {
+ toast.info("계약서 작성 페이지로 이동합니다.")
+ // router.push(`/evcp/contracts/${rfqId}`)
+ }, 1500)
+ }
+
+ } catch (error) {
+ console.error("Final selection error:", error)
+ toast.error("최종 선정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSelecting(false)
+ }
+ }
+
+ // 발송 가능한 RFQ 필터링 (DRAFT 상태)
+ const sendableRfqs = selectedDetails.filter(
+ detail => detail.finalRfqStatus === "DRAFT"
+ )
+ const sendableCount = sendableRfqs.length
+
+ // 선정 가능한 벤더 필터링 (견적 접수 상태)
+ const selectableVendors = selectedDetails.filter(
+ detail => detail.finalRfqStatus === "Quotation Received"
+ )
+ const selectableCount = selectableVendors.length
+
+ // 전체 벤더 중 견적 접수 완료된 벤더 수
+ const allVendors = table.getRowModel().rows.map(row => row.original)
+ const quotationReceivedCount = allVendors.filter(
+ vendor => vendor.finalRfqStatus === "Quotation Received"
+ ).length
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
+ {selectedCount > 0 && (
+ <>
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkRfqSend}
+ className="h-8"
+ disabled={isEmailSending || sendableCount === 0}
+ title={sendableCount === 0 ? "발송 가능한 RFQ가 없습니다 (DRAFT 상태만 가능)" : `${sendableCount}개의 최종 RFQ 발송`}
+ >
+ {isEmailSending ? (
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Mail className="mr-2 h-4 w-4" />
+ )}
+ 최종 RFQ 발송 ({sendableCount}/{selectedCount})
+ </Button>
+
+ {/* 최종 선정 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleFinalSelection}
+ className="h-8"
+ disabled={isSelecting || selectedCount !== 1 || selectableCount === 0}
+ title={
+ selectedCount !== 1
+ ? "최종 선정은 1개의 벤더만 선택해주세요"
+ : selectableCount === 0
+ ? "견적 접수가 완료된 벤더만 선정 가능합니다"
+ : "선택된 벤더를 최종 선정"
+ }
+ >
+ {isSelecting ? (
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Award className="mr-2 h-4 w-4" />
+ )}
+ 최종 선정
+ </Button>
+ </>
+ )}
+
+ {/* 정보 표시 (선택이 없을 때) */}
+ {selectedCount === 0 && quotationReceivedCount > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 견적 접수 완료: {quotationReceivedCount}개 벤더
+ </div>
+ )}
+
+ {/* 새로고침 버튼 */}
+ {onRefresh && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={onRefresh}
+ className="h-8"
+ title="데이터 새로고침"
+ >
+ <RefreshCw className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/final/update-final-rfq-sheet.tsx b/lib/b-rfq/final/update-final-rfq-sheet.tsx
new file mode 100644
index 00000000..65e23a92
--- /dev/null
+++ b/lib/b-rfq/final/update-final-rfq-sheet.tsx
@@ -0,0 +1,70 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { FinalRfqDetailView } from "@/db/schema"
+
+interface UpdateFinalRfqSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ finalRfq: FinalRfqDetailView | null
+}
+
+export function UpdateFinalRfqSheet({
+ open,
+ onOpenChange,
+ finalRfq
+}: UpdateFinalRfqSheetProps) {
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:max-w-md">
+ <SheetHeader>
+ <SheetTitle>최종 RFQ 수정</SheetTitle>
+ <SheetDescription>
+ 최종 RFQ 정보를 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="py-6">
+ {finalRfq && (
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-medium">RFQ 정보</h4>
+ <p className="text-sm text-muted-foreground">
+ RFQ Code: {finalRfq.rfqCode}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 벤더: {finalRfq.vendorName}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 상태: {finalRfq.finalRfqStatus}
+ </p>
+ </div>
+
+ {/* TODO: 실제 업데이트 폼 구현 */}
+ <div className="text-center text-muted-foreground">
+ 업데이트 폼이 여기에 구현됩니다.
+ </div>
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={() => onOpenChange(false)}>
+ 저장
+ </Button>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file