diff --git a/apps/frontend/app/admin/_components/GroupAdminSideBar.tsx b/apps/frontend/app/admin/_components/GroupAdminSideBar.tsx new file mode 100644 index 0000000000..54f1fd529d --- /dev/null +++ b/apps/frontend/app/admin/_components/GroupAdminSideBar.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Separator } from '@/components/shadcn/separator' +import { cn } from '@/libs/utils' +import type { Route } from 'next' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import type { IconType } from 'react-icons' +import { FaChartBar, FaPen } from 'react-icons/fa6' +import { GroupLink } from './GroupLink' + +export function GroupAdminSideBar() { + const pathname = usePathname() + + const navItems: { name: string; path: Route; icon: IconType }[] = [ + { name: 'Dashboard', path: '/admin', icon: FaChartBar }, + { name: 'Problem', path: '/admin/problem', icon: FaPen } + ] + + return ( +
+ {navItems.map((item) => ( + + {item.icon && } + {item.name} + + ))} + + +
+ ) +} diff --git a/apps/frontend/app/admin/_components/GroupLink.tsx b/apps/frontend/app/admin/_components/GroupLink.tsx new file mode 100644 index 0000000000..22f86fb457 --- /dev/null +++ b/apps/frontend/app/admin/_components/GroupLink.tsx @@ -0,0 +1,53 @@ +import { GET_GROUPS } from '@/graphql/group/queries' +import { cn } from '@/libs/utils' +import { useQuery } from '@apollo/client' +import type { Route } from 'next' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { FaUserGroup } from 'react-icons/fa6' + +export function GroupLink() { + const pathname = usePathname() + + const { data } = useQuery(GET_GROUPS, { + variables: { + cursor: 1, + take: 5 + } + }) + return ( +
+ + {} + Group + + {data?.getGroups.map((group) => ( + + {group.groupName} + + ))} +
+ ) +} diff --git a/apps/frontend/app/admin/contest/_components/TimeForm.tsx b/apps/frontend/app/admin/_components/TimeForm.tsx similarity index 89% rename from apps/frontend/app/admin/contest/_components/TimeForm.tsx rename to apps/frontend/app/admin/_components/TimeForm.tsx index 86cc3db9f1..ffea517353 100644 --- a/apps/frontend/app/admin/contest/_components/TimeForm.tsx +++ b/apps/frontend/app/admin/_components/TimeForm.tsx @@ -1,6 +1,6 @@ import { DateTimePickerDemo } from '@/components/shadcn/date-time-picker-demo' import { useController, useFormContext } from 'react-hook-form' -import { ErrorMessage } from '../../_components/ErrorMessage' +import { ErrorMessage } from './ErrorMessage' export function TimeForm({ name }: { name: string }) { const { diff --git a/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx b/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx index fc3a2e9bd9..f01896825b 100644 --- a/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx +++ b/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx @@ -17,7 +17,7 @@ import { toast } from 'sonner' import { useDataTable } from './context' interface DataTableDeleteButtonProps { - target: 'problem' | 'contest' + target: 'problem' | 'contest' | 'assignment' | 'group' deleteTarget: (id: number) => Promise getCanDelete?: (selectedRows: TData[]) => Promise onSuccess?: () => void diff --git a/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx b/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx index 985d545216..f4cc8be8d3 100644 --- a/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx +++ b/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx @@ -14,10 +14,10 @@ import { useState } from 'react' import { useForm } from 'react-hook-form' import { FaAngleLeft } from 'react-icons/fa6' import { IoIosCheckmarkCircle } from 'react-icons/io' +import { TimeForm } from '../../../_components/TimeForm' import { ContestProblemListLabel } from '../../_components/ContestProblemListLabel' import { ContestProblemTable } from '../../_components/ContestProblemTable' import { ImportDialog } from '../../_components/ImportDialog' -import { TimeForm } from '../../_components/TimeForm' import { type ContestProblem, editSchema } from '../../_libs/schemas' import { EditContestForm } from './_components/EditContestForm' diff --git a/apps/frontend/app/admin/contest/create/page.tsx b/apps/frontend/app/admin/contest/create/page.tsx index 513da5dc74..f8ff055c41 100644 --- a/apps/frontend/app/admin/contest/create/page.tsx +++ b/apps/frontend/app/admin/contest/create/page.tsx @@ -10,11 +10,11 @@ import { ConfirmNavigation } from '../../_components/ConfirmNavigation' import { DescriptionForm } from '../../_components/DescriptionForm' import { FormSection } from '../../_components/FormSection' import { SwitchField } from '../../_components/SwitchField' +import { TimeForm } from '../../_components/TimeForm' import { TitleForm } from '../../_components/TitleForm' import { ContestProblemListLabel } from '../_components/ContestProblemListLabel' import { ContestProblemTable } from '../_components/ContestProblemTable' import { ImportDialog } from '../_components/ImportDialog' -import { TimeForm } from '../_components/TimeForm' import type { ContestProblem } from '../_libs/schemas' import { CreateContestForm } from './_components/CreateContestForm' diff --git a/apps/frontend/app/admin/group/[groupId]/_components/GroupSideBar.tsx b/apps/frontend/app/admin/group/[groupId]/_components/GroupSideBar.tsx new file mode 100644 index 0000000000..5736f84968 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/_components/GroupSideBar.tsx @@ -0,0 +1,78 @@ +'use client' + +import { cn } from '@/libs/utils' +import type { Route } from 'next' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import type { IconType } from 'react-icons' +import { + MdAssignment, + MdHome, + MdNotifications, + MdRateReview, + MdOutlineQuestionAnswer, + MdEditDocument, + MdPeople +} from 'react-icons/md' + +interface GroupSideBarProps { + groupId: string +} + +export function GroupSideBar({ groupId }: GroupSideBarProps) { + const pathname = usePathname() + + const navItems: { name: string; path: string; icon: IconType }[] = [ + { name: 'Home', path: `/admin/group/${groupId}`, icon: MdHome }, + { + name: 'Notice', + path: `/admin/group/${groupId}/notice`, + icon: MdNotifications + }, + { + name: 'User', + path: `/admin/group/${groupId}/user`, + icon: MdPeople + }, + { + name: 'Assignment', + path: `/admin/group/${groupId}/assignment`, + icon: MdAssignment + }, + { + name: 'Exam', + path: `/admin/group/${groupId}/exam`, + icon: MdEditDocument + }, + { + name: 'Grade', + path: `/admin/group/${groupId}/grade`, + icon: MdRateReview + }, + { + name: 'Q&A', + path: `/admin/group/${groupId}/qna`, + icon: MdOutlineQuestionAnswer + } + ] + + return ( +
+ {navItems.map((item) => ( + + {item.icon && } + {item.name} + + ))} +
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemColumns.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemColumns.tsx new file mode 100644 index 0000000000..15571aa669 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemColumns.tsx @@ -0,0 +1,175 @@ +'use client' + +import { OptionSelect } from '@/app/admin/_components/OptionSelect' +import { ContainedContests } from '@/app/admin/problem/_components/ContainedContests' +import { Badge } from '@/components/shadcn/badge' +import { Input } from '@/components/shadcn/input' +import type { Level } from '@/types/type' +import type { ColumnDef } from '@tanstack/react-table' +import { toast } from 'sonner' +import type { AssignmentProblem } from '../_libs/schemas' + +export const createColumns = ( + setProblems: React.Dispatch>, + disableInput: boolean +): ColumnDef[] => [ + // { + // id: 'select', + // header: ({ table }) => ( + // table.toggleAllPageRowsSelected(!!value)} + // aria-label="Select all" + // className="translate-y-[2px] bg-white" + // /> + // ), + // cell: ({ row }) => ( + // e.stopPropagation()} + // checked={row.getIsSelected()} + // onCheckedChange={(value) => row.toggleSelected(!!value)} + // aria-label="Select row" + // className="translate-y-[2px] bg-white" + // /> + // ), + // enableSorting: false, + // enableHiding: false + // }, + { + accessorKey: 'title', + header: () =>

Title

, + cell: ({ row }) => ( +

+ {row.getValue('title')} +

+ ), + footer: () =>

Score Sum

, + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'score', + header: () =>

Score

, + cell: ({ row }) => ( +
{ + if (disableInput) { + toast.error('Problem scoring cannot be edited') + } + }} + > + { + if (e.key === 'Enter') { + e.preventDefault() + const target = e.target as HTMLInputElement + target.blur() + } + }} + onBlur={(event) => { + setProblems((prevProblems: AssignmentProblem[]) => + prevProblems.map((problem) => + problem.id === row.original.id + ? { ...problem, score: Number(event.target.value) } + : problem + ) + ) + }} + /> +
+ ), + footer: ({ table }) => ( +
+ row.original) + .reduce((total, problem) => total + problem.score, 0)} + /> +
+ ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'order', + header: () =>

Order

, + cell: ({ table, row }) => { + const tableRows = table.getRowModel().rows + const alphabetArray = tableRows.map((_, index) => + String.fromCharCode(65 + index) + ) + return ( +
{ + if (disableInput) { + toast.error('Problem order cannot be edited') + } + }} + > + { + setProblems((prevProblems: AssignmentProblem[]) => + prevProblems.map((problem) => + problem.id === row.original.id + ? { ...problem, order: selectedOrder.charCodeAt(0) - 65 } + : problem + ) + ) + }} + className="w-[70px] disabled:pointer-events-none" + disabled={disableInput} + /> +
+ ) + } + }, + { + accessorKey: 'difficulty', + header: () =>

Level

, + cell: ({ row }) => { + const level: string = row.getValue('difficulty') + const formattedLevel = `Level ${level.slice(-1)}` + return ( +
+ + {formattedLevel} + +
+ ) + }, + enableSorting: false + }, + { + accessorKey: 'included', + header: () =>

Included

, + cell: ({ row }) => ( +
+ {/* 백엔드 API 작업 완료 후 수정 예정 */} + +
+ ), + enableSorting: false + } +] diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemListLabel.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemListLabel.tsx new file mode 100644 index 0000000000..6060a31cc0 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemListLabel.tsx @@ -0,0 +1,39 @@ +import { Label } from '@/app/admin/_components/Label' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/shadcn/tooltip' +import { MdHelpOutline } from 'react-icons/md' + +export function AssignmentProblemListLabel() { + return ( +
+ + + + + + + + {/* assignment visible 정책 결정 후 문구 확정 */} +

