From fefca6304eefea94f41057f9f934b0e19ceb54bb Mon Sep 17 00:00:00 2001 From: 0-Zz-ang Date: Fri, 22 Aug 2025 13:47:37 +0900 Subject: (박서영)Compliance 설문/응답 리스트 생성 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../responses/compliance-response-stats.tsx | 97 +++++++++++ .../responses/compliance-responses-columns.tsx | 189 +++++++++++++++++++++ .../responses/compliance-responses-list.tsx | 141 +++++++++++++++ .../responses/compliance-responses-page-client.tsx | 62 +++++++ .../responses/compliance-responses-table.tsx | 141 +++++++++++++++ .../responses/compliance-responses-toolbar.tsx | 69 ++++++++ 6 files changed, 699 insertions(+) create mode 100644 lib/compliance/responses/compliance-response-stats.tsx create mode 100644 lib/compliance/responses/compliance-responses-columns.tsx create mode 100644 lib/compliance/responses/compliance-responses-list.tsx create mode 100644 lib/compliance/responses/compliance-responses-page-client.tsx create mode 100644 lib/compliance/responses/compliance-responses-table.tsx create mode 100644 lib/compliance/responses/compliance-responses-toolbar.tsx (limited to 'lib/compliance/responses') diff --git a/lib/compliance/responses/compliance-response-stats.tsx b/lib/compliance/responses/compliance-response-stats.tsx new file mode 100644 index 00000000..dace0505 --- /dev/null +++ b/lib/compliance/responses/compliance-response-stats.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Clock, CheckCircle, Eye, FileText } from "lucide-react"; + +interface ComplianceResponseStatsProps { + stats: { + inProgress: number; + completed: number; + reviewed: number; + total: number; + }; + onFilterChange?: (filter: 'all' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED') => void; + currentFilter?: string; +} + +export function ComplianceResponseStats({ stats, onFilterChange, currentFilter }: ComplianceResponseStatsProps) { + return ( +
+ {/* 전체 응답 */} + onFilterChange?.('all')} + > + + 전체 응답 + + + +
{stats.total}
+

+ 총 {stats.total}개 응답 +

+
+
+ + {/* 진행중 */} + onFilterChange?.('IN_PROGRESS')} + > + + 진행중 + + + +
{stats.inProgress}
+

+ 작성 중인 응답 +

+
+
+ + {/* 제출완료 */} + onFilterChange?.('COMPLETED')} + > + + 제출완료 + + + +
{stats.completed}
+

+ 제출 완료된 응답 +

+
+
+ + {/* 검토완료 */} + onFilterChange?.('REVIEWED')} + > + + 검토완료 + + + +
{stats.reviewed}
+

+ 검토 완료된 응답 +

