diff options
3 files changed, 420 insertions, 130 deletions
diff --git a/components/common/legal/sslvw-pur-inq-req-dialog.tsx b/components/common/legal/sslvw-pur-inq-req-dialog.tsx new file mode 100644 index 00000000..438b6582 --- /dev/null +++ b/components/common/legal/sslvw-pur-inq-req-dialog.tsx @@ -0,0 +1,333 @@ +"use client" + +import * as React from "react" +import { Loader, Database, Check } from "lucide-react" +import { toast } from "sonner" +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { getSSLVWPurInqReqData } from "@/lib/basic-contract/sslvw-service" +import { SSLVWPurInqReq } from "@/lib/basic-contract/sslvw-service" + +interface SSLVWPurInqReqDialogProps { + onConfirm?: (selectedRows: SSLVWPurInqReq[]) => void +} + +export function SSLVWPurInqReqDialog({ onConfirm }: SSLVWPurInqReqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [data, setData] = React.useState<SSLVWPurInqReq[]>([]) + const [error, setError] = React.useState<string | null>(null) + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}) + + const loadData = async () => { + setIsLoading(true) + setError(null) + try { + const result = await getSSLVWPurInqReqData() + if (result.success) { + setData(result.data) + if (result.isUsingFallback) { + toast.info("테스트 데이터를 표시합니다.") + } + } else { + setError(result.error || "데이터 로딩 실패") + toast.error(result.error || "데이터 로딩 실패") + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류" + setError(errorMessage) + toast.error(errorMessage) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (open) { + loadData() + } else { + // 다이얼로그 닫힐 때 데이터 초기화 + setData([]) + setError(null) + setRowSelection({}) + } + }, [open]) + + // 테이블 컬럼 정의 (동적 생성) + const columns = React.useMemo<ColumnDef<SSLVWPurInqReq>[]>(() => { + if (data.length === 0) return [] + + const dataKeys = Object.keys(data[0]) + + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...dataKeys.map((key) => ({ + accessorKey: key, + header: key, + cell: ({ getValue }: any) => { + const value = getValue() + return value !== null && value !== undefined ? String(value) : "" + }, + })), + ] + }, [data]) + + // 테이블 인스턴스 생성 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + // 확인 버튼 핸들러 + const handleConfirm = () => { + if (selectedRows.length === 0) { + toast.error("행을 선택해주세요.") + return + } + + if (onConfirm) { + onConfirm(selectedRows) + toast.success(`${selectedRows.length}개의 행을 선택했습니다.`) + } else { + // 임시로 선택된 데이터 콘솔 출력 + console.log("선택된 행들:", selectedRows) + toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`) + } + + setOpen(false) + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Database className="mr-2 size-4" aria-hidden="true" /> + 법무검토 요청 데이터 조회 + </Button> + </DialogTrigger> + <DialogContent className="max-w-7xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>법무검토 요청 데이터</DialogTitle> + <DialogDescription> + 법무검토 요청 데이터를 조회합니다. + {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`} + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden flex flex-col"> + {isLoading ? ( + <div className="flex items-center justify-center flex-1"> + <Loader className="mr-2 size-6 animate-spin" /> + <span>데이터 로딩 중...</span> + </div> + ) : error ? ( + <div className="flex items-center justify-center flex-1 text-red-500"> + <span>오류: {error}</span> + </div> + ) : data.length === 0 ? ( + <div className="flex items-center justify-center flex-1 text-muted-foreground"> + <span>데이터가 없습니다.</span> + </div> + ) : ( + <> + <ScrollArea className="flex-1"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} className="font-medium"> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id} className="text-sm"> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </ScrollArea> + + {/* 페이지네이션 컨트롤 */} + <div className="flex items-center justify-between px-2 py-4 border-t"> + <div className="flex-1 text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨 + </div> + <div className="flex items-center space-x-6 lg:space-x-8"> + <div className="flex items-center space-x-2"> + <p className="text-sm font-medium">페이지당 행 수</p> + <select + value={table.getState().pagination.pageSize} + onChange={(e) => { + table.setPageSize(Number(e.target.value)) + }} + className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + > + {[10, 20, 30, 40, 50].map((pageSize) => ( + <option key={pageSize} value={pageSize}> + {pageSize} + </option> + ))} + </select> + </div> + <div className="flex w-[100px] items-center justify-center text-sm font-medium"> + {table.getState().pagination.pageIndex + 1} /{" "} + {table.getPageCount()} + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + {"<<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지</span> + {"<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지</span> + {">"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + {">>"} + </Button> + </div> + </div> + </div> + </> + )} + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={() => setOpen(false)}> + 닫기 + </Button> + {/* <Button onClick={loadData} disabled={isLoading} variant="outline"> + {isLoading ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + 로딩 중... + </> + ) : ( + "새로고침" + )} + </Button> */} + <Button onClick={handleConfirm} disabled={selectedRows.length === 0}> + <Check className="mr-2 size-4" /> + 확인 ({selectedRows.length}) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts new file mode 100644 index 00000000..9650d43a --- /dev/null +++ b/lib/basic-contract/sslvw-service.ts @@ -0,0 +1,82 @@ +"use server" + +import { oracleKnex } from '@/lib/oracle-db/db' + +// SSLVW_PUR_INQ_REQ 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요) +export interface SSLVWPurInqReq { + [key: string]: string | number | Date | null | undefined +} + +// 테스트 환경용 폴백 데이터 +const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [ + { + id: 1, + request_number: 'REQ001', + status: 'PENDING', + created_date: new Date('2025-01-01'), + description: '테스트 요청 1' + }, + { + id: 2, + request_number: 'REQ002', + status: 'APPROVED', + created_date: new Date('2025-01-02'), + description: '테스트 요청 2' + } +] + +/** + * SSLVW_PUR_INQ_REQ 테이블 전체 조회 + * @returns 테이블 데이터 배열 + */ +export async function getSSLVWPurInqReqData(): Promise<{ + success: boolean + data: SSLVWPurInqReq[] + error?: string + isUsingFallback?: boolean +}> { + try { + console.log('📋 [getSSLVWPurInqReqData] SSLVW_PUR_INQ_REQ 테이블 조회 시작...') + + const result = await oracleKnex.raw(` + SELECT * + FROM SSLVW_PUR_INQ_REQ + WHERE ROWNUM < 100 + ORDER BY 1 + `) + + // Oracle raw query의 결과는 rows 배열에 들어있음 + const rows = (result.rows || result) as Array<Record<string, unknown>> + + console.log(`✅ [getSSLVWPurInqReqData] 조회 성공 - ${rows.length}건`) + + // 데이터 타입 변환 (필요에 따라 조정) + const cleanedResult = rows.map((item) => { + const convertedItem: SSLVWPurInqReq = {} + for (const [key, value] of Object.entries(item)) { + if (value instanceof Date) { + convertedItem[key] = value + } else if (value === null) { + convertedItem[key] = null + } else { + convertedItem[key] = String(value) + } + } + return convertedItem + }) + + return { + success: true, + data: cleanedResult, + isUsingFallback: false + } + } catch (error) { + console.error('❌ [getSSLVWPurInqReqData] 오류:', error) + console.log('🔄 [getSSLVWPurInqReqData] 폴백 테스트 데이터 사용') + return { + success: true, + data: FALLBACK_TEST_DATA, + isUsingFallback: true + } + } +} diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx index 37ae135c..c71be9d1 100644 --- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, FileDown, Mail, Scale, CheckCircle, AlertTriangle, Send, Gavel, Check, FileSignature } from "lucide-react" +import { Download, FileDown, Mail, CheckCircle, AlertTriangle, Send, Check, FileSignature } from "lucide-react" import { exportTableToExcel } from "@/lib/export" import { downloadFile } from "@/lib/file-download" @@ -18,11 +18,9 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" -import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction, resendContractsAction } from "../service" +import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction } from "../service" import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" +import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog" interface BasicContractDetailTableToolbarActionsProps { table: Table<BasicContractView> @@ -35,10 +33,8 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD // 다이얼로그 상태 const [resendDialog, setResendDialog] = React.useState(false) - const [legalReviewDialog, setLegalReviewDialog] = React.useState(false) const [finalApproveDialog, setFinalApproveDialog] = React.useState(false) const [loading, setLoading] = React.useState(false) - const [reviewNote, setReviewNote] = React.useState("") const [buyerSignDialog, setBuyerSignDialog] = React.useState(false) const [contractsToSign, setContractsToSign] = React.useState<any[]>([]) @@ -49,10 +45,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD const canBulkResend = hasSelectedRows - const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => - row.original.legalReviewRequired && !row.original.legalReviewRequestedAt - ) - const canFinalApprove = hasSelectedRows && selectedRows.some(row => { const contract = row.original; if (contract.completedAt !== null || !contract.signedFilePath) { @@ -67,10 +59,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD // 필터링된 계약서들 계산 const resendContracts = selectedRows.map(row => row.original) - const legalReviewContracts = selectedRows - .map(row => row.original) - .filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt) - const finalApproveContracts = selectedRows .map(row => row.original) .filter(contract => { @@ -204,15 +192,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD }) } - // 법무검토 요청 - const handleLegalReviewRequest = async () => { - if (!canRequestLegalReview) { - toast.error("법무검토 요청 가능한 계약서를 선택해주세요") - return - } - setLegalReviewDialog(true) - } - // 최종승인 const handleFinalApprove = async () => { if (!canFinalApprove) { @@ -241,26 +220,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD } } - // 법무검토 요청 확인 - const confirmLegalReview = async () => { - setLoading(true) - try { - // TODO: 서버액션 호출 - await requestLegalReviewAction(legalReviewContracts.map(c => c.id), reviewNote) - - console.log("법무검토 요청:", legalReviewContracts, "메모:", reviewNote) - toast.success(`${legalReviewContracts.length}건의 법무검토 요청을 완료했습니다`) - setLegalReviewDialog(false) - setReviewNote("") - table.toggleAllPageRowsSelected(false) // 선택 해제 - } catch (error) { - toast.error("법무검토 요청 중 오류가 발생했습니다") - console.error(error) - } finally { - setLoading(false) - } - } - // 최종승인 확인 (수정됨) const confirmFinalApprove = async () => { setLoading(true) @@ -354,25 +313,8 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD </span> </Button> - {/* 법무검토 요청 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleLegalReviewRequest} - disabled={!canRequestLegalReview} - className="gap-2" - title={!hasSelectedRows - ? "계약서를 선택해주세요" - : !canRequestLegalReview - ? "법무검토 요청 가능한 계약서가 없습니다" - : `${legalReviewContracts.length}건 법무검토 요청` - } - > - <Scale className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - 법무검토 {hasSelectedRows ? `(${selectedRows.length})` : ''} - </span> - </Button> + {/* 법무검토 버튼 (SSLVW 데이터 조회) */} + <SSLVWPurInqReqDialog /> {/* 최종승인 버튼 */} <Button @@ -471,73 +413,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD </DialogContent> </Dialog> - {/* 법무검토 요청 다이얼로그 */} - <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}> - <DialogContent className="max-w-2xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Gavel className="size-5" /> - 법무검토 요청 - </DialogTitle> - <DialogDescription> - 선택한 {legalReviewContracts.length}건의 계약서에 대한 법무검토를 요청합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - <div className="max-h-48 overflow-y-auto"> - <div className="space-y-3"> - {legalReviewContracts.map((contract, index) => ( - <div key={contract.id} className="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> - <div className="flex-1"> - <div className="font-medium">{contract.vendorName || '업체명 없음'}</div> - <div className="text-sm text-gray-500"> - {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} - </div> - </div> - <Badge variant="secondary">{contract.status}</Badge> - </div> - ))} - </div> - </div> - - <Separator /> - - <div className="space-y-2"> - <Label htmlFor="review-note">검토 요청 메모 (선택사항)</Label> - <Textarea - id="review-note" - placeholder="법무팀에게 전달할 특별한 요청사항이나 검토 포인트를 입력해주세요..." - value={reviewNote} - onChange={(e) => setReviewNote(e.target.value)} - rows={3} - /> - </div> - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => { - setLegalReviewDialog(false) - setReviewNote("") - }} - disabled={loading} - > - 취소 - </Button> - <Button - onClick={confirmLegalReview} - disabled={loading} - className="gap-2" - > - <Gavel className="size-4" /> - {loading ? "요청 중..." : `${legalReviewContracts.length}건 검토요청`} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - {/* 최종승인 다이얼로그 */} <Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}> <DialogContent className="max-w-2xl"> |