+ If a problem is included in at least one ongoing, or upcoming + contest, it will automatically become invisible state in the ‘All + Problem List’. You cannot change its visibility until all the + ongoing or upcoming contests it is part of have ended. After the + contests are all over, you can manually make the problem visible + again. +

+
+
+
+
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemTable.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemTable.tsx new file mode 100644 index 0000000000..13f81c9f7d --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemTable.tsx @@ -0,0 +1,32 @@ +import { DataTable, DataTableRoot } from '@/app/admin/_components/table' +import { useMemo, type Dispatch, type SetStateAction } from 'react' +import type { AssignmentProblem } from '../_libs/schemas' +import { createColumns } from './AssignmentProblemColumns' + +interface AssignmentProblemTableProps { + problems: AssignmentProblem[] + setProblems: Dispatch> + disableInput: boolean +} + +export function AssignmentProblemTable({ + problems, + setProblems, + disableInput +}: AssignmentProblemTableProps) { + const columns = useMemo( + () => createColumns(setProblems, disableInput), + [setProblems, disableInput] + ) + + return ( + + + + ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTable.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTable.tsx new file mode 100644 index 0000000000..63eb3fad8d --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTable.tsx @@ -0,0 +1,93 @@ +'use client' + +import { + DataTable, + DataTableDeleteButton, + DataTableFallback, + DataTablePagination, + DataTableRoot, + DataTableSearchBar +} from '@/app/admin/_components/table' +import { DELETE_ASSIGNMENT } from '@/graphql/assignment/mutations' +import { GET_ASSIGNMENTS } from '@/graphql/assignment/queries' +import { useApolloClient, useMutation, useSuspenseQuery } from '@apollo/client' +import type { Route } from 'next' +import { columns } from './AssignmentTableColumns' + +interface AssignmentTableProps { + groupId: string +} + +const headerStyle = { + select: '', + title: 'w-3/5', + startTime: 'px-0 w-1/5', + participants: 'px-0 w-1/12', + isVisible: 'px-0 w-1/12' +} + +export function AssignmentTable({ groupId }: AssignmentTableProps) { + const { data } = useSuspenseQuery(GET_ASSIGNMENTS, { + variables: { + groupId: Number(groupId), + take: 300 + } + }) + + const assignments = data.getAssignments.map((assignment) => ({ + ...assignment, + id: Number(assignment.id) + })) + + return ( + +
+ + +
+ + `/admin/group/${groupId}/assignment/${data.id}` as Route + } + /> + +
+ ) +} + +function AssignmentsDeleteButton() { + const client = useApolloClient() + const [deleteAssignment] = useMutation(DELETE_ASSIGNMENT) + + const deleteTarget = (id: number) => { + return deleteAssignment({ + variables: { + groupId: 1, + assignmentId: id + } + }) + } + + const onSuccess = () => { + client.refetchQueries({ + include: [GET_ASSIGNMENTS] + }) + } + + return ( + + ) +} + +export function AssignmentTableFallback() { + return +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTableColumns.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTableColumns.tsx new file mode 100644 index 0000000000..e0ea772b2a --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTableColumns.tsx @@ -0,0 +1,185 @@ +'use client' + +import { DataTableColumnHeader } from '@/app/admin/_components/table/DataTableColumnHeader' +import { Checkbox } from '@/components/shadcn/checkbox' +import { Switch } from '@/components/shadcn/switch' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/shadcn/tooltip' +import { UPDATE_ASSIGNMENT_VISIBLE } from '@/graphql/assignment/mutations' +import { cn, dateFormatter } from '@/libs/utils' +import invisibleIcon from '@/public/icons/invisible.svg' +import visibleIcon from '@/public/icons/visible.svg' +import { useMutation } from '@apollo/client' +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import type { ColumnDef, Row } from '@tanstack/react-table' +import Image from 'next/image' +import { toast } from 'sonner' + +export interface DataTableAssignment { + id: number + title: string + startTime: string + endTime: string + description: string + participants: number + isVisible: boolean + isRankVisible: boolean +} + +function VisibleCell({ row }: { row: Row }) { + const [updateVisible] = useMutation(UPDATE_ASSIGNMENT_VISIBLE) + + return ( +
+ e.stopPropagation()} + id="hidden-mode" + checked={row.original.isVisible} + onCheckedChange={() => { + row.original.isVisible = !row.original.isVisible + const currentTime = dateFormatter(new Date(), 'YYYY-MM-DD HH:mm:ss') + const startTime = dateFormatter( + row.original.startTime, + 'YYYY-MM-DD HH:mm:ss' + ) + const endTime = dateFormatter( + row.original.endTime, + 'YYYY-MM-DD HH:mm:ss' + ) + if (currentTime > startTime && currentTime < endTime) { + toast.error('Cannot change visibility of ongoing assignment') + return + } + // TODO: assignment update API 수정되면 고치기 + updateVisible({ + variables: { + groupId: 1, + input: { + id: row.original.id, + title: row.original.title, + startTime: row.original.startTime, + endTime: row.original.endTime, + description: row.original.description, + isVisible: row.original.isVisible, + isRankVisible: row.original.isRankVisible + } + } + }) + }} + /> +
+ { + + + + + + + {row.original.isVisible ? ( +

This assignment is visible

+ ) : ( +

This assignment is not visible to users

+ )} + +
+
+
+ } +
+
+ ) +} + +export const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( + e.stopPropagation()} + checked={table.getIsAllPageRowsSelected()} + onCheckedChange={(value) => + table.toggleAllPageRowsSelected(Boolean(value)) + } + aria-label="Select all" + className="translate-y-[2px] bg-white" + /> + ), + cell: ({ row }) => ( + e.stopPropagation()} + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(Boolean(value))} + aria-label="Select row" + className="translate-y-[2px] bg-white" + /> + ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'title', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +

