diff options
Diffstat (limited to 'lib/compliance/responses')
6 files changed, 699 insertions, 0 deletions
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 ( + <div className="grid gap-4 md:grid-cols-4"> + {/* 전체 응답 */} + <Card + className={`cursor-pointer hover:shadow-md transition-shadow ${ + currentFilter === 'all' ? 'ring-2 ring-blue-500' : '' + }`} + onClick={() => onFilterChange?.('all')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 응답</CardTitle> + <FileText className="h-3 w-3 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {stats.total}개 응답 + </p> + </CardContent> + </Card> + + {/* 진행중 */} + <Card + className={`cursor-pointer hover:shadow-md transition-shadow ${ + currentFilter === 'IN_PROGRESS' ? 'ring-2 ring-orange-500' : '' + }`} + onClick={() => onFilterChange?.('IN_PROGRESS')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">진행중</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-500">{stats.inProgress}</div> + <p className="text-xs text-muted-foreground"> + 작성 중인 응답 + </p> + </CardContent> + </Card> + + {/* 제출완료 */} + <Card + className={`cursor-pointer hover:shadow-md transition-shadow ${ + currentFilter === 'COMPLETED' ? 'ring-2 ring-green-500' : '' + }`} + onClick={() => onFilterChange?.('COMPLETED')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">제출완료</CardTitle> + <CheckCircle className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-500">{stats.completed}</div> + <p className="text-xs text-muted-foreground"> + 제출 완료된 응답 + </p> + </CardContent> + </Card> + + {/* 검토완료 */} + <Card + className={`cursor-pointer hover:shadow-md transition-shadow ${ + currentFilter === 'REVIEWED' ? 'ring-2 ring-blue-500' : '' + }`} + onClick={() => onFilterChange?.('REVIEWED')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토완료</CardTitle> + <Eye className="h-4 w-4 text-blue-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-blue-500">{stats.reviewed}</div> + <p className="text-xs text-muted-foreground"> + 검토 완료된 응답 + </p> + </CardContent> + </Card> + </div> + ); +} 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<React.SetStateAction<DataTableRowAction<any> | null>>; +} + +export function getResponseColumns({ setRowAction }: GetResponseColumnsProps): ColumnDef<any>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<any> = { + 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, + }; + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<any> = { + id: "actions", + header: "작업", + enableHiding: false, + cell: ({ row }) => { + const response = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => window.location.href = `/evcp/compliance/${response.templateId}/responses/${response.id}`}> + <Eye className="mr-2 h-4 w-4" /> + Detail + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ type: 'delete', row: row })}> + <Trash2 className="mr-2 h-4 w-4" /> + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 40, + }; + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들 (정렬 가능) + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<any>[] = [ + { + accessorKey: "templateName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="템플릿명" /> + ), + cell: ({ row }) => ( + <div className="font-medium">{row.getValue("templateName")}</div> + ), + enableResizing: true, + }, + { + accessorKey: "vendorId", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor ID" /> + ), + cell: ({ row }) => ( + <div className="font-medium">{row.getValue("vendorId") || '-'}</div> + ), + enableResizing: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업체명" /> + ), + cell: ({ row }) => ( + <div className="font-medium">{row.getValue("vendorName") || '-'}</div> + ), + enableResizing: true, + }, + { + accessorKey: "contractName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="계약서명" /> + ), + cell: ({ row }) => ( + <div className="font-medium">{row.getValue("contractName") || '-'}</div> + ), + enableResizing: true, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + const getStatusBadge = (status: string) => { + switch (status) { + case "COMPLETED": + return <Badge variant="default">제출완료</Badge>; + case "IN_PROGRESS": + return <Badge variant="secondary">진행중</Badge>; + case "REVIEWED": + return <Badge variant="outline">검토완료</Badge>; + default: + return <Badge variant="secondary">{status}</Badge>; + } + }; + return getStatusBadge(status); + }, + enableResizing: true, + }, + { + accessorKey: "reviewerName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="검토자" /> + ), + cell: ({ row }) => { + const reviewerName = row.getValue("reviewerName") as string; + return reviewerName || '-'; + }, + enableResizing: true, + }, + { + accessorKey: "reviewedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="검토일시" /> + ), + 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 <Badge variant="default">완료</Badge>; + case "IN_PROGRESS": + return <Badge variant="secondary">진행중</Badge>; + case "REVIEWED": + return <Badge variant="outline">검토완료</Badge>; + default: + return <Badge variant="secondary">{status}</Badge>; + } + }; + + if (responses.length === 0) { + return ( + <div className="text-center py-8"> + <p className="text-muted-foreground">아직 응답이 없습니다.</p> + </div> + ); + } + + return ( + <div className="space-y-4"> + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead>응답 ID</TableHead> + <TableHead>계약 ID</TableHead> + <TableHead>상태</TableHead> + <TableHead>답변 수</TableHead> + <TableHead>완료일</TableHead> + <TableHead>검토일</TableHead> + <TableHead>생성일</TableHead> + <TableHead>작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {responses.map((response) => ( + <TableRow key={response.id}> + <TableCell className="font-medium"> + #{response.id} + </TableCell> + <TableCell> + {response.basicContractId} + </TableCell> + <TableCell> + {getStatusBadge(response.status)} + </TableCell> + <TableCell> + <Badge variant="outline">{response.answersCount}개</Badge> + </TableCell> + <TableCell> + {response.completedAt + ? format(new Date(response.completedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) + : "-" + } + </TableCell> + <TableCell> + {response.reviewedAt + ? format(new Date(response.reviewedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) + : "-" + } + </TableCell> + <TableCell> + {response.createdAt + ? format(new Date(response.createdAt), 'yyyy-MM-dd HH:mm', { locale: ko }) + : "-" + } + </TableCell> + <TableCell> + <div className="flex space-x-2"> + <Button + variant="ghost" + size="sm" + onClick={() => router.push(`/evcp/compliance/${template.id}/responses/${response.id}`)} + > + <Eye className="h-4 w-4 mr-1" /> + 상세보기 + </Button> + {response.status === "COMPLETED" && ( + <Button + variant="ghost" + size="sm" + onClick={() => { + // TODO: 응답 다운로드 기능 구현 + console.log("Download response:", response.id); + }} + > + <Download className="h-4 w-4 mr-1" /> + 다운로드 + </Button> + )} + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + ); +} 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 ( + <> + {/* 응답 통계 카드 */} + <div className="mb-6"> + <ComplianceResponseStats + stats={stats} + onFilterChange={handleFilterChange} + currentFilter={statusFilter} + /> + </div> + + {/* 응답 테이블 */} + <ComplianceResponsesTable + templateId={templateId} + promises={Promise.resolve([{ data: filteredData, pageCount: Math.ceil(filteredData.length / 10) }])} + /> + </> + ); +} 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<DataTableRowAction<any> | 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<any>[] = [ + { + id: "status", + label: "상태", + options: [ + { label: "진행중", value: "IN_PROGRESS" }, + { label: "완료", value: "COMPLETED" }, + { label: "검토완료", value: "REVIEWED" }, + ], + }, + ]; + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { 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 ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <ComplianceResponsesToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ); +} 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<TData> { + table: Table<TData>; +} + +export function ComplianceResponsesToolbarActions<TData>({ + table, +}: ComplianceResponsesToolbarActionsProps<TData>) { + 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 ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="h-8" + > + <Download className="mr-2 h-4 w-4" /> + 내보내기 ({selectedRows.length}) + </Button> + <Button + variant="destructive" + size="sm" + onClick={handleDeleteSelected} + className="h-8" + > + <Trash2 className="mr-2 h-4 w-4" /> + Delete ({selectedRows.length}) + </Button> + </> + )} + </div> + ); +} |
