summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-12 11:36:25 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-12 11:36:25 +0000
commita6b9cdaf9ea5ed548292632f821e36453f377a83 (patch)
tree1c07b92b4173bfe3a12eedba7188fba8dc6f94cb /lib/procurement-rfqs/table
parentdf91418cd28e98ce05845e476e51aa810202bf33 (diff)
(대표님) procurement-rfq 작업업
Diffstat (limited to 'lib/procurement-rfqs/table')
-rw-r--r--lib/procurement-rfqs/table/pr-item-dialog.tsx258
-rw-r--r--lib/procurement-rfqs/table/rfq-table-column.tsx373
-rw-r--r--lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx279
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx209
4 files changed, 1119 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/table/pr-item-dialog.tsx b/lib/procurement-rfqs/table/pr-item-dialog.tsx
new file mode 100644
index 00000000..4523295d
--- /dev/null
+++ b/lib/procurement-rfqs/table/pr-item-dialog.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import * as React from "react";
+import { useState, useEffect } from "react";
+import { formatDate } from "@/lib/utils";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Badge } from "@/components/ui/badge";
+import { ProcurementRfqsView } from "@/db/schema";
+import { fetchPrItemsByRfqId } from "../services";
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Input } from "@/components/ui/input";
+import { Search } from "lucide-react";
+
+// PR 항목 타입 정의
+interface PrItemView {
+ id: number;
+ procurementRfqsId: number;
+ rfqItem: string | null;
+ prItem: string | null;
+ prNo: string | null;
+ itemId: number | null;
+ materialCode: string | null;
+ materialCategory: string | null;
+ acc: string | null;
+ materialDescription: string | null;
+ size: string | null;
+ deliveryDate: Date | null;
+ quantity: number | null;
+ uom: string | null;
+ grossWeight: number | null;
+ gwUom: string | null;
+ specNo: string | null;
+ specUrl: string | null;
+ trackingNo: string | null;
+ majorYn: boolean | null;
+ projectDef: string | null;
+ projectSc: string | null;
+ projectKl: string | null;
+ projectLc: string | null;
+ projectDl: string | null;
+ remark: string | null;
+ rfqCode: string | null;
+ itemCode: string | null;
+ itemName: string | null;
+}
+
+interface PrDetailsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: ProcurementRfqsView | null;
+}
+
+export function PrDetailsDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: PrDetailsDialogProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [prItems, setPrItems] = useState<PrItemView[]>([]);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // 검색어로 필터링된 항목들
+ const filteredItems = React.useMemo(() => {
+ if (!searchTerm.trim()) return prItems;
+
+ const term = searchTerm.toLowerCase();
+ return prItems.filter(item =>
+ (item.materialDescription || "").toLowerCase().includes(term) ||
+ (item.materialCode || "").toLowerCase().includes(term) ||
+ (item.prNo || "").toLowerCase().includes(term) ||
+ (item.prItem || "").toLowerCase().includes(term) ||
+ (item.rfqItem || "").toLowerCase().includes(term)
+ );
+ }, [prItems, searchTerm]);
+
+ // 선택된 RFQ가 변경되면 PR 항목 데이터를 가져옴
+ useEffect(() => {
+ async function loadPrItems() {
+ if (!selectedRfq || !open) {
+ setPrItems([]);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const result = await fetchPrItemsByRfqId(selectedRfq.id);
+ const mappedItems: PrItemView[] = result.data.map(item => ({
+ ...item,
+ // procurementRfqsId가 null이면 selectedRfq.id 사용
+ procurementRfqsId: item.procurementRfqsId ?? selectedRfq.id,
+ // 기타 필요한 필드에 대한 기본값 처리
+ rfqItem: item.rfqItem ?? null,
+ prItem: item.prItem ?? null,
+ prNo: item.prNo ?? null,
+ // 다른 필드도 필요에 따라 추가
+ }));
+
+ setPrItems(mappedItems);
+ } catch (error) {
+ console.error("PR 항목 로드 오류:", error);
+ setPrItems([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ if (open) {
+ loadPrItems();
+ setSearchTerm("");
+ }
+ }, [selectedRfq, open]);
+
+ // 선택된 RFQ가 없는 경우
+ if (!selectedRfq) {
+ return null;
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-screen-sm max-h-[90vh] flex flex-col" style={{ maxWidth: "70vw" }}>
+ <DialogHeader>
+ <DialogTitle className="text-xl">
+ PR 상세 정보 - {selectedRfq.rfqCode}
+ </DialogTitle>
+ <DialogDescription>
+ 프로젝트: {selectedRfq.projectName} ({selectedRfq.projectCode}) | 건수:{" "}
+ {selectedRfq.prItemsCount || 0}건
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="py-4 space-y-3">
+ <Skeleton className="h-8 w-full" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-24 w-full" />
+ </div>
+ ) : (
+ <div className="flex-1 flex flex-col">
+ {/* 검색 필드 */}
+ <div className="mb-4 relative">
+ <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <Input
+ placeholder="PR 번호, 자재 코드, 설명 등 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+</div>
+ {filteredItems.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground border rounded-md">
+ {prItems.length === 0 ? "PR 항목이 없습니다" : "검색 결과가 없습니다"}
+ </div>
+ ) : (
+ <div className="rounded-md border flex-1 overflow-hidden">
+ <div className="overflow-x-auto" style={{ width: "100%" }}>
+ <Table style={{ minWidth: "2500px" }}>
+ <TableCaption>
+ 총 {filteredItems.length}개 항목 (전체 {prItems.length}개 중)
+ </TableCaption>
+ <TableHeader className="bg-muted/50 sticky top-0">
+ <TableRow>
+ <TableHead className="w-[100px] whitespace-nowrap">RFQ Item</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">PR 번호</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">PR Item</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">자재그룹</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">자재 코드</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">자재 카테고리</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">ACC</TableHead>
+ <TableHead className="min-w-[200px] whitespace-nowrap">자재 설명</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">규격</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">납품일</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">수량</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">UOM</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">총중량</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">중량 단위</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">사양 번호</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">사양 URL</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">추적 번호</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">주요 항목</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DEF</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 SC</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 KL</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 LC</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DL</TableHead>
+ <TableHead className="w-[150px] whitespace-nowrap">비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredItems.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="whitespace-nowrap">{item.rfqItem || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.prNo || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.prItem || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.itemCode || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.materialCode || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.materialCategory || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.acc || "-"}</TableCell>
+ <TableCell>{item.materialDescription || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.size || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">
+ {item.deliveryDate ? formatDate(item.deliveryDate) : "-"}
+ </TableCell>
+ <TableCell className="whitespace-nowrap">{item.quantity || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.uom || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.grossWeight || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.gwUom || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.specNo || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.specUrl || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.trackingNo || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">
+ {item.majorYn ? (
+ <Badge variant="secondary">주요</Badge>
+ ) : (
+ "아니오"
+ )}
+ </TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectDef || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectSc || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectKl || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectLc || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectDl || "-"}</TableCell>
+ <TableCell className="text-sm">{item.remark || "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ <DialogFooter className="mt-2">
+ <Button onClick={() => onOpenChange(false)}>닫기</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table-column.tsx b/lib/procurement-rfqs/table/rfq-table-column.tsx
new file mode 100644
index 00000000..3cf06315
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table-column.tsx
@@ -0,0 +1,373 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+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 { ProcurementRfqsView } from "@/db/schema"
+import { Check, Pencil, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+import { Input } from "@/components/ui/input"
+import { updateRfqRemark } from "../services"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementRfqsView> | null>>;
+ // 상태와 상태 설정 함수를 props로 받음
+ editingCell: EditingCellState | null;
+ setEditingCell: (state: EditingCellState | null) => void;
+ updateRemark: (rfqId: number, remark: string) => Promise<void>;
+}
+
+export interface EditingCellState {
+ rowId: string | number;
+ value: string;
+}
+
+
+export function getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark,
+}: GetColumnsProps): ColumnDef<ProcurementRfqsView>[] {
+
+
+
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="status" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "status"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("projectCode")}</div>,
+ meta: {
+ excelHeader: "프로젝트"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "series",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="시리즈" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("series")}</div>,
+ meta: {
+ excelHeader: "시리즈"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 밀봉" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqSealedYn") ? "Y":"N"}</div>,
+ meta: {
+ excelHeader: "RFQ 밀봉"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ NO." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
+ meta: {
+ excelHeader: "RFQ NO."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "po_no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="대표 PR NO." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("po_no")}</div>,
+ meta: {
+ excelHeader: "대표 PR NO."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("itemCode")}</div>,
+ meta: {
+ excelHeader: "자재그룹"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "majorItemMaterialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("majorItemMaterialCode")}</div>,
+ meta: {
+ excelHeader: "자재코드"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("itemName")}</div>,
+ meta: {
+ excelHeader: "자재명"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PR 건수" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("prItemsCount")}</div>,
+ meta: {
+ excelHeader: "PR 건수"
+ },
+ enableResizing: true,
+ // size: 80,
+ },
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 전송일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "earliestQuotationSubmittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첫회신 접수일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "첫회신 접수일"
+ },
+ enableResizing: true,
+ // size: 140,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 마감일"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "sentByUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 요청자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("sentByUserName")}</div>,
+ meta: {
+ excelHeader: "RFQ 요청자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => {
+ const rowId = row.id
+ const value = row.getValue("remark") as string
+ const isEditing = editingCell && editingCell.rowId === rowId
+
+ const startEditing = () => {
+ setEditingCell({
+ rowId,
+ value: value || ""
+ })
+ }
+
+ const cancelEditing = () => {
+ setEditingCell(null)
+ }
+
+ const saveChanges = async () => {
+ if (!editingCell) return
+
+ try {
+
+ // 컴포넌트에서 전달받은 업데이트 함수 사용
+ await updateRemark(row.original.id, editingCell.value)
+ row.original.remark = editingCell.value;
+
+ // 편집 모드 종료
+ setEditingCell(null)
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error)
+ }
+ }
+
+ // 키보드 이벤트 처리
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ saveChanges()
+ } else if (e.key === "Escape") {
+ cancelEditing()
+ }
+ }
+
+ if (isEditing) {
+ return (
+ <div className="flex items-center space-x-1">
+ <Input
+ value={editingCell.value}
+ onChange={(e) => setEditingCell({
+ ...editingCell,
+ value: e.target.value
+ })}
+ onKeyDown={handleKeyDown}
+ autoFocus
+ className="h-8 w-full"
+ />
+ <div className="flex items-center">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={saveChanges}
+ className="h-7 w-7"
+ >
+ <Check className="h-4 w-4 text-green-500" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={cancelEditing}
+ className="h-7 w-7"
+ >
+ <X className="h-4 w-4 text-red-500" />
+ </Button>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div
+ className="flex items-center justify-between group"
+ onDoubleClick={startEditing} // 더블클릭 이벤트 추가
+ >
+ <div className="truncate">{value || "-"}</div>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={startEditing}
+ className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+ <Pencil className="h-3.5 w-3.5 text-muted-foreground" />
+ </Button>
+ </div>
+ )
+ },
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx
new file mode 100644
index 00000000..26725797
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { ClipboardList, Download, Send, Lock, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { ProcurementRfqsView } from "@/db/schema"
+import { PrDetailsDialog } from "./pr-item-dialog"
+import { sealRfq, sendRfq, getPORfqs, fetchExternalRfqs } from "../services"
+
+// total 필드 추가하여 타입 정의 수정
+type PORfqsReturn = Awaited<ReturnType<typeof getPORfqs>>
+
+interface RFQTableToolbarActionsProps {
+ table: Table<ProcurementRfqsView>;
+ // 타입 수정
+ localData?: PORfqsReturn;
+ setLocalData?: React.Dispatch<React.SetStateAction<PORfqsReturn>>;
+ onSuccess?: () => void;
+}
+
+export function RFQTableToolbarActions({
+ table,
+ localData,
+ setLocalData,
+ onSuccess
+}: RFQTableToolbarActionsProps) {
+ // 다이얼로그 열림/닫힘 상태 관리
+ const [dialogOpen, setDialogOpen] = React.useState(false)
+ const [isProcessing, setIsProcessing] = React.useState(false)
+
+ // 선택된 RFQ 가져오기
+ const getSelectedRfq = (): ProcurementRfqsView | null => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 1) {
+ return selectedRows[0].original
+ }
+ return null
+ }
+
+ // 선택된 RFQ
+ const selectedRfq = getSelectedRfq()
+
+ // PR 상세보기 버튼 클릭 핸들러
+ const handleViewPrDetails = () => {
+ const rfq = getSelectedRfq()
+ if (!rfq) {
+ toast.warning("RFQ를 선택해주세요")
+ return
+ }
+
+ if (!rfq.prItemsCount || rfq.prItemsCount <= 0) {
+ toast.warning("선택한 RFQ에 PR 항목이 없습니다")
+ return
+ }
+
+ setDialogOpen(true)
+ }
+
+ // RFQ 밀봉 버튼 클릭 핸들러
+ const handleSealRfq = async () => {
+ const rfq = getSelectedRfq()
+ if (!rfq) {
+ toast.warning("RFQ를 선택해주세요")
+ return
+ }
+
+ // 이미 밀봉된 RFQ인 경우
+ if (rfq.rfqSealedYn) {
+ toast.warning("이미 밀봉된 RFQ입니다")
+ return
+ }
+
+ try {
+ setIsProcessing(true)
+
+ // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
+ if (localData?.data && setLocalData) {
+ // 로컬 데이터에서 해당 행 찾기
+ const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
+ if (rowIndex >= 0) {
+ // 불변성을 유지하면서 로컬 데이터 업데이트 - 타입 안전하게 복사
+ const newData = [...localData.data] as ProcurementRfqsView[];
+ newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: "Y" };
+
+ // 전체 데이터 구조 복사하여 업데이트, total 필드가 있다면 유지
+ setLocalData({
+ ...localData,
+ data: newData ?? [],
+ pageCount: localData.pageCount,
+ total: localData.total ?? 0
+ });
+ }
+ }
+
+ const result = await sealRfq(rfq.id)
+
+ if (result.success) {
+ toast.success("RFQ가 성공적으로 밀봉되었습니다")
+ // 데이터 리프레시
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "RFQ 밀봉 중 오류가 발생했습니다")
+
+ // 서버 요청 실패 시 낙관적 업데이트 되돌리기
+ if (localData?.data && setLocalData) {
+ const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
+ if (rowIndex >= 0) {
+ const newData = [...localData.data] as ProcurementRfqsView[];
+ newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원
+ setLocalData({
+ ...localData,
+ data: newData ?? [],
+ pageCount: localData.pageCount,
+ total: localData.total ?? 0
+ });
+ }
+ }
+ }
+ } catch (error) {
+ console.error("RFQ 밀봉 오류:", error)
+ toast.error("RFQ 밀봉 중 오류가 발생했습니다")
+
+ // 에러 발생 시 낙관적 업데이트 되돌리기
+ if (localData?.data && setLocalData) {
+ const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
+ if (rowIndex >= 0) {
+ const newData = [...localData.data] as ProcurementRfqsView[];
+ newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원
+ setLocalData({
+ ...localData,
+ data: newData ?? [],
+ pageCount: localData.pageCount,
+ total: localData.total ?? 0
+ });
+ }
+ }
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ // RFQ 전송 버튼 클릭 핸들러
+ const handleSendRfq = async () => {
+ const rfq = getSelectedRfq()
+ if (!rfq) {
+ toast.warning("RFQ를 선택해주세요")
+ return
+ }
+
+ // 전송 가능한 상태인지 확인
+ if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ toast.warning("벤더가 할당된 RFQ이거나 전송한 적이 있는 RFQ만 전송할 수 있습니다")
+ return
+ }
+
+ try {
+ setIsProcessing(true)
+
+ const result = await sendRfq(rfq.id)
+
+ if (result.success) {
+ toast.success("RFQ가 성공적으로 전송되었습니다")
+ // 데이터 리프레시
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "RFQ 전송 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("RFQ 전송 오류:", error)
+ toast.error("RFQ 전송 중 오류가 발생했습니다")
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ const handleFetchExternalRfqs = async () => {
+ try {
+ setIsProcessing(true);
+
+ const result = await fetchExternalRfqs();
+
+ if (result.success) {
+ toast.success(result.message || "외부 RFQ를 성공적으로 가져왔습니다");
+ // 데이터 리프레시
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "외부 RFQ를 가져오는 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("외부 RFQ 가져오기 오류:", error);
+ toast.error("외부 RFQ를 가져오는 중 오류가 발생했습니다");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "rfq",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ {/* RFQ 가져오기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFetchExternalRfqs}
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 가져오기</span>
+ </Button>
+
+ {/* PR 상세보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleViewPrDetails}
+ className="gap-2"
+ disabled={!selectedRfq || !(selectedRfq.prItemsCount && selectedRfq.prItemsCount > 0)}
+ >
+ <ClipboardList className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">PR 상세보기</span>
+ </Button>
+
+ {/* RFQ 밀봉 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSealRfq}
+ className="gap-2"
+ disabled={!selectedRfq || selectedRfq.rfqSealedYn === "Y" || selectedRfq.status !== "RFQ Sent" || isProcessing}
+ >
+ <Lock className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 밀봉</span>
+ </Button>
+
+ {/* RFQ 전송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ (selectedRfq.status !== "RFQ Vendor Assignned" && selectedRfq.status !== "RFQ Sent") ||
+ isProcessing
+ }
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 전송</span>
+ </Button>
+ </div>
+
+ {/* PR 상세정보 다이얼로그 */}
+ <PrDetailsDialog
+ open={dialogOpen}
+ onOpenChange={setDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx
new file mode 100644
index 00000000..510f474d
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table.tsx
@@ -0,0 +1,209 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns, EditingCellState } from "./rfq-table-column"
+import { useEffect } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
+import { ProcurementRfqsView } from "@/db/schema"
+import { getPORfqs } from "../services"
+import { toast } from "sonner"
+import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요
+
+interface RFQListTableProps {
+ data?: Awaited<ReturnType<typeof getPORfqs>>;
+ onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void;
+ // 데이터 새로고침을 위한 콜백 추가
+ onDataRefresh?: () => void;
+ maxHeight?: string | number; // Add this prop
+}
+
+// 보다 유연한 타입 정의
+type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>;
+
+export function RFQListTable({
+ data,
+ onSelectRFQ,
+ onDataRefresh,
+ maxHeight
+}: RFQListTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
+ // 인라인 에디팅을 위한 상태 추가
+ const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
+ // 로컬 데이터를 관리하기 위한 상태 추가
+ const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 });
+
+ // 데이터가 변경될 때 로컬 데이터도 업데이트
+ useEffect(() => {
+ setLocalData(data || { data: [], pageCount: 0, total: 0 })
+ }, [data])
+
+
+ // 비고 업데이트 함수
+ const updateRemark = async (rfqId: number, remark: string) => {
+ try {
+ // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
+ if (localData && localData.data) {
+ // 로컬 데이터에서 해당 행 찾기
+ const rowIndex = localData.data.findIndex(row => row.id === rfqId);
+ if (rowIndex >= 0) {
+ // 불변성을 유지하면서 로컬 데이터 업데이트
+ const newData = [...localData.data];
+ newData[rowIndex] = { ...newData[rowIndex], remark };
+
+ // 전체 데이터 구조 복사하여 업데이트
+ setLocalData({ ...localData, data: newData } as typeof localData);
+ }
+ }
+
+ const result = await updateRfqRemark(rfqId, remark);
+
+ if (result.success) {
+ toast.success("비고가 업데이트되었습니다");
+
+ // 서버 데이터 리프레시 호출
+ if (onDataRefresh) {
+ onDataRefresh();
+ }
+ } else {
+ toast.error(result.message || "업데이트 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error);
+ toast.error("업데이트 중 오류가 발생했습니다");
+ }
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ // 액션 유형에 따라 처리
+ switch (rowAction.type) {
+ case "select":
+ // 선택된 문서 처리
+ if (onSelectRFQ) {
+ onSelectRFQ(rowAction.row.original)
+ }
+ break;
+ case "update":
+ // 업데이트 처리 로직
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ // 삭제 처리 로직
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+
+ // 액션 처리 후 rowAction 초기화
+ setRowAction(null)
+ }
+ }, [rowAction, onSelectRFQ])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark
+ }),
+ [setRowAction, editingCell, setEditingCell, updateRemark]
+ )
+
+
+ // Filter fields
+ const filterFields: DataTableFilterField<ProcurementRfqsView>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "프로젝트",
+ type: "text",
+ },
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "자재명",
+ type: "text",
+ },
+
+ {
+ id: "rfqSealedYn",
+ label: "RFQ 밀봉여부",
+ type: "text",
+ },
+ {
+ id: "majorItemMaterialCode",
+ label: "자재코드",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByUserName",
+ label: "요청자",
+ type: "text",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정
+ const { table } = useDataTable({
+ data: localData?.data || [],
+ columns,
+ pageCount: localData?.pageCount || 0,
+ rowCount: localData?.total || 0, // 총 레코드 수 추가
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ return (
+ <div className="w-full overflow-auto">
+ <DataTable table={table} maxHeight={maxHeight}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RFQTableToolbarActions
+ table={table}
+ localData={localData}
+ setLocalData={setLocalData}
+ onSuccess={onDataRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ )
+} \ No newline at end of file