+ {row.getValue('title')} +

+ ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'startTime', + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => ( +

+ {`${dateFormatter(row.original.startTime, 'YY-MM-DD HH:mm')} ~ ${dateFormatter(row.original.endTime, 'YY-MM-DD HH:mm')}`} +

+ ), + size: 250 + }, + { + accessorKey: 'isVisible', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return + }, + size: 100 + } +] diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportDialog.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportDialog.tsx new file mode 100644 index 0000000000..9b3030185c --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportDialog.tsx @@ -0,0 +1,91 @@ +'use client' + +import { FetchErrorFallback } from '@/components/FetchErrorFallback' +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction +} from '@/components/shadcn/alert-dialog' +import { Button } from '@/components/shadcn/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/shadcn/dialog' +import { ErrorBoundary } from '@suspensive/react' +import { PlusCircleIcon } from 'lucide-react' +import { Suspense, useState } from 'react' +import type { AssignmentProblem } from '../_libs/schemas' +import { + ImportProblemTable, + ImportProblemTableFallback +} from './ImportProblemTable' + +interface ImportDialogProps { + problems: AssignmentProblem[] + setProblems: (problems: AssignmentProblem[]) => void +} + +export function ImportDialog({ problems, setProblems }: ImportDialogProps) { + const [showImportDialog, setShowImportDialog] = useState(false) + return ( + <> + + + + + + + Importing from Problem List + {/* 정책 결정 후 문구 확정 예정 */} + + If contest problems are imported from the ‘All Problem List’, the + problems will automatically become invisible state. + + + + + Cancel + + + + + + + + + + + Import Problem + + + }> + { + setProblems(problems) + setShowImportDialog(false) + }} + /> + + + + + + ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemButton.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemButton.tsx new file mode 100644 index 0000000000..f26dade76b --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemButton.tsx @@ -0,0 +1,51 @@ +import { useDataTable } from '@/app/admin/_components/table/context' +import { Button } from '@/components/shadcn/button' +import type { AssignmentProblem } from '../_libs/schemas' +import type { DataTableProblem } from './ImportProblemTableColumns' + +interface ImportProblemButtonProps { + onSelectedExport: (data: AssignmentProblem[]) => void +} + +export function ImportProblemButton({ + onSelectedExport +}: ImportProblemButtonProps) { + const { table } = useDataTable() + + const handleImportProblems = () => { + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original) + + const problems = selectedRows + .map((problem) => ({ + ...problem, + score: problem?.score ?? 0, + order: problem?.order ?? Number.MAX_SAFE_INTEGER + })) + .sort((a, b) => a.order - b.order) + + let order = 0 + const exportedProblems = problems.map((problem, index, arr) => { + if ( + index > 0 && + // NOTE: 만약 현재 요소가 새로 추가된 문제이거나 새로 추가된 문제가 아니라면 이전 문제와 기존 순서가 다를 때 + (arr[index].order === Number.MAX_SAFE_INTEGER || + arr[index - 1].order !== arr[index].order) + ) { + order++ + } + return { + ...problem, + order + } + }) + onSelectedExport(exportedProblems) + } + + return ( + + ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTable.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTable.tsx new file mode 100644 index 0000000000..97f7133259 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTable.tsx @@ -0,0 +1,101 @@ +import { + DataTable, + DataTableFallback, + DataTableLangFilter, + DataTableLevelFilter, + DataTablePagination, + DataTableRoot, + DataTableSearchBar +} from '@/app/admin/_components/table' +import { GET_PROBLEMS } from '@/graphql/problem/queries' +import { useSuspenseQuery } from '@apollo/client' +import { Language, Level } from '@generated/graphql' +import { toast } from 'sonner' +import type { AssignmentProblem } from '../_libs/schemas' +import { ImportProblemButton } from './ImportProblemButton' +import { + columns, + DEFAULT_PAGE_SIZE, + ERROR_MESSAGE, + MAX_SELECTED_ROW_COUNT +} from './ImportProblemTableColumns' + +export function ImportProblemTable({ + checkedProblems, + onSelectedExport +}: { + checkedProblems: AssignmentProblem[] + onSelectedExport: (selectedRows: AssignmentProblem[]) => void +}) { + const { data } = useSuspenseQuery(GET_PROBLEMS, { + variables: { + groupId: 1, + take: 500, + input: { + difficulty: [ + Level.Level1, + Level.Level2, + Level.Level3, + Level.Level4, + Level.Level5 + ], + languages: [Language.C, Language.Cpp, Language.Java, Language.Python3] + } + } + }) + + const problems = data.getProblems.map((problem) => ({ + ...problem, + id: Number(problem.id), + isVisible: problem.isVisible !== undefined ? problem.isVisible : null, + languages: problem.languages ?? [], + tag: problem.tag.map(({ id, tag }) => ({ + id: Number(id), + tag: { + ...tag, + id: Number(tag.id) + } + })), + score: checkedProblems.find((item) => item.id === Number(problem.id)) + ?.score, + order: checkedProblems.find((item) => item.id === Number(problem.id))?.order + })) + + const selectedProblemIds = checkedProblems.map((problem) => problem.id) + + return ( + +
+ + + + +
+ { + const selectedRowCount = table.getSelectedRowModel().rows.length + if ( + selectedRowCount < MAX_SELECTED_ROW_COUNT || + row.getIsSelected() + ) { + row.toggleSelected() + table.setSorting([{ id: 'select', desc: true }]) // NOTE: force to trigger sortingFn + } else { + toast.error(ERROR_MESSAGE) + } + }} + /> + +
+ ) +} + +export function ImportProblemTableFallback() { + return +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTableColumns.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTableColumns.tsx new file mode 100644 index 0000000000..f82f2f1d71 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTableColumns.tsx @@ -0,0 +1,149 @@ +import { DataTableColumnHeader } from '@/app/admin/_components/table/DataTableColumnHeader' +import { Badge } from '@/components/shadcn/badge' +import { Checkbox } from '@/components/shadcn/checkbox' +import type { Level } from '@/types/type' +import type { ColumnDef } from '@tanstack/react-table' +import { toast } from 'sonner' + +export interface DataTableProblem { + id: number + title: string + updateTime: string + difficulty: string + submissionCount: number + acceptedRate: number + languages: string[] + score?: number + order?: number +} + +export const DEFAULT_PAGE_SIZE = 5 +export const MAX_SELECTED_ROW_COUNT = 20 +export const ERROR_MESSAGE = `You can only import up to ${MAX_SELECTED_ROW_COUNT} problems in a assignment` +export const columns: ColumnDef[] = [ + { + accessorKey: 'select', + header: ({ table }) => ( + { + const currentPageNotSelectedCount = table + .getRowModel() + .rows.filter((row) => !row.getIsSelected()).length + const selectedRowCount = table.getSelectedRowModel().rows.length + + if ( + selectedRowCount + currentPageNotSelectedCount <= + MAX_SELECTED_ROW_COUNT + ) { + table.toggleAllPageRowsSelected() + table.setSorting([{ id: 'select', desc: true }]) // NOTE: force to trigger sortingFn + } else { + toast.error(ERROR_MESSAGE) + } + }} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + + ), + sortingFn: (rowA, rowB) => { + const aSelected = rowA.getIsSelected() + const bSelected = rowB.getIsSelected() + + if (aSelected === bSelected) { + return 0 + } + return aSelected ? 1 : -1 + }, + enableHiding: false + }, + { + accessorKey: 'title', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ {row.getValue('title')} +
+ ) + }, + enableHiding: false + }, + { + accessorKey: 'languages', + header: () => {}, + cell: () => {}, + filterFn: (row, id, value) => { + const languages = row.original.languages + if (!languages?.length) { + return false + } + + const langValue: string[] = row.getValue(id) + const valueArray = value as string[] + const result = langValue.some((language) => valueArray.includes(language)) + return result + } + }, + { + accessorKey: 'updateTime', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return
{row.original.updateTime.substring(2, 10)}
+ } + }, + { + accessorKey: 'difficulty', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const level: string = row.getValue('difficulty') + const formattedLevel = `Level ${level.slice(-1)}` + return ( +
+ + {formattedLevel} + +
+ ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + } + }, + { + accessorKey: 'submissionCount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return
{row.getValue('submissionCount')}
+ } + }, + { + accessorKey: 'acceptedRate', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const acceptedRate: number = row.getValue('acceptedRate') + const acceptedRateFloat = (acceptedRate * 100).toFixed(2) + return
{acceptedRateFloat}%
+ } + } +] diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_libs/schemas.ts b/apps/frontend/app/admin/group/[groupId]/assignment/_libs/schemas.ts new file mode 100644 index 0000000000..f09d265be2 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/_libs/schemas.ts @@ -0,0 +1,87 @@ +import * as v from 'valibot' + +export const createSchema = v.object({ + title: v.pipe( + v.string(), + v.minLength(1, 'The title must contain at least 1 character(s)'), + v.maxLength(200, 'The title can only be up to 200 characters long') + ), + + isRankVisible: v.boolean(), + isVisible: v.boolean(), + description: v.pipe( + v.string(), + v.minLength(1), + v.check((value) => value !== '

') + ), + startTime: v.date(), + endTime: v.date(), + enableCopyPaste: v.boolean(), + isJudgeResultVisible: v.boolean(), + invitationCode: v.nullable( + v.pipe( + v.string(), + v.regex(/^\d{6}$/, 'The invitation code must be a 6-digit number') + ) + ) +}) + +export const editSchema = v.object({ + id: v.number(), + ...createSchema.entries +}) + +export interface AssignmentProblem { + id: number + title: string + order: number + difficulty: string + score: number +} + +export interface ScoreSummary { + studentId: string + realName?: string | null + username: string + submittedProblemCount: number + totalProblemCount: number + assignmentScore: number + assignmentPerfectScore: number + problemScores: { + problemId: number + score: number + maxScore: number + }[] +} + +export interface ProblemData { + order: number + score: number + problemId: number +} + +export interface OverallSubmission { + title: string + studentId: string + realname?: string | null + username: string + result: string + language: string + submissionTime: string + codeSize?: number | null + ip?: string | null + id: number + order?: number | null + problemId: number +} + +export interface UserSubmission { + problemTitle: string + submissionResult: string + language: string + submissionTime: string + codeSize?: number | null + ip?: string | null + id: number + order?: number | null +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/create/_components/CreateAssignmentForm.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/create/_components/CreateAssignmentForm.tsx new file mode 100644 index 0000000000..0210c7c083 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/create/_components/CreateAssignmentForm.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useConfirmNavigationContext } from '@/app/admin/_components/ConfirmNavigation' +import { + CREATE_ASSIGNMENT, + IMPORT_PROBLEMS_TO_ASSIGNMENT +} from '@/graphql/assignment/mutations' +// import { UPDATE_ASSIGNMENT_PROBLEMS_ORDER } from '@/graphql/problem/mutations' +import { useMutation } from '@apollo/client' +import type { CreateAssignmentInput } from '@generated/graphql' +import { valibotResolver } from '@hookform/resolvers/valibot' +import type { Route } from 'next' +import { useRouter } from 'next/navigation' +import type { ReactNode } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import type { AssignmentProblem } from '../../_libs/schemas' +import { createSchema } from '../../_libs/schemas' + +interface CreateAssignmentFormProps { + groupId: string + children: ReactNode + problems: AssignmentProblem[] + setIsCreating: (isCreating: boolean) => void +} + +export function CreateAssignmentForm({ + groupId, + children, + problems, + setIsCreating +}: CreateAssignmentFormProps) { + const methods = useForm({ + resolver: valibotResolver(createSchema), + defaultValues: { + invitationCode: null, + isRankVisible: true, + isVisible: true, + enableCopyPaste: false, + isJudgeResultVisible: false + } + }) + + const { setShouldSkipWarning } = useConfirmNavigationContext() + const router = useRouter() + + const [createAssignment, { error }] = useMutation(CREATE_ASSIGNMENT) + const [importProblemsToAssignment] = useMutation( + IMPORT_PROBLEMS_TO_ASSIGNMENT + ) + // const [updateAssignmentProblemsOrder] = useMutation( + // UPDATE_ASSIGNMENT_PROBLEMS_ORDER + // ) + + const isSubmittable = (input: CreateAssignmentInput) => { + if (input.startTime >= input.endTime) { + toast.error('Start time must be less than end time') + return + } + + if ( + new Set(problems.map((problem) => problem.order)).size !== problems.length + ) { + toast.error('Duplicate problem order found') + return + } + onSubmit() + } + + const onSubmit = async () => { + const input = methods.getValues() + setIsCreating(true) + + const { data } = await createAssignment({ + variables: { + groupId: Number(groupId), + input + } + }) + + const assignmentId = Number(data?.createAssignment.id) + + if (error) { + toast.error('Failed to create assignment') + setIsCreating(false) + return + } + + await importProblemsToAssignment({ + variables: { + groupId: Number(groupId), + assignmentId, + problemIdsWithScore: problems.map((problem) => { + return { + problemId: problem.id, + score: problem.score + } + }) + } + }) + + // TODO: 백엔드 작업 완료되면 Assignment problem order 변경 기능 추가 + // const orderArray = problems + // .sort((a, b) => a.order - b.order) + // .map((problem) => problem.id) + + // await updateAssignmentProblemsOrder({ + // variables: { + // groupId: 1, + // assignmentId, + // orders: orderArray + // } + // }) + + setShouldSkipWarning(true) + toast.success('Assignment created successfully') + router.push(`/admin/group/${groupId}/assignment` as Route) + router.refresh() + } + + return ( +
+ {children} +
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/create/error.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/create/error.tsx new file mode 100644 index 0000000000..4c8d7d9857 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/create/error.tsx @@ -0,0 +1,23 @@ +'use client' + +import { ErrorDetail } from '@/components/ErrorDetail' +import { captureError } from '@/libs/captureError' +import { useEffect } from 'react' + +interface Props { + error: Error & { digest?: string } + reset: () => void +} + +export default function Error({ error }: Props) { + useEffect(() => { + captureError(error) + }, [error]) + + return ( + + ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/create/page.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/create/page.tsx new file mode 100644 index 0000000000..63accbffac --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/create/page.tsx @@ -0,0 +1,103 @@ +'use client' + +import { ConfirmNavigation } from '@/app/admin/_components/ConfirmNavigation' +import { DescriptionForm } from '@/app/admin/_components/DescriptionForm' +import { FormSection } from '@/app/admin/_components/FormSection' +import { SwitchField } from '@/app/admin/_components/SwitchField' +import { TimeForm } from '@/app/admin/_components/TimeForm' +import { TitleForm } from '@/app/admin/_components/TitleForm' +import { Button } from '@/components/shadcn/button' +import { ScrollArea } from '@/components/shadcn/scroll-area' +import type { Route } from 'next' +import Link from 'next/link' +import { useState } from 'react' +import { FaAngleLeft } from 'react-icons/fa6' +import { IoMdCheckmarkCircleOutline } from 'react-icons/io' +import { AssignmentProblemListLabel } from '../_components/AssignmentProblemListLabel' +import { AssignmentProblemTable } from '../_components/AssignmentProblemTable' +import { ImportDialog } from '../_components/ImportDialog' +import type { AssignmentProblem } from '../_libs/schemas' +import { CreateAssignmentForm } from './_components/CreateAssignmentForm' + +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + const [problems, setProblems] = useState([]) + const [isCreating, setIsCreating] = useState(false) + + return ( + + +
+
+ + + + Create Assignment +
+ + + + + + +
+ + + + + + +
+ + + + + + + + + + + +
+
+ + +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/page.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/page.tsx new file mode 100644 index 0000000000..7072896bdb --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/assignment/page.tsx @@ -0,0 +1,38 @@ +import { FetchErrorFallback } from '@/components/FetchErrorFallback' +import { Button } from '@/components/shadcn/button' +import { ErrorBoundary } from '@suspensive/react' +import { PlusCircleIcon } from 'lucide-react' +import type { Route } from 'next' +import Link from 'next/link' +import { Suspense } from 'react' +import { + AssignmentTable, + AssignmentTableFallback +} from './_components/AssignmentTable' + +export const dynamic = 'force-dynamic' + +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + return ( +
+
+
+

Assignment List

+

Here's a list you made

+
+ +
+ + }> + + + +
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/edit/page.tsx b/apps/frontend/app/admin/group/[groupId]/edit/page.tsx new file mode 100644 index 0000000000..122f78b7ef --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/edit/page.tsx @@ -0,0 +1,4 @@ +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + return
{groupId} Edit Page
+} diff --git a/apps/frontend/app/admin/group/[groupId]/error.tsx b/apps/frontend/app/admin/group/[groupId]/error.tsx new file mode 100644 index 0000000000..bc069a85aa --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/error.tsx @@ -0,0 +1,20 @@ +'use client' + +import { ErrorDetail } from '@/components/ErrorDetail' +import { captureError } from '@/libs/captureError' +import { useEffect } from 'react' + +interface Props { + error: Error & { digest?: string } + reset: () => void +} + +export default function Error({ error }: Props) { + useEffect(() => { + captureError(error) + }, [error]) + + return ( + + ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/exam/page.tsx b/apps/frontend/app/admin/group/[groupId]/exam/page.tsx new file mode 100644 index 0000000000..c6d18896f3 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/exam/page.tsx @@ -0,0 +1,4 @@ +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + return
{groupId} Exam Page
+} diff --git a/apps/frontend/app/admin/group/[groupId]/grade/page.tsx b/apps/frontend/app/admin/group/[groupId]/grade/page.tsx new file mode 100644 index 0000000000..9634d9a55b --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/grade/page.tsx @@ -0,0 +1,4 @@ +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + return
{groupId} Grade Page
+} diff --git a/apps/frontend/app/admin/group/[groupId]/layout.tsx b/apps/frontend/app/admin/group/[groupId]/layout.tsx new file mode 100644 index 0000000000..c0b653ce78 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/layout.tsx @@ -0,0 +1,51 @@ +'use client' + +import { Button } from '@/components/shadcn/button' +import { cn } from '@/libs/utils' +import { useState } from 'react' +import { MdClose, MdMenu } from 'react-icons/md' +import { GroupSideBar } from './_components/GroupSideBar' + +export default function Layout({ + children, + params +}: { + children: React.ReactNode + params: { groupId: string } +}) { + const { groupId } = params + const [isSideBarOpen, setIsSideBarOpen] = useState(true) + return ( +
+ {isSideBarOpen && ( + + )} +
+ {!isSideBarOpen && ( + + )} + {children} +
+
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/notice/page.tsx b/apps/frontend/app/admin/group/[groupId]/notice/page.tsx new file mode 100644 index 0000000000..856d67e786 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/notice/page.tsx @@ -0,0 +1,4 @@ +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + return
{groupId} Notice Page
+} diff --git a/apps/frontend/app/admin/group/[groupId]/page.tsx b/apps/frontend/app/admin/group/[groupId]/page.tsx new file mode 100644 index 0000000000..cb92db38d5 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/page.tsx @@ -0,0 +1,52 @@ +'use client' + +import { KatexContent } from '@/components/KatexContent' +import { Button } from '@/components/shadcn/button' +import { GET_GROUP } from '@/graphql/group/queries' +import periodIcon from '@/public/icons/period.svg' +import { useSuspenseQuery } from '@apollo/client' +import type { Route } from 'next' +import Image from 'next/image' +import Link from 'next/link' +import { FaAngleLeft, FaPencil } from 'react-icons/fa6' + +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + + const { data } = useSuspenseQuery(GET_GROUP, { + variables: { + groupId: Number(groupId) + } + }) + const group = data.getGroup + + return ( +
+
+
+ + + + {group.groupName} +
+ + + +
+
+

Invitation code: 000409

+
+ period + 2025 Spring +
+
+ +
+ ) +} diff --git a/apps/frontend/app/admin/group/[groupId]/qna/page.tsx b/apps/frontend/app/admin/group/[groupId]/qna/page.tsx new file mode 100644 index 0000000000..d8ec3b3d4c --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/qna/page.tsx @@ -0,0 +1,4 @@ +export default function Page({ params }: { params: { groupId: string } }) { + const { groupId } = params + return
{groupId} Q&A Page
+} diff --git a/apps/frontend/app/admin/group/[groupId]/user/_components/Columns.tsx b/apps/frontend/app/admin/group/[groupId]/user/_components/Columns.tsx new file mode 100644 index 0000000000..a31ef99e12 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/user/_components/Columns.tsx @@ -0,0 +1,34 @@ +import type { ColumnDef } from '@tanstack/react-table' + +interface DataTableGroupUser { + id: number + studentId: string + name: string + email: string + role: string +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'studentId', + header: 'Student ID', + cell: ({ row }) =>
{row.getValue('studentId')}
+ }, + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => { + return
{row.getValue('name') || '-'}
+ } + }, + { + accessorKey: 'email', + header: 'Email', + cell: ({ row }) =>
{row.getValue('email')}
+ }, + { + accessorKey: 'role', + header: 'Role', + cell: ({ row }) =>
{row.getValue('role')}
+ } +] diff --git a/apps/frontend/app/admin/group/[groupId]/user/_components/GroupUserTable.tsx b/apps/frontend/app/admin/group/[groupId]/user/_components/GroupUserTable.tsx new file mode 100644 index 0000000000..cd29f8ff9f --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/user/_components/GroupUserTable.tsx @@ -0,0 +1,42 @@ +'use client' + +import { + DataTable, + DataTableFallback, + DataTablePagination, + DataTableRoot +} from '@/app/admin/_components/table' +import { GET_GROUP_MEMBERS } from '@/graphql/user/queries' +import { useSuspenseQuery } from '@apollo/client' +import { columns } from './Columns' + +interface GroupUserTableProps { + groupId: string +} + +export function GroupUserTable({ groupId }: GroupUserTableProps) { + const { data } = useSuspenseQuery(GET_GROUP_MEMBERS, { + variables: { + groupId: Number(groupId), + cursor: 1, + take: 1000, + leaderOnly: false + } + }) + const users = data.getGroupMembers.map((member) => ({ + ...member, + id: member.userId + })) + + return ( + + {/* 유저를 눌렀을 때 해당 학생의 여러 assignment 점수들을 통합한 overall 페이지로 이동하게 만들 예정*/} + + + + ) +} + +export function GroupUserTableFallback() { + return +} diff --git a/apps/frontend/app/admin/group/[groupId]/user/page.tsx b/apps/frontend/app/admin/group/[groupId]/user/page.tsx new file mode 100644 index 0000000000..c3af830ee9 --- /dev/null +++ b/apps/frontend/app/admin/group/[groupId]/user/page.tsx @@ -0,0 +1,31 @@ +import { FetchErrorFallback } from '@/components/FetchErrorFallback' +import { Button } from '@/components/shadcn/button' +import { ErrorBoundary } from '@suspensive/react' +import { PlusCircleIcon } from 'lucide-react' +import { Suspense } from 'react' +import { + GroupUserTable, + GroupUserTableFallback +} from './_components/GroupUserTable' + +export const dynamic = 'force-dynamic' + +export default function User({ params }: { params: { groupId: string } }) { + const { groupId } = params + return ( +
+
+

User List

+ +
+ + }> + + + +
+ ) +} diff --git a/apps/frontend/app/admin/group/_components/Columns.tsx b/apps/frontend/app/admin/group/_components/Columns.tsx new file mode 100644 index 0000000000..ee2c987230 --- /dev/null +++ b/apps/frontend/app/admin/group/_components/Columns.tsx @@ -0,0 +1,84 @@ +import { DataTableColumnHeader } from '@/app/admin/_components/table/DataTableColumnHeader' +import { Badge } from '@/components/shadcn/badge' +import { Checkbox } from '@/components/shadcn/checkbox' +import type { SemesterSeason } from '@/types/type' +import type { ColumnDef } from '@tanstack/react-table' + +interface DataTableGroup { + id: number + groupName: string + description: string +} + +export const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( + e.stopPropagation()} + checked={table.getIsAllPageRowsSelected()} + onCheckedChange={(value) => + table.toggleAllPageRowsSelected(Boolean(value)) + } + aria-label="Select all" + className="translate-y-[2px] bg-white" + /> + ), + cell: ({ row }) => ( + e.stopPropagation()} + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(Boolean(value))} + aria-label="Select row" + className="translate-y-[2px] bg-white" + /> + ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'groupName', + header: () =>

Name

, + cell: ({ row }) => ( +

+ {row.getValue('groupName')} +

+ ) + }, + { + accessorKey: 'courseNum', + header: ({ column }) => ( +
+ +
+ ), + cell: () =>

DAS0000_00

+ }, + { + accessorKey: 'semester', + header: ({ column }) => ( +
+ +
+ ), + cell: () => { + const yearSeason = '2025 Spring' + const season = yearSeason.split(' ')[1] + return ( +
+ + {yearSeason} + +
+ ) + } + }, + { + accessorKey: 'members', + header: 'Members', + cell: () =>
49
+ } +] diff --git a/apps/frontend/app/admin/group/_components/GroupTable.tsx b/apps/frontend/app/admin/group/_components/GroupTable.tsx new file mode 100644 index 0000000000..7bf9ce2455 --- /dev/null +++ b/apps/frontend/app/admin/group/_components/GroupTable.tsx @@ -0,0 +1,83 @@ +'use client' + +import { + DataTable, + DataTableDeleteButton, + DataTableFallback, + DataTablePagination, + DataTableRoot, + DataTableSearchBar +} from '@/app/admin/_components/table' +import { DELETE_GROUP } from '@/graphql/group/mutation' +import { GET_GROUPS } from '@/graphql/group/queries' +import { useApolloClient, useMutation, useSuspenseQuery } from '@apollo/client' +import type { Route } from 'next' +import { columns } from './Columns' + +const headerStyle = { + select: '', + groupName: 'w-2/5', + courseNum: 'px-0 w-1/5', + semester: 'px-0 w-1/5', + members: 'px-0 w-1/6' +} + +export function GroupTable() { + const { data } = useSuspenseQuery(GET_GROUPS, { + variables: { + cursor: 1, + take: 5 + } + }) + const groups = data.getGroups.map((group) => ({ + id: Number(group.id), + groupName: group.groupName, + description: group.description + })) + + return ( + +
+ + {/* TODO: 백엔드 구현 이후 Duplicate 버튼 추가 예정 */} + +
+ `/admin/group/${data.id}` as Route} + /> + +
+ ) +} + +function ContestsDeleteButton() { + const client = useApolloClient() + const [deleteGroup] = useMutation(DELETE_GROUP) + + const deleteTarget = (id: number) => { + return deleteGroup({ + variables: { + groupId: id + } + }) + } + + const onSuccess = () => { + client.refetchQueries({ + include: [GET_GROUPS] + }) + } + + return ( + + ) +} + +export function GroupTableFallback() { + return +} diff --git a/apps/frontend/app/admin/group/error.tsx b/apps/frontend/app/admin/group/error.tsx new file mode 100644 index 0000000000..9c7781e690 --- /dev/null +++ b/apps/frontend/app/admin/group/error.tsx @@ -0,0 +1,18 @@ +'use client' + +import { ErrorDetail } from '@/components/ErrorDetail' +import { captureError } from '@/libs/captureError' +import { useEffect } from 'react' + +interface Props { + error: Error & { digest?: string } + reset: () => void +} + +export default function Error({ error }: Props) { + useEffect(() => { + captureError(error) + }, [error]) + + return +} diff --git a/apps/frontend/app/admin/group/page.tsx b/apps/frontend/app/admin/group/page.tsx new file mode 100644 index 0000000000..d32295bb7b --- /dev/null +++ b/apps/frontend/app/admin/group/page.tsx @@ -0,0 +1,21 @@ +import { FetchErrorFallback } from '@/components/FetchErrorFallback' +import { ErrorBoundary } from '@suspensive/react' +import { Suspense } from 'react' +import { GroupTable, GroupTableFallback } from './_components/GroupTable' + +export const dynamic = 'force-dynamic' + +export default function Page() { + return ( +
+
+

Group List

+
+ + }> + + + +
+ ) +} diff --git a/apps/frontend/app/admin/layout.tsx b/apps/frontend/app/admin/layout.tsx index d6cc87bb80..7376aceee4 100644 --- a/apps/frontend/app/admin/layout.tsx +++ b/apps/frontend/app/admin/layout.tsx @@ -3,6 +3,7 @@ import codedangLogo from '@/public/logos/codedang-with-text.svg' import Image from 'next/image' import Link from 'next/link' import { ClientApolloProvider } from './_components/ApolloProvider' +import { GroupAdminSideBar } from './_components/GroupAdminSideBar' import { SideBar } from './_components/SideBar' export default function Layout({ children }: { children: React.ReactNode }) { @@ -20,9 +21,10 @@ export default function Layout({ children }: { children: React.ReactNode }) { height={28} /> - - + + {/* */} + {/*TODO: role이 groupAdmin인지 확인하고 아니면 그냥 SideBar를 보여주도록 할 예정 */} {/*