+
+
+
+ ); +} diff --git a/lib/compliance/responses/compliance-responses-columns.tsx b/lib/compliance/responses/compliance-responses-columns.tsx new file mode 100644 index 00000000..c9596ae5 --- /dev/null +++ b/lib/compliance/responses/compliance-responses-columns.tsx @@ -0,0 +1,189 @@ +"use client"; + +import * as React from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuShortcut, +} from "@/components/ui/dropdown-menu"; +import { MoreHorizontal, Eye, Download, Trash2 } from "lucide-react"; +import type { DataTableRowAction } from "@/types/table"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; + +interface GetResponseColumnsProps { + setRowAction: React.Dispatch | null>>; +} + +export function getResponseColumns({ setRowAction }: GetResponseColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }; + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + header: "작업", + enableHiding: false, + cell: ({ row }) => { + const response = row.original; + + return ( + + + + + + window.location.href = `/evcp/compliance/${response.templateId}/responses/${response.id}`}> + + Detail + + setRowAction({ type: 'delete', row: row })}> + + Delete + ⌘⌫ + + + + ); + }, + size: 40, + }; + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들 (정렬 가능) + // ---------------------------------------------------------------- + const dataColumns: ColumnDef[] = [ + { + accessorKey: "templateName", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("templateName")}
+ ), + enableResizing: true, + }, + { + accessorKey: "vendorId", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("vendorId") || '-'}
+ ), + enableResizing: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("vendorName") || '-'}
+ ), + enableResizing: true, + }, + { + accessorKey: "contractName", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("contractName") || '-'}
+ ), + enableResizing: true, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + const getStatusBadge = (status: string) => { + switch (status) { + case "COMPLETED": + return 제출완료; + case "IN_PROGRESS": + return 진행중; + case "REVIEWED": + return 검토완료; + default: + return {status}; + } + }; + return getStatusBadge(status); + }, + enableResizing: true, + }, + { + accessorKey: "reviewerName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const reviewerName = row.getValue("reviewerName") as string; + return reviewerName || '-'; + }, + enableResizing: true, + }, + { + accessorKey: "reviewedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue("reviewedAt") as Date; + return date ? format(new Date(date), 'yyyy-MM-dd HH:mm', { locale: ko }) : '-'; + }, + enableResizing: true, + }, + ]; + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, dataColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ]; +} diff --git a/lib/compliance/responses/compliance-responses-list.tsx b/lib/compliance/responses/compliance-responses-list.tsx new file mode 100644 index 00000000..cfa934ec --- /dev/null +++ b/lib/compliance/responses/compliance-responses-list.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Eye, Download } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { complianceSurveyTemplates } from "@/db/schema/compliance"; + +interface ComplianceResponsesListProps { + template: typeof complianceSurveyTemplates.$inferSelect; + responses: Array<{ + id: number; + basicContractId: number; + templateId: number; + status: string; + completedAt: Date | null; + reviewedBy: number | null; + reviewedAt: Date | null; + reviewNotes: string | null; + createdAt: Date; + updatedAt: Date; + answersCount: number; + }>; +} + +export function ComplianceResponsesList({ template, responses }: ComplianceResponsesListProps) { + const router = useRouter(); + + const getStatusBadge = (status: string) => { + switch (status) { + case "COMPLETED": + return 완료; + case "IN_PROGRESS": + return 진행중; + case "REVIEWED": + return 검토완료; + default: + return {status}; + } + }; + + if (responses.length === 0) { + return ( +
+

아직 응답이 없습니다.

+
+ ); + } + + return ( +
+
+ + + + 응답 ID + 계약 ID + 상태 + 답변 수 + 완료일 + 검토일 + 생성일 + 작업 + + + + {responses.map((response) => ( + + + #{response.id} + + + {response.basicContractId} + + + {getStatusBadge(response.status)} + + + {response.answersCount}개 + + + {response.completedAt + ? format(new Date(response.completedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) + : "-" + } + + + {response.reviewedAt + ? format(new Date(response.reviewedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) + : "-" + } + + + {response.createdAt + ? format(new Date(response.createdAt), 'yyyy-MM-dd HH:mm', { locale: ko }) + : "-" + } + + +
+ + {response.status === "COMPLETED" && ( + + )} +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/lib/compliance/responses/compliance-responses-page-client.tsx b/lib/compliance/responses/compliance-responses-page-client.tsx new file mode 100644 index 00000000..758d9ed7 --- /dev/null +++ b/lib/compliance/responses/compliance-responses-page-client.tsx @@ -0,0 +1,62 @@ +"use client"; + +import * as React from "react"; +import { getComplianceResponsesWithPagination } from "@/lib/compliance/services"; +import { ComplianceResponsesTable } from "./compliance-responses-table"; +import { ComplianceResponseStats } from "./compliance-response-stats"; + +interface ComplianceResponsesPageClientProps { + templateId: number; + promises?: Promise<[{ data: any[]; pageCount: number }, any]>; + isInfiniteMode: boolean; +} + +export function ComplianceResponsesPageClient({ + templateId, + promises, + isInfiniteMode +}: ComplianceResponsesPageClientProps) { + // 페이지네이션 모드 데이터 + const paginationData = promises ? React.use(promises) : null; + const responses = paginationData ? paginationData[0] : { data: [], pageCount: 0 }; + const stats = paginationData ? paginationData[1] : { + inProgress: 0, + completed: 0, + reviewed: 0, + total: 0, + }; + + const [statusFilter, setStatusFilter] = React.useState<'all' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED'>('all'); + + // 필터링된 데이터 + const filteredData = React.useMemo(() => { + if (statusFilter === 'all') { + return responses.data; + } + return responses.data.filter(item => item.status === statusFilter); + }, [responses.data, statusFilter]); + + // 통계 카드 클릭 핸들러 + const handleFilterChange = (filter: 'all' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED') => { + setStatusFilter(filter); + }; + + return ( + <> + {/* 응답 통계 카드 */} +
+ +
+ + {/* 응답 테이블 */} + + + ); +} diff --git a/lib/compliance/responses/compliance-responses-table.tsx b/lib/compliance/responses/compliance-responses-table.tsx new file mode 100644 index 00000000..e4292719 --- /dev/null +++ b/lib/compliance/responses/compliance-responses-table.tsx @@ -0,0 +1,141 @@ +"use client"; + +import * as React from "react"; +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 type { + DataTableAdvancedFilterField, + DataTableRowAction, + DataTableFilterField, +} from "@/types/table" +import { getComplianceResponsesWithPagination } from "../services"; +import { getResponseColumns } from "./compliance-responses-columns"; +import { ComplianceResponsesToolbarActions } from "./compliance-responses-toolbar"; + +interface ComplianceResponsesTableProps { + templateId: number; + promises?: Promise<[{ data: any[]; pageCount: number }]>; +} + +export function ComplianceResponsesTable({ templateId, promises }: ComplianceResponsesTableProps) { + // 페이지네이션 모드 데이터 + const paginationData = promises ? React.use(promises) : null; + const [{ data: initialData = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }]; + + const [rowAction, setRowAction] = React.useState | null>(null); + const [data, setData] = React.useState(initialData); + const [currentSorting, setCurrentSorting] = React.useState<{ id: string; desc: boolean }[]>([]); + + // 초기 데이터가 변경되면 data 상태 업데이트 + React.useEffect(() => { + setData(initialData); + }, [initialData]); + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getResponseColumns({ setRowAction }), + [setRowAction] + ) + + // 기본 필터 필드 설정 + const filterFields: DataTableFilterField[] = [ + { + id: "status", + label: "상태", + options: [ + { label: "진행중", value: "IN_PROGRESS" }, + { label: "완료", value: "COMPLETED" }, + { label: "검토완료", value: "REVIEWED" }, + ], + }, + ]; + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "vendorId", label: "Vendor ID", type: "text" }, + { id: "vendorName", label: "업체명", type: "text" }, + { id: "contractName", label: "계약서명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "진행중", value: "IN_PROGRESS" }, + { label: "완료", value: "COMPLETED" }, + { label: "검토완료", value: "REVIEWED" }, + ] + }, + { id: "answersCount", label: "답변 수", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "completedAt", label: "완료일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableRowSelection: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + // 정렬 상태 변경 감지 + React.useEffect(() => { + const newSorting = table.getState().sorting; + if (JSON.stringify(newSorting) !== JSON.stringify(currentSorting)) { + setCurrentSorting(newSorting); + } + }, [table.getState().sorting, currentSorting]); + + // 정렬이 변경될 때 데이터 다시 로드 (응답 데이터는 클라이언트 사이드 정렬) + React.useEffect(() => { + if (currentSorting && currentSorting.length > 0) { + const sortedData = [...initialData].sort((a, b) => { + for (const sort of currentSorting) { + const aValue = a[sort.id]; + const bValue = b[sort.id]; + + if (aValue === bValue) continue; + + if (aValue === null || aValue === undefined) return 1; + if (bValue === null || bValue === undefined) return -1; + + if (typeof aValue === 'string' && typeof bValue === 'string') { + return sort.desc ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue); + } + + if (aValue instanceof Date && bValue instanceof Date) { + return sort.desc ? bValue.getTime() - aValue.getTime() : aValue.getTime() - bValue.getTime(); + } + + return sort.desc ? (bValue > aValue ? 1 : -1) : (aValue > bValue ? 1 : -1); + } + return 0; + }); + + setData(sortedData); + } else { + setData(initialData); + } + }, [currentSorting, initialData]); + + return ( + <> + + + + + + + ); +} diff --git a/lib/compliance/responses/compliance-responses-toolbar.tsx b/lib/compliance/responses/compliance-responses-toolbar.tsx new file mode 100644 index 00000000..26755aee --- /dev/null +++ b/lib/compliance/responses/compliance-responses-toolbar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Download, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { Table } from "@tanstack/react-table"; + +interface ComplianceResponsesToolbarActionsProps { + table: Table; +} + +export function ComplianceResponsesToolbarActions({ + table, +}: ComplianceResponsesToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows; + + const handleDeleteSelected = async () => { + if (selectedRows.length === 0) { + toast.error("삭제할 응답을 선택해주세요."); + return; + } + + // TODO: 선택된 응답들 삭제 기능 구현 + console.log("Delete selected responses:", selectedRows.map(row => row.original)); + toast.success(`${selectedRows.length}개의 응답이 삭제되었습니다.`); + + // 페이지 새로고침으로 데이터 업데이트 + window.location.reload(); + }; + + const handleExport = () => { + if (selectedRows.length === 0) { + toast.error("내보낼 응답을 선택해주세요."); + return; + } + + // TODO: 선택된 응답들 내보내기 기능 구현 + console.log("Export selected responses:", selectedRows.map(row => row.original)); + toast.success(`${selectedRows.length}개의 응답이 내보내졌습니다.`); + }; + + return ( +
+ {selectedRows.length > 0 && ( + <> + + + + )} +
+ ); +} -- cgit v1.2.3